使用 Anchor 在 Solana 區塊鏈開發鏈上程式

前言

本文為「Solana 開發者的入門指南」影片的學習筆記。

安裝框架

安裝 Anchor 框架。

1
cargo install --git https://github.com/project-serum/anchor --tag v0.24.2 anchor-cli --locked

查看版本。

1
2
anchor --version
anchor-cli 0.24.2

建立專案

使用 anchor 初始化一個專案。

1
anchor init anchor-escrow

programs/anchor-escrow/Cargo.toml 檔指定語言版本,並且添加依賴套件。

1
2
3
4
5
# ...
[dependencies]
anchor-lang = "0.22.0"
anchor-spl = {version = "0.22.0"}
spl-token = {version = "3.3.0", features = ["no-entrypoint"]}

建立程式公鑰

執行以下指令,生成 Program ID。

1
2
anchor keys list
anchor_escrow: CVXMDc2cNvT94Ghz9m9UUbskSwPfd6nHcZpxXZwJFE8b

更新 Anchor.toml 檔,替換 Program ID。

1
2
3
4
5
6
[features]
seeds = false
[programs.localnet]
anchor_escrow = "CVXMDc2cNvT94Ghz9m9UUbskSwPfd6nHcZpxXZwJFE8b"

#...

更新 src/lib.rs 檔,替換 Program ID。

1
declare_id!("CVXMDc2cNvT94Ghz9m9UUbskSwPfd6nHcZpxXZwJFE8b");

實作後端

更新 src/lib.rs 檔。其中 #[program] 區塊是用來定義一個程式,其中的每一個方法用來定義其 RPC 請求處理器,也就是所謂 Solana 的 Instruction 處理器,而這些處理器就是用來讓客戶端使用的端點。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
use anchor_lang::prelude::*;
use anchor_spl::token::{self, CloseAccount, Mint, SetAuthority, TokenAccount, Transfer};
use spl_token::instruction::AuthorityType;

declare_id!("CVXMDc2cNvT94Ghz9m9UUbskSwPfd6nHcZpxXZwJFE8b");

#[program]
pub mod anchor_escrow {
use super::*;

const ESCROW_PDA_SEED: &[u8] = b"escrow";

pub fn initialize(
ctx: Context<Initialize>,
_vault_account_bump: u8,
initializer_amount: u64,
taker_amount: u64,
) -> Result<()> {
ctx.accounts.escrow_account.initializer_key = *ctx.accounts.initializer.key;
ctx.accounts
.escrow_account
.initializer_deposit_token_account = *ctx
.accounts
.initializer_deposit_token_account
.to_account_info()
.key;
ctx.accounts
.escrow_account
.initializer_receive_token_account = *ctx
.accounts
.initializer_receive_token_account
.to_account_info()
.key;
ctx.accounts.escrow_account.initializer_amount = initializer_amount;
ctx.accounts.escrow_account.taker_amount = taker_amount;

let (vault_authority, _vault_authority_bump) =
Pubkey::find_program_address(&[ESCROW_PDA_SEED], ctx.program_id);
token::set_authority(
ctx.accounts.into_set_authority_context(),
AuthorityType::AccountOwner,
Some(vault_authority),
)?;

token::transfer(
ctx.accounts.into_transfer_to_pda_context(),
ctx.accounts.escrow_account.initializer_amount,
)?;

Ok(())
}

pub fn cancel(ctx: Context<Cancel>) -> Result<()> {
let (_vault_authority, vault_authority_bump) =
Pubkey::find_program_address(&[ESCROW_PDA_SEED], ctx.program_id);
let authority_seeds = &[&ESCROW_PDA_SEED[..], &[vault_authority_bump]];

token::transfer(
ctx.accounts
.into_transfer_to_initializer_context()
.with_signer(&[&authority_seeds[..]]),
ctx.accounts.escrow_account.initializer_amount,
)?;

token::close_account(
ctx.accounts
.into_close_context()
.with_signer(&[&authority_seeds[..]]),
)?;

Ok(())
}

pub fn exchange(ctx: Context<Exchange>) -> Result<()> {
let (_vault_authority, vault_authority_bump) =
Pubkey::find_program_address(&[ESCROW_PDA_SEED], ctx.program_id);
let authority_seeds = &[&ESCROW_PDA_SEED[..], &[vault_authority_bump]];

token::transfer(
ctx.accounts.into_transfer_to_initializer_context(),
ctx.accounts.escrow_account.taker_amount,
)?;

token::transfer(
ctx.accounts
.into_transfer_to_taker_context()
.with_signer(&[&authority_seeds[..]]),
ctx.accounts.escrow_account.initializer_amount,
)?;

token::close_account(
ctx.accounts
.into_close_context()
.with_signer(&[&authority_seeds[..]]),
)?;

Ok(())
}
}

