本文详细介绍了在Starknet上部署ERC-20合约的完整流程,包括使用Starknet Foundry(sncast)和Starknet.js两种方法。内容涵盖账户设置、合约类声明、实例部署以及与部署后合约的交互。提供了清晰的代码示例和截图,适合希望学习Starknet开发的人员。
在上一篇文章中,我们介绍了 Starknet 的声明-部署模型,包括普通合约和账户合约的部署路径。本文将这些概念付诸实践。我们将以 ERC-20 章节中的 ERC-20 合约作为部署示例,使用 Starknet Foundry(sncast)和 Starknet.js 演示完整的部署工作流:设置用于签署交易的账户、声明 ERC-20 合约类、部署 ERC-20 合约实例,以及与已部署的合约进行交互。
使用 scarb new erc20 创建一个新的 Scarb 项目,用 cd erc20 进入项目目录,然后将 src/lib.cairo 的内容替换为以下代码:
use starknet::ContractAddress;
#[starknet::interface]
pub trait IERC20<TContractState> {
fn total_supply(self: @TContractState) -> u256;
fn balance_of(self: @TContractState, account: ContractAddress) -> u256;
fn allowance(self: @TContractState, owner: ContractAddress, spender: ContractAddress) -> u256;
fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;
fn transfer_from(
ref self: TContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256,
) -> bool;
fn approve(ref self: TContractState, spender: ContractAddress, amount: u256) -> bool;
fn name(self: @TContractState) -> ByteArray;
fn symbol(self: @TContractState) -> ByteArray;
fn decimals(self: @TContractState) -> u8;
fn mint(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool; // 用于测试目的
}
#[starknet::contract]
pub mod ERC20 {
use starknet::{ContractAddress, get_caller_address};
use starknet::storage::{
Map, StoragePointerWriteAccess, StoragePointerReadAccess, StoragePathEntry,
};
#[storage]
pub struct Storage {
balances: Map<ContractAddress, u256>,
allowances: Map<
(ContractAddress, ContractAddress), u256,
>, // (owner, spender) -> amount
token_name: ByteArray,
symbol: ByteArray,
decimal: u8,
total_supply: u256,
owner: ContractAddress,
}
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
Transfer: Transfer,
Approval: Approval,
}
#[derive(Drop, starknet::Event)]
pub struct Transfer {
#[key]
from: ContractAddress,
#[key]
to: ContractAddress,
amount: u256,
}
#[derive(Drop, starknet::Event)]
pub struct Approval {
#[key]
owner: ContractAddress,
#[key]
spender: ContractAddress,
value: u256,
}
#[constructor]
fn constructor(ref self: ContractState, owner: ContractAddress) {
self.token_name.write("Rare Token");
self.symbol.write("RST");
self.decimal.write(18);
self.owner.write(owner);
}
#[abi(embed_v0)]
impl ERC20Impl of super::IERC20<ContractState> {
fn total_supply(self: @ContractState) -> u256 {
self.total_supply.read()
}
fn balance_of(self: @ContractState, account: ContractAddress) -> u256 {
let balance = self.balances.entry(account).read();
balance
}
fn name(self: @ContractState) -> ByteArray {
self.token_name.read()
}
fn symbol(self: @ContractState) -> ByteArray {
self.symbol.read()
}
fn decimals(self: @ContractState) -> u8 {
self.decimal.read()
}
fn allowance(
self: @ContractState, owner: ContractAddress, spender: ContractAddress,
) -> u256 {
let allowance = self.allowances.entry((owner, spender)).read();
allowance
}
fn transfer(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool {
let sender = get_caller_address();
let sender_prev_balance = self.balances.entry(sender).read();
let recipient_prev_balance = self.balances.entry(recipient).read();
assert(sender_prev_balance >= amount, 'Insufficient amount');
self.balances.entry(sender).write(sender_prev_balance - amount);
self.balances.entry(recipient).write(recipient_prev_balance + amount);
assert(
self.balances.entry(recipient).read() > recipient_prev_balance,
'Transaction failed',
);
self.emit(Transfer { from: sender, to: recipient, amount });
true
}
fn transfer_from(
ref self: ContractState,
sender: ContractAddress,
recipient: ContractAddress,
amount: u256,
) -> bool {
let spender = get_caller_address();
let spender_allowance = self.allowances.entry((sender, spender)).read();
let sender_balance = self.balances.entry(sender).read();
let recipient_balance = self.balances.entry(recipient).read();
assert(amount <= spender_allowance, 'amount exceeds allowance');
assert(amount <= sender_balance, 'amount exceeds balance');
self.allowances.entry((sender, spender)).write(spender_allowance - amount);
self.balances.entry(sender).write(sender_balance - amount);
self.balances.entry(recipient).write(recipient_balance + amount);
self.emit(Transfer { from: sender, to: recipient, amount });
true
}
fn approve(ref self: ContractState, spender: ContractAddress, amount: u256) -> bool {
let caller = get_caller_address();
self.allowances.entry((caller, spender)).write(amount);
self.emit(Approval { owner: caller, spender, value: amount });
true
}
fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool {
let caller = get_caller_address();
assert(caller == self.owner.read(), 'Call not owner');
let previous_total_supply = self.total_supply.read();
let previous_balance = self.balances.entry(recipient).read();
self.total_supply.write(previous_total_supply + amount);
self.balances.entry(recipient).write(previous_balance + amount);
let zero_address: ContractAddress = 0.try_into().unwrap();
self.emit(Transfer { from: zero_address, to: recipient, amount });
true
}
}
}
我们将使用两种方法部署此合约:Starknet Foundry(sncast)和 Starknet.js。两种方法遵循相同的部署步骤:
sncast)部署我们需要一个账户合约来签署交易并支付部署费用。如果你已经配置了 sncast 账户,可以跳到声明步骤。否则,我们创建一个。
使用 sncast 创建账户的命令是:
sncast account create --network <NETWORK_NAME> --name <ACCOUNT_NAME>
其中:
<NETWORK_NAME> 是你要部署到的网络(例如 sepolia、mainnet)<ACCOUNT_NAME> 是你选择的本地标识符,用于引用此账户(例如 my_account、deployer_account)例如,要在 Sepolia 上创建账户:
sncast account create --network sepolia --name new_account_1
运行上述命令时,sncast 会:
~/.starknet_accounts/starknet_open_zeppelin_accounts.json)。使用以下命令查看文件:cat ~/.starknet_accounts/starknet_open_zeppelin_accounts.json
注意:此处我们使用
account create(而不是declare),因为账户合约类通常由提供者预先声明。在本例中,OpenZeppelin 已经声明了我们使用的账户合约类,因此我们只是创建它的一个实例,而不是声明一个新的。
运行 create 命令后,你的输出应如下所示:

