清晰的说明 + 完整、可运行的代码示例,把 “在 Solana 上实现捆绑交易(atomic / bundled swap)” 的两种主流实现方式讲清楚:
方法 A(最简单、推荐)—— 直接在同一笔交易中放入多条 transfer 指令:由交易的多个参与者在同一笔交易上签名,交易中包含双方的 token transfer 指令,若有一条失败则整笔交易回滚。
方法 B(更结构化、可扩展)—— 使用一个 on-chain 程序(Anchor)来执行原子交换:程序要求双方在同一笔交易中作为 signer,程序通过 CPI 调用 token program 完成两笔转账,失败则整笔回滚。该方式便于加入额外检查(最小价格、nonce、防重放等)。
下面我将分别说明概念、先决条件、完整代码(客户端 TypeScript / Node.js 示例 + Anchor 程序示例),以及如何运行与测试。代码尽量详尽,足够拷贝、运行与理解。
背景知识(简短)
-
在 Solana 中,一笔交易可以包含多条 instruction(跨程序调用也行),并且交易所包含的所有 instruction 要么全部执行成功要么全部失败 —— 这保证了“同一笔交易内的原子性”。
-
spl-token 的 Transfer 指令需要来源 token account 的 owner 签名(或预先 Approve 给 delegate)。因此,若要在同一笔 tx 中把 A 的代币给 B 并且把 B 的代币给 A,需要 A 和 B 都在该交易上签名(或先做 approve)。
-
如果希望由一个程序来担保交易(例如做更多验证),程序可以在交易中执行 CPI(跨合约调用),并且可以利用顶层交易的签名位来做 token CPI(即程序可以在 CPI 中把顶层的签名者标记为 is_signer,token program 会认可)。
前提与准备
-
Node.js >= 18(示例用 TypeScript + @solana/web3.js + @solana/spl-token)
-
Anchor(如果使用方法 B):Rust + Anchor CLI(anchor)已安装。示例基于 Anchor v0.28+(请用你本地的 Anchor 版本相匹配)。
-
使用 devnet 或 localnet(示例我用 devnet);若使用 localnet,请确保 solana-test-validator 在运行。
-
安装依赖:@solana/web3.js 与 @solana/spl-token(见下面每个示例的安装步骤)。
方法 A:在同一个交易中直接放两条 transfer 指令(客户端实现)
适用场景:双方即时在线、都能在客户端签名 —— 最简洁高效的原子交换方式。
核心思想(一句话)
把 Transfer A→B 和 Transfer B→A 两条 instruction 放在同一笔 transaction 中,要求 A 和 B 都对该交易签名。若任一条失败(账户余额不足、signature 缺失等),整个交易回滚。
完整示例(TypeScript)
下面的示例包含以下步骤(在 devnet 上跑):
-
创建两个 test 钱包(payer、userA、userB);
-
创建两个 SPL Token Mint(tokenA、tokenB)并 mint 给各自账户;
-
构建一笔交易,包含两条 transfer instruction:A 的 tokenA 转给 B;B 的 tokenB 转给 A;
-
让 A 与 B 共同签名并发送交易;
-
验证转账结果。
注意:示例为了演示方便在脚本中直接生成 Keypair 并 airdrop 测试 SOL。生产环境请用钱包提供者(Phantom、Globe 等)签名或使用更加安全的 key 管理。
安装依赖
mkdir solana-atomic-swap && cd solana-atomic-swap
npm init -y
npm i @solana/web3.js @solana/spl-token @types/node typescript ts-node
npx tsc --init
atomic_swap_direct.ts(完整脚本)
将下面代码保存为 atomic_swap_direct.ts,然后用 npx ts-node atomic_swap_direct.ts 运行(示例基于 devnet)。
/**
* atomic_swap_direct.ts
* 演示:在同一笔交易中把 tokenA 从 Alice 转到 Bob,同时把 tokenB 从 Bob 转到 Alice
* 要求 Alice 和 Bob 都对该交易签名 —— 原子执行
*/
import {
Keypair,
Connection,
clusterApiUrl,
LAMPORTS_PER_SOL,
PublicKey,
Transaction,
sendAndConfirmTransaction,
} from "@solana/web3.js";
import {
createMint,
getOrCreateAssociatedTokenAccount,
mintTo,
transfer,
getMint,
TOKEN_PROGRAM_ID,
} from "@solana/spl-token";
async functionmain() {
const connection = new Connection(clusterApiUrl("devnet"), "confirmed");
// 1) 准备三个钱包:fee payer / Alice / Bob
const feePayer = Keypair.generate(); // 脚本费用支付者
const alice = Keypair.generate();
const bob = Keypair.generate();
// airdrop SOL
for (const kp of [feePayer, alice, bob]) {
const sig = await connection.requestAirdrop(kp.publicKey, LAMPORTS_PER_SOL * 2);
await connection.confirmTransaction(sig, "confirmed");
}
console.log("Airdrop done.");
// 2) 创建两个 Mint:tokenA(decimals=6)和 tokenB(decimals=6)
const decimals = 6;
const tokenAMint = await createMint(connection, feePayer, feePayer.publicKey, null, decimals);
const tokenBMint = await createMint(connection, feePayer, feePayer.publicKey, null, decimals);
console.log("Created mints:", tokenAMint.toBase58(), tokenBMint.toBase58());
// 3) Alice 创建她的 ATA 并 mint 一些 tokenA 给她
const aliceTokenAAccount = await getOrCreateAssociatedTokenAccount(
connection,
feePayer,
tokenAMint,
alice.publicKey
);
// Bob 创建他自己的 ATA for tokenB,并 mint 给他
const bobTokenBAccount = await getOrCreateAssociatedTokenAccount(
connection,
feePayer,
tokenBMint,
bob.publicKey
);
// mint initial balances: Alice has 100 tokenA, Bob has 200 tokenB
const aliceAmount = BigInt(100) * BigInt(10 ** decimals); // use BigInt to avoid fp
const bobAmount = BigInt(200) * BigInt(10 ** decimals);
await mintTo(connection, feePayer, tokenAMint, aliceTokenAAccount.address, feePayer, Number(aliceAmount));
await mintTo(connection, feePayer, tokenBMint, bobTokenBAccount.address, feePayer, Number(bobAmount));
console.log("Minted initial balances.");
// create the counterpart ATAs (the target accounts for the swap)
const bobTokenAAccount = await getOrCreateAssociatedTokenAccount(
connection,
feePayer,
tokenAMint,
bob.publicKey
);
const aliceTokenBAccount = await getOrCreateAssociatedTokenAccount(
connection,
feePayer,
tokenBMint,
alice.publicKey
);
// show before balances
const mintAInfo = await getMint(connection, tokenAMint);
console.log(`Decimals for tokenA: ${mintAInfo.decimals}`);
console.log("Before balances:");
console.log("Alice tokenA:", (await connection.getTokenAccountBalance(aliceTokenAAccount.address)).value.uiAmountString);
console.log("Alice tokenB:", (await connection.getTokenAccountBalance(aliceTokenBAccount.address)).value.uiAmountString);
console.log("Bob tokenA:", (await connection.getTokenAccountBalance(bobTokenAAccount.address)).value.uiAmountString);
console.log("Bob tokenB:", (await connection.getTokenAccountBalance(bobTokenBAccount.address)).value.uiAmountString);
// 4) 构建原子交易:Alice 给 Bob 10 tokenA,Bob 给 Alice 20 tokenB
const sendAmountA = BigInt(10) * BigInt(10 ** decimals);
const sendAmountB = BigInt(20) * BigInt(10 ** decimals);
// 使用 spl-token 的 transfer 工具生成两条 transfer 指令
const ixAtoB = transfer(
// transfer 的 helper 返回 TransactionInstruction
// 参数:connection, payer, source, destination, owner, amount
// 这里我们只用其 signature-friendly form:create instruction manually via helper is nontrivial.
// 因此使用 transfer from spl-token v0.3 helper that returns tx signature when executed.
// 为了把 instruction 放入 Transaction,我们需要使用 createTransferInstruction(low-level)
// But for simplicity, use low-level createTransferInstruction from spl-token package
// We'll import it below.
{} as any
);
// NOTE: Above comment: the helper `transfer` executes a transaction.
// For building instruction-only, use createTransferInstruction.
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
上面脚本演示思路清楚,但为了保持代码易读与可复制,我将给出完整且可运行的 atomic_swap_direct.ts(包含实际构建 instruction 的方式)。下面是可直接运行的最终版本(已用 createTransferInstruction):
请用以下完整最终脚本替换上面占位内容并运行:
// atomic_swap_direct.ts (final runnable)
// 注意:需要 @solana/spl-token v0.3.x 或更高,且 Node 支持 BigInt
import {
Keypair,
Connection,
clusterApiUrl,
LAMPORTS_PER_SOL,
PublicKey,
Transaction,
sendAndConfirmTransaction,
Signer,
} from "@solana/web3.js";
import {
createMint,
getOrCreateAssociatedTokenAccount,
mintTo,
getMint,
createTransferInstruction,
TOKEN_PROGRAM_ID,
} from "@solana/spl-token";
async function airdropIfNeeded(connection: Connection, pubkey: PublicKey) {
const lamports = (await connection.getBalance(pubkey)) || 0;
if (lamports < LAMPORTS_PER_SOL) {
const sig = await connection.requestAirdrop(pubkey, LAMPORTS_PER_SOL * 2);
await connection.confirmTransaction(sig, "confirmed");
}
}
async functionmain() {
const connection = new Connection(clusterApiUrl("devnet"), "confirmed");
const feePayer = Keypair.generate();
const alice = Keypair.generate();
const bob = Keypair.generate();
// Airdrop
await Promise.all([airdropIfNeeded(connection, feePayer.publicKey), airdropIfNeeded(connection, alice.publicKey), airdropIfNeeded(connection, bob.publicKey)]);
console.log("Airdrop done.");
// Create Mints
const decimals = 6;
const tokenAMint = await createMint(connection, feePayer, feePayer.publicKey, null, decimals);
const tokenBMint = await createMint(connection, feePayer, feePayer.publicKey, null, decimals);
console.log("Created token mints:", tokenAMint.toBase58(), tokenBMint.toBase58());
// Create ATAs
const aliceTokenAAccount = await getOrCreateAssociatedTokenAccount(connection, feePayer, tokenAMint, alice.publicKey);
const aliceTokenBAccount = await getOrCreateAssociatedTokenAccount(connection, feePayer, tokenBMint, alice.publicKey);
const bobTokenAAccount = await getOrCreateAssociatedTokenAccount(connection, feePayer, tokenAMint, bob.publicKey);
const bobTokenBAccount = await getOrCreateAssociatedTokenAccount(connection, feePayer, tokenBMint, bob.publicKey);
// Mint initial tokens: Alice 100 tokenA, Bob 200 tokenB
const aliceAmount = 100n * 10n ** BigInt(decimals);
const bobAmount = 200n * 10n ** BigInt(decimals);
await mintTo(connection, feePayer, tokenAMint, aliceTokenAAccount.address, feePayer, Number(aliceAmount));
await mintTo(connection, feePayer, tokenBMint, bobTokenBAccount.address, feePayer, Number(bobAmount));
console.log("Minted initial supplies.");
// Show balances before
console.log("Before swap:");
console.log("Alice tokenA:", (await connection.getTokenAccountBalance(aliceTokenAAccount.address)).value.uiAmountString);
console.log("Alice tokenB:", (await connection.getTokenAccountBalance(aliceTokenBAccount.address)).value.uiAmountString);
console.log("Bob tokenA:", (await connection.getTokenAccountBalance(bobTokenAAccount.address)).value.uiAmountString);
console.log("Bob tokenB:", (await connection.getTokenAccountBalance(bobTokenBAccount.address)).value.uiAmountString);
// Build atomic transaction: Alice -> Bob 10 tokenA; Bob -> Alice 20 tokenB
const sendAmountA = 10n * 10n ** BigInt(decimals); // 10 tokenA
const sendAmountB = 20n * 10n ** BigInt(decimals); // 20 tokenB
// Create transfer instructions (token program)
const ixAtoB = createTransferInstruction(aliceTokenAAccount.address, bobTokenAAccount.address, alice.publicKey, Number(sendAmountA), [], TOKEN_PROGRAM_ID);
const ixBtoA = createTransferInstruction(bobTokenBAccount.address, aliceTokenBAccount.address, bob.publicKey, Number(sendAmountB), [], TOKEN_PROGRAM_ID);
// Construct transaction and add two instructions
const tx = new Transaction();
tx.add(ixAtoB);
tx.add(ixBtoA);
// Set fee payer and recent blockhash will be populated by RPC send method
tx.feePayer = feePayer.publicKey;
// Partial sign by feePayer (payer covers tx fee)
tx.partialSign(feePayer);
// At this point, the tx needs Alice and Bob signatures as owners of source accounts.
// We will have Alice and Bob sign the tx.
// Serialize transaction message for them to sign:
tx.recentBlockhash = (await connection.getLatestBlockhash("confirmed")).blockhash;
// Sign with Alice and Bob
tx.partialSign(alice);
tx.partialSign(bob);
// Serialize and send
const raw = tx.serialize();
const txid = await connection.sendRawTransaction(raw);
console.log("Sent atomic swap txid:", txid);
await connection.confirmTransaction(txid, "confirmed");
console.log("Transaction confirmed.");
// Balances after
console.log("After swap:");
console.log("Alice tokenA:", (await connection.getTokenAccountBalance(aliceTokenAAccount.address)).value.uiAmountString);
console.log("Alice tokenB:", (await connection.getTokenAccountBalance(aliceTokenBAccount.address)).value.uiAmountString);
console.log("Bob tokenA:", (await connection.getTokenAccountBalance(bobTokenAAccount.address)).value.uiAmountString);
console.log("Bob tokenB:", (await connection.getTokenAccountBalance(bobTokenBAccount.address)).value.uiAmountString);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
运行与预期结果
-
运行后你会看到在同一笔交易中两笔转账成功执行,最后 Alice 的 tokenA 减少 10 且获得 20 个 tokenB,Bob 相反。若任一 signer(Alice 或 Bob)不对交易签名或余额不足,交易会回滚(失败)。
优点与限制
-
优点:实现极其简单、延迟低、不需要部署程序;只要双方同时在线签名即可完成原子交换。
-
限制:双方必须同时对同一笔交易签名;若一方离线或不信任签名流程,可采用 escrow 流程(方法 B)。
方法 B:使用 Anchor 程序(on-chain 合约)来执行原子交换(程序端执行 CPI)
适用场景:想在链上有一个可审计的交换合约,能在单笔交易里执行复杂的业务逻辑(例如价格检查、nonce、签名验证、手续费分成、以及后续扩展如买断/仲裁)且仍然保证原子性。
核心思想
编写一个 Anchor 程序 atomic_swap,其 swap 指令需要传入双方的 token accounts(source/destination),并要求两个参与者(ownerA、ownerB)都作为 signer 出现在 transaction 的 account metas 中。程序执行 CPI 调用 token::transfer 两次,token program 会检查转账的 owner 是否在 transaction 的签名者列表里(是的话则允许),如果任一 transfer 因余额不足或缺签名失败,整个 instruction 会 panic,整笔交易回滚。
注意:程序能在执行时做更多检查(例如检查期望金额、双方的订单 id、deadline、签名 nonce 等),更适合生产环境。
下面给出完整 Anchor 程序(Rust)以及一个 TypeScript 客户端示例(使用 Anchor provider 调用),展示如何在同一笔交易中让两个用户签名并调用程序的 swap 指令。
Anchor 程序(完整)
-
Anchor 项目初始化(本地)
anchor init atomic-swap-anchor --javascript
cd atomic-swap-anchor
编辑 Cargo.toml 与 Anchor.toml 按需(Anchor 默认生成即可)。
-
Anchor 程序源码 — programs/atomic-swap-anchor/src/lib.rs
将下面内容替换或写入该文件:
use anchor_lang::prelude::*;
use anchor_spl::token::{self, Mint, TokenAccount, Transfer, Token};
declare_id!("ReplaceWithProgramIDWhenDeployedxxxxxxxxxxxxxxxxxxxx");
#[program]
pub mod atomic_swap_anchor {
use super::*;
/// swap: 程序会在 CPI 中执行两次 token transfer。
/// preconditions:
/// - owner_a 是 source_a 的 owner,并且为 signer(顶层交易签名)
/// - owner_b 是 source_b 的 owner,并且为 signer
/// - amount_a 和 amount_b 是预期转移数量
pub fn swap(
ctx: Context<Swap>,
amount_a: u64,
amount_b: u64
) -> Result<()> {
// 做一些可选的检查
// 比如可以验证 mint 是否符合预期(由客户端提供)
// assert_eq!(ctx.accounts.mint_a.key(), some_expected_mint);
// Transfer token A: source_a -> dest_a (owned by owner_b)
let cpi_accounts_a = Transfer {
from: ctx.accounts.source_a.to_account_info(),
to: ctx.accounts.dest_a.to_account_info(),
authority: ctx.accounts.owner_a.to_account_info(), // owner_a must be signer of tx
};
let cpi_program = ctx.accounts.token_program.to_account_info();
let cpi_ctx_a = CpiContext::new(cpi_program.clone(), cpi_accounts_a);
token::transfer(cpi_ctx_a, amount_a)?;
// Transfer token B: source_b -> dest_b (owned by owner_a)
let cpi_accounts_b = Transfer {
from: ctx.accounts.source_b.to_account_info(),
to: ctx.accounts.dest_b.to_account_info(),
authority: ctx.accounts.owner_b.to_account_info(), // owner_b must be signer
};
let cpi_ctx_b = CpiContext::new(cpi_program, cpi_accounts_b);
token::transfer(cpi_ctx_b, amount_b)?;
Ok(())
}
}
#[derive(Accounts)]
pub struct Swap<'info> {
/// owner A: must be signer in the top-level transaction
#[account(mut)]
pub owner_a: Signer<'info>,
/// owner B: must be signer
#[account(mut)]
pub owner_b: Signer<'info>,
/// source token account owned by owner_a (token A)
#[account(mut, has_one = owner_a)]
pub source_a: Account<'info, TokenAccount>,
/// destination token account for token A (owned by owner_b)
#[account(mut, has_one = owner_b)]
pub dest_a: Account<'info, TokenAccount>,
/// source token account owned by owner_b (token B)
#[account(mut, has_one = owner_b)]
pub source_b: Account<'info, TokenAccount>,
/// destination token account for token B (owned by owner_a)
#[account(mut, has_one = owner_a)]
pub dest_b: Account<'info, TokenAccount>,
/// SPL Token program
pub token_program: Program<'info, Token>,
}
说明:
-
上面 Swap 账户结构通过 has_one 强制 source_a.owner == owner_a 等关系,Anchor 会在运行时自动检查。
-
owner_a 与 owner_b 都被声明为 Signer,这意味着调用该 instruction 的顶层交易必须包含这两个签名(实现了安全且原子的双向转移)。
-
程序通过 CPI 调用 token program 的 transfer;token program 会检查传入的 authority 是否为 signer(而 outer tx 的签名会被 token program 检查通过),若缺签或余额不足则会 panic —— 从而回滚整个 instruction/transaction。
-
构建与部署(本地 devnet 或本地 validator)
-
在项目根运行:anchor build
-
配置 Anchor.toml network 为 devnet,或者用 anchor deploy --provider.cluster devnet(需要你已配置好 wallet 与网络)。
-
部署后把 declare_id! 的 program id 替换为部署后的 program id。
生产建议:在部署前务必进行单元测试与安全审计。
客户端(TypeScript)示例:如何在单笔交易中让两个用户签名并调用程序
假设程序已部署且我们有 programId,下面演示如何使用 @project-serum/anchor 或直接用 @solana/web3.js 构建 transaction 并让 owner_a 与 owner_b 都签名。
安装(客户端)
npm i @project-serum/anchor @solana/web3.js @solana/spl-token
swap_via_anchor.ts(客户端示例)
import * as anchor from "@project-serum/anchor";
import { Keypair, SystemProgram, Transaction } from "@solana/web3.js";
import { TOKEN_PROGRAM_ID, getOrCreateAssociatedTokenAccount, createMint, mintTo } from "@solana/spl-token";
async functionmain() {
// Provider & connection (Devnet)
const provider = anchor.AnchorProvider.env(); // 需设置 env 或手动构造 provider
anchor.setProvider(provider);
const connection = provider.connection;
// Load program (IDL generated by anchor build)
const idl = JSON.parse(require("fs").readFileSync("./target/idl/atomic_swap_anchor.json", "utf8"));
const programId = new anchor.web3.PublicKey("PUT_YOUR_DEPLOYED_PROGRAM_ID_HERE");
const program = new anchor.Program(idl, programId, provider);
// Create two keypairs (ownerA, ownerB) — in production, they are external wallets
const ownerA = Keypair.generate();
const ownerB = Keypair.generate();
// Airdrop both
await connection.requestAirdrop(ownerA.publicKey, 2e9);
await connection.requestAirdrop(ownerB.publicKey, 2e9);
await new Promise((r) => setTimeout(r, 5000));
// Create token mints and ATAs & mint balances (similar于方法A)
const decimals = 6;
const payer = provider.wallet.payer as Keypair;
const mintA = await createMint(connection, payer, payer.publicKey, null, decimals);
const mintB = await createMint(connection, payer, payer.publicKey, null, decimals);
const ownerA_tokenA = await getOrCreateAssociatedTokenAccount(connection, payer, mintA, ownerA.publicKey);
const ownerA_tokenB = await getOrCreateAssociatedTokenAccount(connection, payer, mintB, ownerA.publicKey);
const ownerB_tokenA = await getOrCreateAssociatedTokenAccount(connection, payer, mintA, ownerB.publicKey);
const ownerB_tokenB = await getOrCreateAssociatedTokenAccount(connection, payer, mintB, ownerB.publicKey);
// Mint A: ownerA gets 100 tokenA; ownerB gets 200 tokenB
await mintTo(connection, payer, mintA, ownerA_tokenA.address, payer, 100 * 10 ** decimals);
await mintTo(connection, payer, mintB, ownerB_tokenB.address, payer, 200 * 10 ** decimals);
// Now build the anchor instruction (program.rpc.swap)
// Anchor expects the accounts layout matching the program's Swap context
const amountA = 10 * 10 ** decimals; // ownerA -> ownerB
const amountB = 20 * 10 ** decimals; // ownerB -> ownerA
// Build transaction but require ownerA & ownerB to sign
const tx = await program.rpc.swap(
new anchor.BN(amountA),
new anchor.BN(amountB),
{
accounts: {
ownerA: ownerA.publicKey,
ownerB: ownerB.publicKey,
sourceA: ownerA_tokenA.address,
destA: ownerB_tokenA.address,
sourceB: ownerB_tokenB.address,
destB: ownerA_tokenB.address,
tokenProgram: TOKEN_PROGRAM_ID,
},
signers: [ownerA, ownerB], // Anchor will attach signatures from these signers
instructions: [],
}
);
console.log("Swap txid:", tx);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
要点说明:
-
在上面 program.rpc.swap(..., { signers: [ownerA, ownerB] }) 中,Anchor 会把 ownerA 与 ownerB 放到交易的签名位,使得当程序执行 CPI 时,token program 能识别这些账户为签名者并允许 transfer。
-
若任何一方没有签名或余额不足,CPI token::transfer 将失败,从而导致程序 swap 返回错误,整笔交易回滚(保证原子性)。
-
Anchor 的 has_one 在运行时会对账户关系做验证,增强安全性。
❝本公众号发布的内容除特别标明外版权归原作者所有。若涉及版权问题,请联系我们。所有信息及评论区内容仅供参考,请读者自行判断信息真伪,不构成任何投资建议。据此产生的任何损失,本公众号概不负责,亦不负任何法律责任。