#[derive(Accounts)]
#[instruction(vault_account_bump: u8, initializer_amount: u64)]
pub struct Initialize<'info> {
/// CHECK: This is not dangerous because we don't read or write from this account
#[account(mut, signer)]
pub initializer: AccountInfo<'info>,
pub mint: Account<'info, Mint>,
#[account(
init,
seeds = [b"token-seed".as_ref()],
bump,
payer = initializer,
token::mint = mint,
token::authority = initializer,
)]
pub vault_account: Account<'info, TokenAccount>,
#[account(
mut,
constraint = initializer_deposit_token_account.amount >= initializer_amount
)]
pub initializer_deposit_token_account: Account<'info, TokenAccount>,
pub initializer_receive_token_account: Account<'info, TokenAccount>,
#[account(zero)]
pub escrow_account: Box<Account<'info, EscrowAccount>>,
/// CHECK: This is not dangerous because we don't read or write from this account
pub system_program: AccountInfo<'info>,
pub rent: Sysvar<'info, Rent>,
/// CHECK: This is not dangerous because we don't read or write from this account
pub token_program: AccountInfo<'info>,
}

#[derive(Accounts)]
pub struct Cancel<'info> {
/// CHECK: This is not dangerous because we don't read or write from this account
#[account(mut, signer)]
pub initializer: AccountInfo<'info>,
#[account(mut)]
pub vault_account: Account<'info, TokenAccount>,
/// CHECK: This is not dangerous because we don't read or write from this account
pub vault_authority: AccountInfo<'info>,
#[account(mut)]
pub initializer_deposit_token_account: Account<'info, TokenAccount>,
#[account(
mut,
constraint = escrow_account.initializer_key == *initializer.key,
constraint = escrow_account.initializer_deposit_token_account == *initializer_deposit_token_account.to_account_info().key,
close = initializer
)]
pub escrow_account: Box<Account<'info, EscrowAccount>>,
/// CHECK: This is not dangerous because we don't read or write from this account
pub token_program: AccountInfo<'info>,
}

#[derive(Accounts)]
pub struct Exchange<'info> {
#[account(signer)]
/// CHECK: This is not dangerous because we don't read or write from this account
pub taker: AccountInfo<'info>,
#[account(mut)]
pub taker_deposit_token_account: Box<Account<'info, TokenAccount>>,
#[account(mut)]
pub taker_receive_token_account: Box<Account<'info, TokenAccount>>,
#[account(mut)]
pub initializer_deposit_token_account: Box<Account<'info, TokenAccount>>,
#[account(mut)]
pub initializer_receive_token_account: Box<Account<'info, TokenAccount>>,
/// CHECK: This is not dangerous because we don't read or write from this account
#[account(mut)]
pub initializer: AccountInfo<'info>,
#[account(
mut,
constraint = escrow_account.taker_amount <= taker_deposit_token_account.amount,
constraint = escrow_account.initializer_deposit_token_account == *initializer_deposit_token_account.to_account_info().key,
constraint = escrow_account.initializer_receive_token_account == *initializer_receive_token_account.to_account_info().key,
constraint = escrow_account.initializer_key == *initializer.key,
close = initializer
)]
pub escrow_account: Box<Account<'info, EscrowAccount>>,
#[account(mut)]
pub vault_account: Box<Account<'info, TokenAccount>>,
/// CHECK: This is not dangerous because we don't read or write from this account
pub vault_authority: AccountInfo<'info>,
/// CHECK: This is not dangerous because we don't read or write from this account
pub token_program: AccountInfo<'info>,
}