如果你在 Voyager 上搜索该地址,会注意到账户地址存在,但状态为“未初始化”。这表明账户合约尚未部署。

要部署新创建的账户,从输出中复制地址,并从 Starknet 水龙头 向其注入 STRK 代币。这就是我们在上一篇文章中讨论的反事实部署过程:账户使用预先注入的地址支付自己的部署费用,从而产生一个独立存在且不与任何其他账户关联的新账户。
获得测试代币后,使用以下命令部署账户:
sncast account deploy --network sepolia --name <ACCOUNT_NAME>
将 <ACCOUNT_NAME> 替换为创建账户时使用的名称。对于此示例,我们使用 new_account_1,因为这是我们在 account create 步骤中使用的名称:
sncast account deploy --network sepolia --name new_account_1
运行上述命令后,sncast 会提示你将该账户设为本地默认账户或全局默认账户。本地默认仅适用于当前项目,全局默认适用于所有 Scarb 项目。对于本教程,选择本地。确认后,账户即部署完成:

如果你在 Voyager 上检查交易,会注意到交易类型为 DEPLOY_ACCOUNT。这是专用于首次创建账户的协议级交易类型,无需现有账户来赞助部署。

在此交易期间,定序器验证部署,使用你的公钥运行账户构造函数,并从预先注入的地址中扣除部署费用。你的账户合约现在已在 Starknet 上激活。
与由提供者预先声明的账户合约不同,普通合约(如我们的 ERC-20)在部署之前必须由我们自行声明。
声明普通合约的语法是:
sncast --account <ACCOUNT_NAME> \
declare \
--url <URL> \
--contract-name <CONTRACT_NAME>
其中:
<ACCOUNT_NAME>:你之前创建并部署的账户名称<URL>:你所部署网络对应的 RPC 端点 URL<CONTRACT_NAME>:合约模块的名称,例如 mod ERC20 {} 中的 ERC20要在 Sepolia 上声明我们的 ERC-20 合约,运行以下命令,将 {apiKey} 替换为你的 Alchemy API 密钥,并将 new_account_1 替换为你的账户名称:
sncast --account new_account_1 \
declare \
--url https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_10/{apiKey} \
--contract-name ERC20
回想一下,声明会将你的合约代码注册到链上,并返回一个类哈希。以下是此过程中发生的事情:
sncast 读取你的 Sierra 文件,在本地编译为 CASM(Cairo 汇编),并从该 CASM 计算编译后的类哈希sncast 将 Sierra 类和本地计算的编译后类哈希一起发送给定序器,并由你的账户签名输出同时显示交易哈希和类哈希。复制类哈希,因为稍后需要用它来部署合约实例:

