Solana[part6]_社交项目1-合约部分文档

Solana[part6]_社交项目1-合约部分

社交项目地址链接

🔗 合约地址:https://github.com/SoniaChan33/sol-friend

🔗 客户端地址: https://github.com/SoniaChan33/solana-friend-cli

一、项目概述

本项目是基于Solana区块链的去中心化社交应用合约,使用Rust语言开发。合约支持用户账户初始化、关注/取消关注其他用户、发布内容、查询粉丝列表及查询帖子等核心社交功能。通过Solana的Program Derived Address (PDA)机制管理用户数据,使用Borsh进行数据序列化/反序列化,确保链上数据的高效存储与读取。

二、核心模块与数据结构

1. 指令定义(instruction.rs

定义了合约支持的所有操作指令,通过SocialInstruction枚举实现,包含以下指令:

指令名 功能描述 参数说明
InitializeUser 初始化用户账户( profile 或 post 类型) seed_type: 账户类型(“profile” 或 “post”)
FollowUser 关注指定用户 user_to_follow: 被关注用户的Pubkey
UnfollowUser 取消关注指定用户 user_to_unfollow: 被取消关注用户的Pubkey
QueryFollowers 查询当前用户的粉丝列表 无参数
PostContent 发布内容 content: 帖子内容字符串
QueryPosts 查询用户的帖子信息 无参数

2. 状态数据结构(state.rs

定义了链上存储的数据结构及操作方法,核心结构如下:

(1)UserProfile

存储用户的粉丝列表信息:

pub struct UserProfile {
    pub data_len: u16,       // 粉丝数量(与followers.len()一致,用于快速计算存储空间)
    pub followers: Vec<Pubkey>, // 粉丝的Pubkey列表
}

核心方法

  • new():初始化空的用户资料
  • follow(&mut self, user: Pubkey):添加粉丝(去重)
  • unfollow(&mut self, user_to_follow: Pubkey):移除粉丝(更新数量)

(2)UserPost

记录用户发布的帖子数量:

pub struct UserPost {
    pub post_count: u16, // 帖子总数
}

核心方法

  • new():初始化帖子计数器(初始为0)
  • add_post(&mut self):发布新帖子时递增计数器
  • get_count(&self):获取当前帖子总数

(3)Post

存储单条帖子的具体内容:

pub struct Post {
    pub content: String,  // 帖子内容
    pub timestamp: u64,   // 发布时间戳(基于Solana网络时钟)
}

核心方法

  • new(content: String, timestamp: u64):创建新帖子实例

三、指令处理逻辑(processor.rs

Processor结构体实现了所有指令的具体处理逻辑,核心流程如下:

1. initialize_user:初始化用户账户

  • 功能:创建用户的PDA账户(分为profilepost两种类型),用于存储用户资料或帖子计数器。
  • 流程
    1. 解析账户信息(用户账户、PDA账户、系统程序);
    2. 基于用户Pubkey和seed_type(“profile"或"post”)生成PDA地址,验证传入的PDA账户是否匹配;
    3. 计算账户所需存储空间(profile账户根据最大粉丝数计算,post账户固定大小);
    4. 调用系统程序创建PDA账户(预存足够租金以豁免租金);
    5. 初始化对应的数据结构(UserProfileUserPost)并序列化到PDA账户中。

2. follow_user:关注用户

  • 功能:将被关注用户的Pubkey添加到当前用户的UserProfile粉丝列表中。
  • 流程
    1. 解析PDA账户(存储当前用户的UserProfile);
    2. 读取data_len计算当前粉丝列表的存储空间,反序列化UserProfile
    3. 调用follow方法添加粉丝(自动去重);
    4. 将更新后的UserProfile重新序列化并写入PDA账户。

3. unfollow_user:取消关注用户

  • 功能:从当前用户的UserProfile粉丝列表中移除指定用户的Pubkey。
  • 流程
    1. 解析PDA账户(存储当前用户的UserProfile);
    2. 反序列化UserProfile
    3. 调用unfollow方法移除粉丝(更新data_len);
    4. 将更新后的UserProfile重新序列化并写入PDA账户。

4. query_followers:查询粉丝列表

  • 功能:读取当前用户的UserProfile并输出粉丝列表。
  • 流程
    1. 解析PDA账户(存储当前用户的UserProfile);
    2. 反序列化UserProfile并通过msg!打印粉丝列表。

5. post_content:发布内容

  • 功能:创建新的帖子PDA账户,存储帖子内容和时间戳,并更新用户的帖子计数器。
  • 流程
    1. 解析账户信息(用户账户、帖子计数器PDA、新帖子PDA、系统程序);
    2. 读取当前用户的UserPost,递增post_count并重新序列化;
    3. 基于用户Pubkey、"post"种子和最新post_count生成新帖子的PDA地址;
    4. 计算帖子所需存储空间(基于Post结构体大小),创建新帖子PDA账户;
    5. 将帖子内容和当前时间戳(通过Solana时钟获取)序列化到新帖子PDA中。

6. query_post:查询帖子信息

  • 功能:读取用户的帖子计数器和指定帖子的内容。
  • 流程
    1. 解析账户信息(帖子计数器PDA、目标帖子PDA);
    2. 分别反序列化UserPostPost,通过msg!打印帖子数量和内容。

四、遇到的坑

1. 关于Rust借用规则(follow_user中的作用域)

{
    let data = &pda_account.data.borrow(); // 不可变引用
    // ... 读取data计算size
} // data的生命周期结束,不可变引用释放

原因:Rust的借用规则禁止同一作用域内同时存在不可变引用可变引用。此处用作用域限制data(不可变引用)的生命周期,确保后续调用try_borrow_mut_data()(获取可变引用)时不会触发借用冲突。

2. 数组切片的使用(follow_user中的&data[..U16_SIZE]

let len: &[u8] = &data[..U16_SIZE]; // U16_SIZE=2

原因UserProfiledata_lenu16类型(占2字节),存储在账户数据的前2字节。通过数组切片提取前2字节,再转换为u16,用于计算粉丝列表的总存储空间(data_len即粉丝数量)。

3. 序列化的必要性(修改数据后重新序列化)

user_profile.serialize(&mut *pda_account.try_borrow_mut_data()?)?;

原因:链上账户存储的是字节数据,user_profile是内存中的结构体实例。当结构体被修改(如添加粉丝)后,必须通过serialize将内存中的最新状态转换为字节流,写入账户数据区域,否则修改不会被持久化到链上。

4. PDA地址计算(post_content中的种子)

Pubkey::find_program_address(&[user_account.key.as_ref(), "post".as_bytes(), &[count as u8]], program_id)

原因:PDA(程序派生地址)需通过“种子+程序ID”生成,确保唯一性。此处使用:

  • 用户Pubkey(区分不同用户);
  • "post"字符串(标识帖子类型);
  • 帖子计数器count(区分同一用户的不同帖子);
    三者组合作为种子,确保每个帖子的PDA地址唯一。

5. unwrap()?的区别

  • unwrap():当ResultOk时返回值,为Err时直接触发程序恐慌(panic),适合确定不会出错的场景(如已知序列化Post必然成功)。
  • ?:当ResultErr时将错误返回给调用者,由上层处理,适合需要传播错误的场景(如账户解析失败)。

6. create_account指令参数

system_instruction::create_account(
    user_account.key,    // 资金来源(创建者)
    &pda,                // 新账户地址(PDA)
    lamports,            // 初始Lamports(需覆盖租金)
    space as u64,        // 账户存储空间
    program_id           // 账户所有者(当前合约)
)

原因:创建Solana账户必须指定:资金来源(支付租金)、账户地址、存储空间(决定租金金额)、所有者(只有所有者程序可修改账户数据)。

7. invoke_signed中的signer_seeds

&[&[user_account.key.as_ref(), "post".as_bytes(), &[count as u8], &[bump_seed]]]

原因:PDA本身不是私钥控制的账户,无法直接签名。signer_seeds用于证明“当前合约有权限操作该PDA”——通过种子重新计算PDA地址,验证与目标地址一致,从而获得临时签名权限,确保账户创建的安全性。

8. mod语句调用注意

在 Rust 中,mod 语句用于告诉编译器包含某个模块的源文件。只有在模块被声明为 pub mod xxx; 后,其他文件(如 processor.rs)才能通过 use crate::xxx::… 访问其内容。

原因如下:

state.rs 只是物理存在于 src 目录下,只有在 lib.rs 里用 pub mod state; 声明后,state 模块才会被编译进 crate 并对外可见。
如果不在 lib.rs 里声明,crate::state 这个路径在其他文件里就找不到。
processor.rs 只是使用 state,但模块的“引入”必须在 crate 的根(即 lib.rs)声明。

五、程序入口(lib.rs

定义了Solana程序的入口点process_instruction,将指令处理逻辑委托给Processor::process_instruction,符合Solana程序的标准入口规范。

六、总结

本合约通过PDA管理用户数据,结合Borsh序列化和Solana系统指令,实现了去中心化社交应用的核心功能。关键设计点包括:用PDA确保数据所有权、通过计数器和种子生成唯一帖子地址、严格遵循Rust内存安全规则处理账户数据。