文章主要介绍了如何在Solana区块链上使用持久的nonce来签名和发送离线交易,以避免交易过期的问题。
持久性随机数(durable nonces)是一种便捷的工具,可以用来避免交易过期。在本指南中,我们将向你展示如何使用持久性随机数来签署和发送离线交易,而无需担心交易过期。
在本指南中,你将:
依赖项 | 版本 |
---|---|
node.js | 18.12.1 |
tsc | 5.0.2 |
ts-node | 10.9.1 |
solana-cli | 1.14.16 |
@solana/web3.js | 1.74.0 |
bs58 | 5.0.0 |
随机数是在一次使用中用到的数字。在 Solana 的上下文中,随机数是用来防止重放攻击的数字。重放攻击是指交易被拦截后重新发送到网络的情况。
典型的 Solana 交易在交易数据中包含一个最近的区块哈希,以便运行时可以验证交易的唯一性。为了限制运行时需要双重检查的历史数量,Solana 只查看最后 150 个区块。这意味着如果在 150 个区块内发送了两个相同的交易,第二个交易将失败。这也意味着过时的交易(超过 150 个区块)将会失败。
不幸的是,如果你在线下发送交易(或有其他特别耗时的限制),你可能会遇到交易过期的问题。这就是持久性随机数发挥作用的地方。Solana 允许你创建一种特殊类型的账户,即随机数账户。你可以将此账户视为你自己的私有区块哈希队列。你可以生成新的唯一ID,推进到下一个 ID,或者甚至将队列的控制权转移给其他人。此账户保存一个唯一的值或随机数。你可以在创建交易时使用随机数,而不是最近的区块哈希。为了防止重放攻击,每次通过在交易的第一个指令中调用 advanceNonceAccount
更改随机数。试图在不推进随机数的情况下使用随机数账户的交易将失败。这是一个例子:
// nonceAdvance 方法位于 SystemProgram 类上,并返回一个 TransactionInstruction(类似于 SystemProgram.transfer)
const advanceIx = SystemProgram.nonceAdvance({
authorizedPubkey: nonceAuthKeypair.publicKey,
noncePubkey: nonceKeypair.publicKey
})
const transferIx = SystemProgram.transfer({
fromPubkey: senderKeypair.publicKey,
toPubkey: destination.publicKey,
lamports: TRANSFER_AMOUNT,
});
const sampleTx = new Transaction();
// 首先将随机数推进指令添加到交易中
sampleTx.add(advanceIx, transferIx);
这就是 Solana 的持久性随机数所提供的功能:提前准备一个带有唯一 ID 的交易,避免因过旧而被拒绝。这是一种在不妨碍顺序的情况下设置交易以供后续执行,同时防止欺诈和保持交易队列的秩序。
让我们创建一个新的 Node.js 项目并安装 Solana-Web3.js 库。在你的终端中依次输入以下命令:
mkdir offline-tx && cd offline-tx && echo > app.ts
npm init -y # 或 yarn init -y
npm install @solana/web3.js@1 bs58 # 或 yarn add @solana/web3.js@1 bs58
在你喜欢的编辑器中打开 app.ts
文件,并添加以下导入:
import { Connection, Keypair, LAMPORTS_PER_SOL, NonceAccount, NONCE_ACCOUNT_LENGTH, SystemProgram, Transaction, TransactionSignature, TransactionConfirmationStatus, SignatureStatus } from "@solana/web3.js";
import { encode, decode } from 'bs58';
import fs from 'fs';
我们从 @solana/web3.js
IMPORT 了必要的依赖项,bs58
(用于进行 Base-58 编码和解码的 JS 包),以及 fs
(允许我们读写项目目录中的文件)。
让我们声明一些常量,以便在整个指南中使用。将以下代码添加到 app.ts
文件中的导入下方:
const RPC_URL = 'http://127.0.0.1:8899';
const TRANSFER_AMOUNT = LAMPORTS_PER_SOL * 0.01;
const nonceAuthKeypair = Keypair.generate();
const nonceKeypair = Keypair.generate();
const senderKeypair = Keypair.generate();
const connection = new Connection(RPC_URL);
让我们分解一下这些内容:
RPC_URL
- 默认本地 Solana 集群的 URL(如果你想使用 devnet 或 mainnet,只需将连接 URL 更改为你的 QuickNode RPC 端点)TRANSFER_AMOUNT
- 我们在示例交易中转移的 SOL 数量nonceAuthKeypair
- 随机数授权账户的密钥对nonceKeypair
- 随机数账户的密钥对senderKeypair
- 发送者账户的密钥对connection
- 与本地 Solana 集群的连接最后,创建一个名为 main
的异步函数,并添加以下代码:
async function main() {
const { useNonce, waitTime } = parseCommandLineArgs();
console.log(`尝试使用 ${useNonce ? "随机数" : "最近的区块哈希"} 发送交易。等待 ${waitTime}ms 以模拟离线交易。`)
try {
// 第一步 - 为随机数授权账户提供资金
await fundAccounts([nonceAuthKeypair, senderKeypair]);
// 第二步 - 创建随机数账户
await createNonce();
// 第三步 - 创建一个交易
await createTx(useNonce);
// 第四步 - 离线签署交易
await signOffline(waitTime, useNonce);
// 第五步 - 执行交易
await executeTx();
} catch (error) {
console.error(error);
}
}
我们概述了创建随机数、生成交易、离线签署它和执行它的步骤。我们将在后续过程中填写每个步骤的细节。我们还将使用命令行参数来启用一些后续场景测试。我们将使用一个 布尔值 useNonce
和一个以毫秒为单位的 waitTime
来帮助我们测试离线签名。
让我们创建几个可以帮助处理重复任务的函数。
要获取随机数信息,我们需要获取随机数账户的账户信息。为此,我们将使用 Connection
类中的 getAccountInfo
方法。我们还必须使用 NonceAccount
类中的 fromAccountData
方法来解码账户数据。将以下代码添加到 app.ts
文件中:
async function fetchNonceInfo() {
const accountInfo = await connection.getAccountInfo(nonceKeypair.publicKey);
if (!accountInfo) throw new Error("未找到账户信息");
const nonceAccount = NonceAccount.fromAccountData(accountInfo.data);
console.log(" 授权:", nonceAccount.authorizedPubkey.toBase58());
console.log(" 随机数:", nonceAccount.nonce);
return nonceAccount;
}
我们将使用命令行参数来启用后续场景测试。我们将使用一个 布尔值 useNonce
和一个以毫秒为单位的 waitTime
来帮助我们测试离线签名。将以下代码添加到 app.ts
文件中:
function parseCommandLineArgs() {
let useNonce = false;
let waitTime = 120000;
for (let i = 2; i < process.argv.length; i++) {
if (process.argv[i] === '-useNonce') {
useNonce = true;
} else if (process.argv[i] === '-waitTime') {
if (i + 1 < process.argv.length) {
waitTime = parseInt(process.argv[i + 1]);
i++;
} else {
console.error('错误: -waitTime 标志需要一个参数');
process.exit(1);
}
} else {
console.error(`错误: 未知参数 '${process.argv[i]}'`);
process.exit(1);
}
}
return { useNonce, waitTime };
}
此函数将解析命令行参数('-useNonce' 和 '-waitTime'),使用一个 for 循环 遍历每个命令行参数,以 process.argv
为依据。该函数将返回 useNonce
和 waitTime
值。
让我们创建一个编码并将序列化交易写入文件的函数。将以下代码添加到 app.ts
文件中:
async function encodeAndWriteTransaction(tx: Transaction, filename: string, requireAllSignatures = true) {
const serialisedTx = encode(tx.serialize({ requireAllSignatures }));
fs.writeFileSync(filename, serialisedTx);
console.log(` 交易已写入 ${filename}`);
return serialisedTx;
}
我们使用导入的 bs58
库中的 encode
方法对序列化的交易进行编码。然后使用 fs
库中的 writeFileSync
方法将编码后的交易写入文件。我们有一个可选参数 requireAllSignatures
,默认为 true
。这将要求在交易中包含所有签名。我们的部分未签名交易将没有所有签名,因此在调用此函数时会将其设置为 false
。
让我们创建一个函数来读取并解码我们从文件中获取的序列化交易。将以下代码添加到 app.ts
文件中:
async function readAndDecodeTransaction(filename: string): Promise<Transaction> {
const transactionData = fs.readFileSync(filename, 'utf-8');
const decodedData = decode(transactionData);
const transaction = Transaction.from(decodedData);
return transaction;
}
我们使用 fs
库从文件中读取交易数据。然后使用 bs58
库中的 decode
方法。最后,我们使用 _Transaction_
类中的 from
方法以解码后的数据显示出交易对象。
首先,让我们为确认交易已经处理完成定义一个辅助函数。将以下代码添加到 app.ts
文件中:
async function confirmTransaction(
connection: Connection,
signature: TransactionSignature,
desiredConfirmationStatus: TransactionConfirmationStatus = 'confirmed',
timeout: number = 30000,
pollInterval: number = 1000,
searchTransactionHistory: boolean = false
): Promise<SignatureStatus> {
const start = Date.now();
while (Date.now() - start < timeout) {
const { value: statuses } = await connection.getSignatureStatuses([signature], { searchTransactionHistory });
if (!statuses || statuses.length === 0) {
throw new Error('获取签名状态失败');
}
const status = statuses[0];
if (status === null) {
await new Promise(resolve => setTimeout(resolve, pollInterval));
continue;
}
if (status.err) {
throw new Error(`交易失败: ${JSON.stringify(status.err)}`);
}
if (status.confirmationStatus && status.confirmationStatus === desiredConfirmationStatus) {
return status;
}
if (status.confirmationStatus === 'finalized') {
return status;
}
await new Promise(resolve => setTimeout(resolve, pollInterval));
}
throw new Error(`交易确认超时,超过 ${timeout}ms`);
}
此函数将轮询 Solana 网络以获取交易状态,直到它被确认或超时为止。我们为超时和轮询间隔包含一些默认值,但你可以根据需要进行调整。
太棒了!现在让我们构建在 main
函数中概述的每一个步骤。
我们需要一些测试 SOL 来实现我们的交易。让我们创建一个名为 fundAccounts
的函数,并添加以下代码:
async function fundAccounts(accountsToFund: Keypair[]) {
console.log("---第1步---为账户提供资金");
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();
const airdropPromises = accountsToFund.map(account => {
return connection.requestAirdrop(account.publicKey, LAMPORTS_PER_SOL);
});
const airDropSignatures = await Promise.all(airdropPromises).catch(error => {
console.error("请求空投失败: ", error);
throw error;
});
const airdropConfirmations = airDropSignatures.map(signature => {
return confirmTransaction(connection, signature, 'finalized');
});
await Promise.all(airdropConfirmations).catch(error => {
console.error("确认空投失败: ", error);
throw error;
});
}
我们简单地传递一个 Keypair 数组,并使用 requestAirdrop
方法为每个账户请求 1 SOL 的空投。我们在网络上等待空投被确认( 已完成),以确保我们的资金可用于后续步骤。
在使用随机数创建交易之前,我们需要创建一个随机数账户。让我们创建一个名为 createNonce
的函数,并添加以下代码:
async function createNonce() {
console.log("---第2步---创建随机数账户");
const newNonceTx = new Transaction();
const rent = await connection.getMinimumBalanceForRentExemption(NONCE_ACCOUNT_LENGTH);
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();
newNonceTx.feePayer = nonceAuthKeypair.publicKey;
newNonceTx.recentBlockhash = blockhash;
newNonceTx.lastValidBlockHeight = lastValidBlockHeight;
newNonceTx.add(
SystemProgram.createAccount({
fromPubkey: nonceAuthKeypair.publicKey,
newAccountPubkey: nonceKeypair.publicKey,
lamports: rent,
space: NONCE_ACCOUNT_LENGTH,
programId: SystemProgram.programId,
}),
SystemProgram.nonceInitialize({
noncePubkey: nonceKeypair.publicKey,
authorizedPubkey: nonceAuthKeypair.publicKey,
})
);
newNonceTx.sign(nonceKeypair, nonceAuthKeypair);
try {
const signature = await connection.sendRawTransaction(newNonceTx.serialize());
await confirmTransaction(connection, signature, 'finalized');
console.log(" 随机数账户已创建: ", signature);
} catch (error) {
console.error("创建随机数账户失败: ", error);
throw error;
}
}
我们正在创建一个新的交易 newNonceTx
,并添加两个指令。第一个指令是使用 SystemProgram.createAccount
方法创建一个新账户。我们使用 nonceAuthKeypair
为新账户 nonceKeypair
提供资金。我们还使用 SystemProgram.nonceInitialize
方法将该账户初始化为随机数账户。我们将 nonceAuthKeypair
作为 authorizedPubkey
,将 nonceKeypair
作为 noncePubkey
。然后我们使用 nonceKeypair
和 nonceAuthKeypair
签署该交易并将其发送到网络。
如果交易成功,我们现在将获得一个随机数账户,可以在后续交易中使用我们的 nonceAuthKeypair
作为权限。
我们现在准备好创建将用于离线签名的交易。让我们创建一个名为 createTx
的函数,并添加以下代码:
async function createTx(useNonce = false) {
console.log("---第3步---创建交易");
const destination = Keypair.generate();
const transferIx = SystemProgram.transfer({
fromPubkey: senderKeypair.publicKey,
toPubkey: destination.publicKey,
lamports: TRANSFER_AMOUNT,
});
const advanceIx = SystemProgram.nonceAdvance({
authorizedPubkey: nonceAuthKeypair.publicKey,
noncePubkey: nonceKeypair.publicKey
})
const sampleTx = new Transaction();
if (useNonce) {
sampleTx.add(advanceIx, transferIx);
const nonceAccount = await fetchNonceInfo();
sampleTx.recentBlockhash = nonceAccount.nonce;
}
else {
sampleTx.add(transferIx);
sampleTx.recentBlockhash = (await connection.getLatestBlockhash()).blockhash;
}
sampleTx.feePayer = senderKeypair.publicKey;
const serialisedTx = encodeAndWriteTransaction(sampleTx, './unsigned.json', false);
return serialisedTx;
}
我们的函数接受一个参数 useNonce
,用来确定我们是否想在交易中使用随机数(如果不使用,则使用最近的区块哈希)。该交易将从 senderKeypair
转移 TRANSFER_AMOUNT
到一个新的随机生成的账户 destination
。该函数将返回序列化的交易,以便我们可以在离线签名中使用它。让我们进一步分解一下这个函数:
Keypair.generate()
方法创建一个新的 destination
账户。SystemProgram.transfer
方法创建一个 transferIx
指令。该指令将把 TRANSFER_AMOUNT
从 senderKeypair
转移到 destination
账户。SystemProgram.nonceAdvance
方法创建一个 advanceIx
指令。该指令将使用 nonceAuthKeypair
作为权限推进随机数账户。sampleTx
交易。useNonce
为 true,我们在 transferIx
指令之前将 advanceIx
指令添加到交易中。我们还将 recentBlockhash
设置为随机数账户的随机数。useNonce
为 false,我们仅将 transferIx
指令放入交易,并将 recentBlockhash
设置为网络上的最新区块哈希。feePayer
设置为发送 SOL 的同一账户( senderKeypair
)我们将通过添加一些处理时间( waitTime
)来模拟一次离线交易,而不是断开互联网或前往冷存储设备。让我们创建一个名为 signOffline
的函数,并添加以下代码:
async function signOffline(waitTime = 120000, useNonce = false): Promise<string> {
console.log("---第4步---离线签署交易");
await new Promise((resolve) => setTimeout(resolve, waitTime));
const unsignedTx = await readAndDecodeTransaction('./unsigned.json');
if (useNonce) unsignedTx.sign(nonceAuthKeypair, senderKeypair);
else unsignedTx.sign(senderKeypair);
const serialisedTx = encodeAndWriteTransaction(unsignedTx, './signed.json');
return serialisedTx;
}
如你所见,我们传递一个可选参数 waitTime
,通过 setTimeout
函数调用。这将模拟将交易转移到冷存储设备、签署并转移回的耗时。我们将此作为参数包含在内,以便你可以调整时间,以便观察随机数账户如何用于离线签署交易。
然后我们从先前步骤中读取未签名的交易,使用 nonceAuthKeypair
(如果 useNonce
为 true)和 senderKeypair
签署交易,并将签署后的交易写入名为 signed.json
的文件。我们返回序列化的交易,以便我们可以在下一步中使用它。
我们现在应该在名为 signed.json
的文件中保存了签署的交易。让我们创建一个名为 sendSignedTx
的函数,以解码交易并将其发送到网络:
async function executeTx() {
console.log("---第5步---执行交易");
const signedTx = await readAndDecodeTransaction('./signed.json');
const sig = await connection.sendRawTransaction(signedTx.serialize());
console.log(" 交易已发送: ", sig);
}
我们的辅助函数使这一切变得简单!我们只需从文件中读取签署的交易,序列化后发送到网络。
太棒了!让我们测试一下。在你的文件底部添加以下代码:
main();
这将调用我们的 main
函数,在我们运行程序时执行上面的内容。
到目前为止,你做得非常好。现在我们需要做的就是运行我们的程序。如果你像上面那样使用本地网络,你需要打开两个终端。在第一个终端中,运行以下命令:
solana-test-validator
这将启动你的本地 Solana 集群。
如果你回忆起来,我们的程序期望两个参数,useNonce
和 waitTime
。让我们运行三个模拟以查看随机数账户是如何工作的:
模拟 | useNonce | waitTime | 预期结果 |
---|---|---|---|
1. 模拟使用最近区块哈希的典型交易 | false | 0 | 成功 |
2. 模拟使用区块哈希的离线(延迟)交易 | false | 120000 | 失败 |
3. 模拟使用随机数账户的离线(延迟)交易 | true | 120000 | 成功 |
在第二个终端中,运行你的第一次模拟,一个使用最近区块哈希的典型“在线”交易(我们通过将 waitTime
设置为 0,并且 不 使用 useNonce
参数实现这个):
ts-node app -waitTime 0 # 默认不使用 useNonce
这是一个典型的交易,我们应该看到成功的结果。
在第二个终端中,运行你的第二次模拟,以模拟使用最近区块哈希的离线交易。由于区块哈希在交易处理之前会过期,因此我们 期待看到错误。运行以下命令:
ts-node app -waitTime 120000 # 默认不使用 useNonce
你看到错误了吗(例如,Blockhash 未找到
)?如果是太好了!这是我们预期的。如果没有,请尝试增加 waitTime
并再次运行该命令。
在第二个终端中,运行你的第三次模拟,以模拟使用随机数账户的离线交易。由于随机数账户不会过期,因此我们 期待看到成功的结果。运行以下命令:
ts-node app -useNonce -waitTime 120000
由于我们使用了随机数账户,因此不应该有任何区块哈希过期的问题。我们应该看到类似这样的成功结果:
尝试使用随机数发送交易。等待 12000ms 以模拟离线交易。
---第1步---为账户提供资金
---第2步---创建随机数账户
随机数账户已创建: 3XzR...USMq
---第3步---创建交易
授权: EVxuoBFLQ8KpTLChkgW4RBU5pdxvCLUndntUsNW1cSyQ
随机数: H4rwV9cdhcwNk4jSUxRTgrnSnkLYCTU7c8Vc3ZS6RNRQ
交易已写入 ./unsigned.json
---第4步---离线签署交易
交易已写入 ./signed.json
---第5步---执行交易
交易已发送: 5Ep3sPV1r1hr73kKQG8Q2xD4B9erPEzW7Hitxwe8iwBooXFq8iz4WC6YzrRE6VBUL8arZHqmYKBF52QrPKbRgmRK
好的工作 🎉!你已经成功创建了随机数账户,并利用它执行了离线交易。
你已成功创建随机数账户并利用它执行离线交易。你已准备好在 Solana 旅程的下一个阶段迈出步伐。
你计划如何在工作流中使用随机数和离线交易?我们很想知道你正在进行什么!请在 Discord 中与我们联系或在 Twitter 上跟随我们,以获取所有最新信息!
让我们知道如果你有任何反馈或对新主题的请求。我们期待你的消息。
- 原文链接: quicknode.com/guides/sol...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!