#[account]
pub struct EscrowAccount {
pub initializer_key: Pubkey,
pub initializer_deposit_token_account: Pubkey,
pub initializer_receive_token_account: Pubkey,
pub initializer_amount: u64,
pub taker_amount: u64,
}

impl<'info> Initialize<'info> {
fn into_transfer_to_pda_context(&self) -> CpiContext<'_, '_, '_, 'info, Transfer<'info>> {
let cpi_accounts = Transfer {
from: self
.initializer_deposit_token_account
.to_account_info()
.clone(),
to: self.vault_account.to_account_info().clone(),
authority: self.initializer.clone(),
};
CpiContext::new(self.token_program.clone(), cpi_accounts)
}

fn into_set_authority_context(&self) -> CpiContext<'_, '_, '_, 'info, SetAuthority<'info>> {
let cpi_accounts = SetAuthority {
account_or_mint: self.vault_account.to_account_info().clone(),
current_authority: self.initializer.clone(),
};
CpiContext::new(self.token_program.clone(), cpi_accounts)
}
}

impl<'info> Cancel<'info> {
fn into_transfer_to_initializer_context(
&self,
) -> CpiContext<'_, '_, '_, 'info, Transfer<'info>> {
let cpi_accounts = Transfer {
from: self.vault_account.to_account_info().clone(),
to: self
.initializer_deposit_token_account
.to_account_info()
.clone(),
authority: self.vault_authority.clone(),
};
CpiContext::new(self.token_program.clone(), cpi_accounts)
}

fn into_close_context(&self) -> CpiContext<'_, '_, '_, 'info, CloseAccount<'info>> {
let cpi_accounts = CloseAccount {
account: self.vault_account.to_account_info().clone(),
destination: self.initializer.clone(),
authority: self.vault_authority.clone(),
};
CpiContext::new(self.token_program.clone(), cpi_accounts)
}
}

impl<'info> Exchange<'info> {
fn into_transfer_to_initializer_context(
&self,
) -> CpiContext<'_, '_, '_, 'info, Transfer<'info>> {
let cpi_accounts = Transfer {
from: self.taker_deposit_token_account.to_account_info().clone(),
to: self
.initializer_receive_token_account
.to_account_info()
.clone(),
authority: self.taker.clone(),
};
CpiContext::new(self.token_program.clone(), cpi_accounts)
}

fn into_transfer_to_taker_context(&self) -> CpiContext<'_, '_, '_, 'info, Transfer<'info>> {
let cpi_accounts = Transfer {
from: self.vault_account.to_account_info().clone(),
to: self.taker_receive_token_account.to_account_info().clone(),
authority: self.vault_authority.clone(),
};
CpiContext::new(self.token_program.clone(), cpi_accounts)
}

fn into_close_context(&self) -> CpiContext<'_, '_, '_, 'info, CloseAccount<'info>> {
let cpi_accounts = CloseAccount {
account: self.vault_account.to_account_info().clone(),
destination: self.initializer.clone(),
authority: self.vault_authority.clone(),
};
CpiContext::new(self.token_program.clone(), cpi_accounts)
}
}

使用 anchor 指令進行編譯。

1
anchor build

實作前端

配合教材,將 @project-serum/anchor 依賴套件降級。

1
npm install --save @project-serum/anchor@0.22.0

安裝 @solana/spl-token 依賴套件。

1
npm install --save @solana/spl-token@0.1.8

更新 tests/anchor-escrow.ts 檔,建立測試案例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
import * as anchor from '@project-serum/anchor';
import { Program } from '@project-serum/anchor';
import NodeWallet from '@project-serum/anchor/dist/cjs/nodewallet';
import { AnchorEscrow } from '../target/types/anchor_escrow';
import { PublicKey, SystemProgram, Transaction, Connection, Commitment } from '@solana/web3.js';
import { TOKEN_PROGRAM_ID, Token } from "@solana/spl-token";
import { assert } from "chai";