sncast 在输出中提供了一条可直接使用的部署命令。你可以直接复制生成的命令,或者使用下面更易读的格式。两种方法效果相同。
声明合约类后,我们可以从该类哈希部署任意数量的实例。sncast deploy 命令封装了与 UDC 的交互,并在底层处理部署。
部署合约的语法是:
sncast \
--account <ACCOUNT_NAME> \
deploy \
--class-hash <CLASS_HASH> \
--arguments "<CONSTRUCTOR_ARGS>" \
--url <URL>
默认情况下,sncast 会生成一个随机 salt,以确保合约地址唯一。如果你需要预测或重现生成的合约地址,可以通过 --salt 传递一个特定值,例如 --salt 0x146。你还可以传递 --unique,这会使用你的部署者地址修改 salt,以确保地址对你账户而言是唯一的。在本教程中,我们省略这两个标志,让 sncast 自动生成 salt。
要部署 ERC-20 合约的实例,运行以下命令。将 <OWNER_ADDRESS> 替换为你的实际账户地址,{apiKey} 替换为你的 Alchemy API 密钥。你可以使用自己声明步骤得到的类哈希,也可以使用下面提供的类哈希:
sncast \
--account new_account_1 \
deploy \
--class-hash 0x23a5a4819dcac3a6b5fe724596647d3fc1f176ca565a0fb908c4457f1cc875b \
--arguments "<OWNER_ADDRESS>" \
--url https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_10/{apiKey}
所有者地址可以用单引号或双引号包裹。该地址将被设为合约所有者,这意味着只有此账户有权铸造代币,因此请确保它是你打算用于与合约交互的地址。
你将在终端中收到交易收据,其中包含合约地址:

你可以在 Voyager 上验证部署。注意交易类型是 INVOKE,操作是 deployContract。这确认了 sncast 通过 UDC 部署了合约:

点击 交易 哈希打开交易详情。这里显示了传递给 UDC 的 deployContract 函数的精确参数:

