如何在 Solana 上使用 Durable Nonce 发送离线交易

  • QuickNode
  • 发布于 2025-01-25 19:54
  • 阅读 18

文章主要介绍了如何在Solana区块链上使用持久的nonce来签名和发送离线交易,以避免交易过期的问题。

概述

持久性随机数(durable nonces)是一种便捷的工具,可以用来避免交易过期。在本指南中,我们将向你展示如何使用持久性随机数来签署和发送离线交易,而无需担心交易过期。

你将要做的事情

在本指南中,你将:

  1. 创建一个持久性随机数账户
  2. 使用持久性随机数创建并序列化一个交易
  3. 在模拟离线环境中签署交易
  4. 将签署后的交易发送到 Solana 网络
  5. 尝试几个场景以测试随机数的工作原理

你将需要的东西

依赖项 版本
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 为依据。该函数将返回 useNoncewaitTime 值。

编码并写入交易

让我们创建一个编码并将序列化交易写入文件的函数。将以下代码添加到 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。然后我们使用 nonceKeypairnonceAuthKeypair 签署该交易并将其发送到网络。

如果交易成功,我们现在将获得一个随机数账户,可以在后续交易中使用我们的 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_AMOUNTsenderKeypair 转移到 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 集群。

如果你回忆起来,我们的程序期望两个参数,useNoncewaitTime。让我们运行三个模拟以查看随机数账户是如何工作的:

模拟 useNonce waitTime 预期结果
1. 模拟使用最近区块哈希的典型交易 false 0 成功
2. 模拟使用区块哈希的离线(延迟)交易 false 120000 失败
3. 模拟使用随机数账户的离线(延迟)交易 true 120000 成功

模拟 1 - 典型交易

在第二个终端中,运行你的第一次模拟,一个使用最近区块哈希的典型“在线”交易(我们通过将 waitTime 设置为 0,并且 使用 useNonce 参数实现这个):

ts-node app -waitTime 0 # 默认不使用 useNonce

这是一个典型的交易,我们应该看到成功的结果。

模拟 2 - 使用区块哈希的离线交易

在第二个终端中,运行你的第二次模拟,以模拟使用最近区块哈希的离线交易。由于区块哈希在交易处理之前会过期,因此我们 期待看到错误。运行以下命令:

ts-node app -waitTime 120000 # 默认不使用 useNonce

你看到错误了吗(例如,Blockhash 未找到)?如果是太好了!这是我们预期的。如果没有,请尝试增加 waitTime 并再次运行该命令。

模拟 3 - 使用随机数账户的离线交易

在第二个终端中,运行你的第三次模拟,以模拟使用随机数账户的离线交易。由于随机数账户不会过期,因此我们 期待看到成功的结果。运行以下命令:

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 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
QuickNode
QuickNode
江湖只有他的大名,没有他的介绍。