describe('anchor-escrow', () => {
const commitment: Commitment = 'processed';
const connection = new Connection('https://rpc-mainnet-fork.dappio.xyz', { commitment, wsEndpoint: 'wss://rpc-mainnet-fork.dappio.xyz/ws' });
const options = anchor.Provider.defaultOptions();
const wallet = NodeWallet.local();
const provider = new anchor.Provider(connection, wallet, options);

anchor.setProvider(provider);

const program = anchor.workspace.AnchorEscrow as Program<AnchorEscrow>;

let mintA = null as Token;
let mintB = null as Token;
let initializerTokenAccountA = null;
let initializerTokenAccountB = null;
let takerTokenAccountA = null;
let takerTokenAccountB = null;
let vault_account_pda = null;
let vault_account_bump = null;
let vault_authority_pda = null;

const takerAmount = 1000;
const initializerAmount = 500;

const escrowAccount = anchor.web3.Keypair.generate();
const payer = anchor.web3.Keypair.generate();
const mintAuthority = anchor.web3.Keypair.generate();
const initializerMainAccount = anchor.web3.Keypair.generate();
const takerMainAccount = anchor.web3.Keypair.generate();

it("Initialize program state", async () => {
// Airdropping tokens to a payer.
await provider.connection.confirmTransaction(
await provider.connection.requestAirdrop(payer.publicKey, 1000000000),
"processed"
);

// Fund Main Accounts
await provider.send(
(() => {
const tx = new Transaction();
tx.add(
SystemProgram.transfer({
fromPubkey: payer.publicKey,
toPubkey: initializerMainAccount.publicKey,
lamports: 100000000,
}),
SystemProgram.transfer({
fromPubkey: payer.publicKey,
toPubkey: takerMainAccount.publicKey,
lamports: 100000000,
})
);
return tx;
})(),
[payer]
);

mintA = await Token.createMint(
provider.connection,
payer,
mintAuthority.publicKey,
null,
0,
TOKEN_PROGRAM_ID
);

mintB = await Token.createMint(
provider.connection,
payer,
mintAuthority.publicKey,
null,
0,
TOKEN_PROGRAM_ID
);

initializerTokenAccountA = await mintA.createAccount(initializerMainAccount.publicKey);
takerTokenAccountA = await mintA.createAccount(takerMainAccount.publicKey);

initializerTokenAccountB = await mintB.createAccount(initializerMainAccount.publicKey);
takerTokenAccountB = await mintB.createAccount(takerMainAccount.publicKey);

await mintA.mintTo(
initializerTokenAccountA,
mintAuthority.publicKey,
[mintAuthority],
initializerAmount
);

await mintB.mintTo(
takerTokenAccountB,
mintAuthority.publicKey,
[mintAuthority],
takerAmount
);

let _initializerTokenAccountA = await mintA.getAccountInfo(initializerTokenAccountA);
let _takerTokenAccountB = await mintB.getAccountInfo(takerTokenAccountB);

assert.ok(_initializerTokenAccountA.amount.toNumber() == initializerAmount);
assert.ok(_takerTokenAccountB.amount.toNumber() == takerAmount);
});

it("Initialize escrow", async () => {
const [_vault_account_pda, _vault_account_bump] = await PublicKey.findProgramAddress(
[Buffer.from(anchor.utils.bytes.utf8.encode("token-seed"))],
program.programId
);
vault_account_pda = _vault_account_pda;
vault_account_bump = _vault_account_bump;

const [_vault_authority_pda, _vault_authority_bump] = await PublicKey.findProgramAddress(
[Buffer.from(anchor.utils.bytes.utf8.encode("escrow"))],
program.programId
);
vault_authority_pda = _vault_authority_pda;

await program.rpc.initialize(
vault_account_bump,
new anchor.BN(initializerAmount),
new anchor.BN(takerAmount),
{
accounts: {
initializer: initializerMainAccount.publicKey,
vaultAccount: vault_account_pda,
mint: mintA.publicKey,
initializerDepositTokenAccount: initializerTokenAccountA,
initializerReceiveTokenAccount: initializerTokenAccountB,
escrowAccount: escrowAccount.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
rent: anchor.web3.SYSVAR_RENT_PUBKEY,
tokenProgram: TOKEN_PROGRAM_ID,
},
instructions: [
await program.account.escrowAccount.createInstruction(escrowAccount),
],
signers: [escrowAccount, initializerMainAccount],
}
);

let _vault = await mintA.getAccountInfo(vault_account_pda);

let _escrowAccount = await program.account.escrowAccount.fetch(
escrowAccount.publicKey
);

// Check that the new owner is the PDA.
assert.ok(_vault.owner.equals(vault_authority_pda));

// Check that the values in the escrow account match what we expect.
assert.ok(_escrowAccount.initializerKey.equals(initializerMainAccount.publicKey));
assert.ok(_escrowAccount.initializerAmount.toNumber() == initializerAmount);
assert.ok(_escrowAccount.takerAmount.toNumber() == takerAmount);
assert.ok(
_escrowAccount.initializerDepositTokenAccount.equals(initializerTokenAccountA)
);
assert.ok(
_escrowAccount.initializerReceiveTokenAccount.equals(initializerTokenAccountB)
);
});

it("Exchange escrow state", async () => {
await program.rpc.exchange({
accounts: {
taker: takerMainAccount.publicKey,
takerDepositTokenAccount: takerTokenAccountB,
takerReceiveTokenAccount: takerTokenAccountA,
initializerDepositTokenAccount: initializerTokenAccountA,
initializerReceiveTokenAccount: initializerTokenAccountB,
initializer: initializerMainAccount.publicKey,
escrowAccount: escrowAccount.publicKey,
vaultAccount: vault_account_pda,
vaultAuthority: vault_authority_pda,
tokenProgram: TOKEN_PROGRAM_ID,
},
signers: [takerMainAccount]
});

let _takerTokenAccountA = await mintA.getAccountInfo(takerTokenAccountA);
let _takerTokenAccountB = await mintB.getAccountInfo(takerTokenAccountB);
let _initializerTokenAccountA = await mintA.getAccountInfo(initializerTokenAccountA);
let _initializerTokenAccountB = await mintB.getAccountInfo(initializerTokenAccountB);

assert.ok(_takerTokenAccountA.amount.toNumber() == initializerAmount);
assert.ok(_initializerTokenAccountA.amount.toNumber() == 0);
assert.ok(_initializerTokenAccountB.amount.toNumber() == takerAmount);
assert.ok(_takerTokenAccountB.amount.toNumber() == 0);
});

it("Initialize escrow and cancel escrow", async () => {
// Put back tokens into initializer token A account.
await mintA.mintTo(
initializerTokenAccountA,
mintAuthority.publicKey,
[mintAuthority],
initializerAmount
);

await program.rpc.initialize(
vault_account_bump,
new anchor.BN(initializerAmount),
new anchor.BN(takerAmount),
{
accounts: {
initializer: initializerMainAccount.publicKey,
vaultAccount: vault_account_pda,
mint: mintA.publicKey,
initializerDepositTokenAccount: initializerTokenAccountA,
initializerReceiveTokenAccount: initializerTokenAccountB,
escrowAccount: escrowAccount.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
rent: anchor.web3.SYSVAR_RENT_PUBKEY,
tokenProgram: TOKEN_PROGRAM_ID,
},
instructions: [
await program.account.escrowAccount.createInstruction(escrowAccount),
],
signers: [escrowAccount, initializerMainAccount],
}
);

// Cancel the escrow.
await program.rpc.cancel({
accounts: {
initializer: initializerMainAccount.publicKey,
initializerDepositTokenAccount: initializerTokenAccountA,
vaultAccount: vault_account_pda,
vaultAuthority: vault_authority_pda,
escrowAccount: escrowAccount.publicKey,
tokenProgram: TOKEN_PROGRAM_ID,
},
signers: [initializerMainAccount]
});

// Check the final owner should be the provider public key.
const _initializerTokenAccountA = await mintA.getAccountInfo(initializerTokenAccountA);
assert.ok(_initializerTokenAccountA.owner.equals(initializerMainAccount.publicKey));

// Check all the funds are still there.
assert.ok(_initializerTokenAccountA.amount.toNumber() == initializerAmount);
});
});

參考資料