当你运行合约部署命令时,sncast 自动处理了 UDC 交互:
--salt,sncast 生成一个随机 salt(如输入数据表第二行所示的 0xd815d280876b878e),以确保合约地址唯一INVOKE 交易,包含:
classHash:0x23a5a4819dcac3a6b5fe724596647d3fc1f176ca565a0fb908c4457f1cc875b(输入数据表第一行)salt:0xd815d280876b878e(输入数据表第二行)notFromZero:0x0(false);由于没有传递 --unique,合约地址是根据部署者地址而非零地址推导的calldata:[0x14154fb6dd088b5ceb46df635ecce6e1a9b0455357931ac7df4263a7dbf39a9](输入数据表第四行 - 你的构造函数参数,即所有者地址)deployContract 创建你的合约实例,返回合约地址 0x02ceed65a4bd731034c01113685c831b01c15d7d432f71afb1cf1634b53a2125(在输出数据字段中用黄色高亮显示)作为使用 Starknet Foundry 的 sncast 命令的替代方案,我们也可以使用 Starknet.js 部署合约。这种方法允许以编程方式部署,从而更容易实现部署自动化或将其集成到开发工作流中。
由于 ERC-20 合约类已经使用 sncast 方法在链上声明,如果从同一个项目使用相同的编译器版本再次声明相同的代码,将导致“合约已声明”错误,因为相同的类哈希已存在于链上。为避免此错误,我们将创建一个新的 Scarb 项目:
scarb new erc20_starknetjs
cd erc20_starknetjs
将合约设置部分的相同 ERC-20 合约代码复制到 src/lib.cairo 中,然后使用 scarb build 编译项目。这样就为使用 Starknet.js 声明和部署创建了一个干净的状态。
现在,为部署代码创建一个 scripts 文件夹:
mkdir scripts
安装部署脚本所需的依赖包:
npm install starknet dotenv
npm install -D typescript @types/node tsx
各包用途:
.env 文件加载环境变量通过向 package.json 添加 "type": "module" 字段,将项目配置为使用 ES 模块语法(import/export)而非 CommonJS(require/module.exports):
{
"dependencies": {
"dotenv": "^17.2.3",
"starknet": "^9.2.1"
},
"type": "module",
"devDependencies": {
"@types/node": "^25.0.3",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}
这允许我们使用 import 语句,而不是较旧的 require() 语法。
在项目根目录创建一个 .env 文件:
PRIVATE_KEY=your_private_key_here
ALCHEMY_API_KEY=your_alchemy_api_key_here
OWNER_ADDRESS=your_owner_address_here
由于我们将通过 Voyager 调用已部署合约的写函数,你需要连接一个钱包,如 Ready 或 Braavos。你有两个选项:
选项 1:导入我们创建的账户(推荐)
使用 ~/.starknet_accounts/starknet_open_zeppelin_accounts.json 中的私钥,将我们之前创建的账户导入 Ready 钱包。按照此指南导入你的账户:
导入后,在 .env 文件中设置以下内容:
OWNER_ADDRESS:导入账户的地址(与 JSON 文件中的相同)PRIVATE_KEY:JSON 文件中的私钥选项 2:使用你现有的账户
如果你已有 Ready 或 Braavos 的 Sepolia 账户,可以使用这些凭证:
OWNER_ADDRESS:你现有钱包的地址PRIVATE_KEY:你现有钱包的私钥将
.env添加到.gitignore文件中,以避免将私钥提交到版本控制。
在运行部署脚本之前,你需要从 Sierra 文件生成 CASM 文件。较新版本的 Scarb 在运行 scarb build 时不会自动生成 CASM 文件。
这需要 Sierra 编译器,你可以通过运行以下命令安装:
cargo install starknet-sierra-compile
安装后,通过运行以下命令将 Sierra 文件编译为 CASM:
starknet-sierra-compile \
target/dev/erc20_starknetjs_ERC20.contract_class.json \
target/dev/erc20_starknetjs_ERC20.compiled_contract_class.json

此命令接受两个参数:
target/dev/erc20_starknetjs_ERC20.contract_class.json —— scarb build 生成的 Sierra 文件target/dev/erc20_starknetjs_ERC20.compiled_contract_class.json —— 将要创建的 CASM 字节码文件文件名遵循
{package_name}_{contract_module_name}的模式。如果你的合约名称与erc20_starknetjs_ERC20不同,请相应调整两个文件路径,然后运行命令。
你会在 target/dev 目录中看到新生成的 .compiled_contract_class.json 文件,其中包含部署所需的 CASM 字节码。

我们创建的脚本将自动化部署过程的每一步。它会连接到 Starknet Sepolia,使用你的私钥初始化你的账户,并读取 Sierra 和 CASM 文件。
创建 scripts/deploy.ts,包含以下代码:
import { Account, RpcProvider, json, CallData } from "starknet";
import fs from "fs";
import * as dotenv from "dotenv";
dotenv.config();
async function main() {
// 使用 Alchemy RPC URL 设置提供者
const provider = new RpcProvider({
nodeUrl: `https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_10/${process.env.ALCHEMY_API_KEY}`,
});
// 设置账户
const account = new Account({
provider: provider,
address: process.env.OWNER_ADDRESS!,
signer: process.env.PRIVATE_KEY!,
});
// 读取已编译的合约文件
const compiledSierra = json.parse(
fs
.readFileSync("./target/dev/erc20_starknetjs_ERC20.contract_class.json")
.toString("ascii")
);
const compiledCasm = json.parse(
fs
.readFileSync(
"./target/dev/erc20_starknetjs_ERC20.compiled_contract_class.json"
)
.toString("ascii")
);
// 声明合约
console.log("\n声明合约...");
const declareResponse = await account.declare({
contract: compiledSierra,
casm: compiledCasm,
});
console.log(
"声明交易哈希:",
declareResponse.transaction_hash
);
await provider.waitForTransaction(declareResponse.transaction_hash);
// 准备构造函数参数
const myCallData = new CallData(compiledSierra.abi);
const constructorCalldata = myCallData.compile("constructor", {
owner: account.address,
});
// 部署合约
console.log("\n部署合约...");
const deployResponse = await account.deployContract({
classHash: declareResponse.class_hash,
constructorCalldata: constructorCalldata,
});
console.log("部署交易哈希:", deployResponse.transaction_hash);
await provider.waitForTransaction(deployResponse.transaction_hash);
console.log("\n部署摘要:");
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.log("类哈希:", declareResponse.class_hash);
console.log("合约地址:", deployResponse.contract_address);
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error("\n部署失败:", error);
process.exit(1);
});
如果你的合约名称与 erc20_starknetjs_ERC20 不同,请将部署脚本中的合约名称更新为正确的名称。
要找到你项目的正确名称,请运行:
ls target/dev/*.contract_class.json
例如,如果你看到 my_token_MyERC20.contract_class.json,请按如下方式更新文件路径:
// 将:
fs.readFileSync("./target/dev/erc20_starknetjs_ERC20.contract_class.json");
// 改为:
fs.readFileSync("./target/dev/my_token_MyERC20.contract_class.json");
对 .contract_class.json 和 .compiled_contract_class.json 两个文件都执行相同操作。
与 sncast 的主要区别:
Starknet.js 采用了与 Starknet Foundry 命令行方法不同的方式。它没有分别运行声明和部署命令,而是通过 declareAndDeploy() 函数一步完成两个步骤,这意味着你不必在命令之间复制类哈希。虽然两种方法都依赖 UDC 进行部署,但 Starknet.js 允许我们以编程方式控制诸如 salt 生成和部署类型等方面,从而更容易构建带有适当错误处理的自动化部署脚本。
运行以下命令来部署你的合约:
npx tsx scripts/deploy.ts
如果成功,你将看到如下输出:

现在合约已部署,我们可以通过 Voyager 的 Web 界面与之交互。转到 Voyager Sepolia 上的已部署合约。点击“写入合约”标签页。此界面显示了合约的所有公开函数。
连接你的钱包(Ready 或 Braavos),并确保你使用的钱包地址是你设置为所有者的那个。这一点很重要,因为只有所有者才能调用受限制的函数,例如 mint。

铸造代币:在写入部分,找到 mint 函数并填写参数:

点击“写入”并在你的钱包中确认交易。确认后,你将收到交易哈希。
切换到“读取”部分,找到 total_supply。点击“查询”查看结果:

检查余额:在同一个“读取”部分,使用 balance_of,传入你刚刚铸造代币的地址。点击“查询”确认你收到了 1200000000000000000(等于 1000 个铸造的 Rare 代币)。

你可以进一步尝试:将代币转账到其他地址、铸造更多代币或检查授权,以全面测试合约的功能。
Starknet.js 与 ERC-20 交互我们已经了解了如何通过 Voyager Web 界面与合约交互。现在让我们改用 Starknet.js 来做同样的事情。下面的脚本向一个账户铸造代币,并显示包含事件详情的交易确认。创建 scripts/interact.ts,包含以下代码:
import { Account, RpcProvider, Contract } from "starknet";
import * as dotenv from "dotenv";
dotenv.config();
async function interactWithERC20() {
// 初始化 Sepolia 测试网的提供者
const provider = new RpcProvider({
nodeUrl: `https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_10/${process.env.ALCHEMY_API_KEY}`,
});
// 初始化账户
const account = new Account({
provider: provider,
address: process.env.OWNER_ADDRESS!,
signer: process.env.PRIVATE_KEY!,
});
// 替换为你的已部署合约地址
const contractAddress =
"0x1416c331f53ddfc9cd022984a4ef6b9871a3c82e59904de2cd8214cfa59f00c";
// 从已部署合约获取 ABI
const { abi } = await provider.getClassAt(contractAddress);
if (abi === undefined) {
throw new Error("ABI not found");
}
// 创建合约实例
const contract = new Contract({
abi,
address: contractAddress,
providerOrAccount: provider,
});
// 准备 mint 调用的数据
const call = contract.populate("mint", [
account.address,
3000000000000000000000n, // 铸造 3000 个代币(18 位小数)
]);
console.log("铸造代币中...");
// 通过账户执行交易
const { transaction_hash: mintTxHash } = await account.execute(call);
console.log("交易哈希:", mintTxHash);
// 等待交易确认
const receipt = await provider.waitForTransaction(mintTxHash);
if (receipt.isSuccess()) {
console.log("\n铸造成功!");
// 解析并显示发出的事件
const events = contract.parseEvents(receipt);
console.log("\n发出的事件:");
console.log(events);
} else {
console.error("交易失败");
}
}
// 运行函数并处理任何错误
interactWithERC20().catch(console.error);
将 contractAddress 替换为你的已部署合约地址,并使用以下命令运行脚本:
npx tsx scripts/interact.ts
你应该会看到输出显示铸造成功,并发出从零地址到你的账户的 Transfer 事件,如下所示:

你也可以通过更改合约地址,与现有的 ERC-20 代币(如 STRK)交互。创建一个新的 interact_existing.ts 文件,并粘贴以下脚本来检查你的 STRK 余额:
import { RpcProvider, Contract } from "starknet";
import * as dotenv from "dotenv";
dotenv.config();
async function checkSTRKBalance() {
const alchemyApiKey = process.env.ALCHEMY_API_KEY;
const provider = new RpcProvider({
nodeUrl: `https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_10/${alchemyApiKey}`,
});
// 替换为你的账户地址
const accountAddress =
"0x014154fb6Dd088b5ceB46df635eCCe6e1a9B0455357931aC7Df4263A7dBf39a9";
const strkContractAddress =
"0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d";
const { abi } = await provider.getClassAt(strkContractAddress);
const strkContract = new Contract({
abi,
address: strkContractAddress,
providerOrAccount: provider,
});
const balance = await strkContract.balance_of(accountAddress);
console.log(`STRK 余额(原始): ${balance.toString()}`);
const balanceInSTRK = Number(balance) / 10 ** 18;
console.log(`STRK 余额: ${balanceInSTRK} STRK`);
}
checkSTRKBalance().catch(console.error);
使用以下命令运行:npx tsx scripts/interact_existing.ts

注意: 当与现有代币(如 STRK)交互时,你可以检查余额,如果持有代币,还可以进行转移或授权操作,但铸造需要是合约所有者。
在本文中,我们介绍了使用 sncast 和 Starknet.js 部署 ERC-20 代币并与之交互的实际示例。在下一篇文章中,我们将探索使用直接 deploy_syscall 的工厂模式,展示合约如何在无需 UDC 的情况下部署其他合约。
- 原文链接: rareskills.io/post/cairo...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!
作者暂未设置收款二维码