Solana 社区正在不断改进并拓展网络的功能。但这并不总是意味着要开发全新的技术,有时也意味着以创新的方式利用现有的网络特性。
Solana Pay 就是一个很好的例子。它并没有为网络引入新的功能,而是巧妙地利用了 Solana 网络已有的签名机制,使商家和应用可以发起交易请求,并基于特定交易类型构建 “门控机制”(gating mechanism)。
在本教程中,你将学习如何使用 Solana Pay 创建转账与交易请求、将请求编码为二维码、对交易进行部分签名,并根据你设定的条件对交易进行访问控制。我们希望你不仅能掌握这些技巧,更能将其视为一种创新思路 - 利用网络现有能力进行创造性开发,为你后续构建独特的客户端交互提供灵感与跳板。
Solana Pay 规范[1]是一组标准,允许用户在各种 Solana 应用程序和钱包中统一使用 URL 请求付款和启动交易。
请求 URL 以 solana: 为前缀,以便平台可以将链接定向到适当的应用程序。例如,在移动设备上,以 solana: 开头的 URL 将被定向到支持 Solana Pay 规范的钱包应用程序。从那里,钱包可以使用 URL 的其余部分来适当地处理请求。
Solana Pay 规范定义了两种类型的请求:
1. 传输请求:用于简单的 SOL 或 SPL Token 传输
2. 交易请求:用于请求任何类型的 Solana 交易
转账请求规范描述了一种非交互式的 SOL 或 SPL Token 转账请求方式。转账请求的 URL 格式如下:
solana:<recipient>?<optional-query-params>
其中:
• recipient:是必填项,必须是一个 base58 编码的公钥,表示被请求进行转账的账户地址。
此外,还支持以下可选查询参数:
• amount:一个非负整数或小数,表示请求转账的 Token 数量;
• spl-token:若请求的是 SPL Token(而非 SOL),则该字段为 SPL Token 的铸币账户(mint account)地址,需为 base58 编码的公钥;
• reference:一个或多个 base58 编码的 32 字节数组,用作交易的参考值。由于客户端可能无法获得交易签名,该字段可用于链上识别交易;
* label:一个 URL 编码的 UTF-8 字符串,用于标识此次请求的来源;
• message:一个 URL 编码的 UTF-8 字符串,用于描述此次转账请求的用途或背景;
• memo:一个 UR L 编码的 UTF-8 字符串,必须作为 SPL Memo 指令写入转账交易中。
以下是一个请求转账 1 SOL 的 URL 示例:
solana:mvines9iiHiQTysrwkJjGf2gb9Ex9jXJX8ns3qwf2kN?
amount=1&label=Michael&message=Thanks%20for%20all%20the%20fish&memo=OrderId1234
下面是一个请求 0.1 USDC 转账的 URL 示例:
solana:mvines9iiHiQTysrwkJjGf2gb9Ex9jXJX8ns3qwf2kN?amount=0.01&spl-token=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v
Solana Pay 的交易请求与转账请求类似,都是钱包可以识别的 URL。但不同的是,交易请求是交互式的,且格式更具扩展性。格式如下:
solana:<link>
其中,link 是一个 URL,钱包会向它发起 HTTP 请求来获取实际的交易信息,而不是像转账请求那样直接包含所有交易数据。
当钱包收到交易请求 URL 时,会发生四件事:
1. GET 请求: 钱包向 link 发起 GET 请求,获取 label(描述)和 icon(图标)用于展示。
2. POST 请求: 钱包随后会发送用户的公钥(public key)到该链接。
3. 服务端构建交易:根据用户公钥(及其他参数),构建交易并返回序列化后的 base64 格式交易。
4. 用户签名并广播交易: 钱包解码交易,提示用户签名并发送。
鉴于交易请求比传输请求更复杂,本教程的其余部分将重点介绍创建交易请求。
开发者需要做的第一步是设置一个 REST API 端点,并将其 URL 包含在交易请求中。在本教程中,我们将使用 Next.jsAPIRoutes[2] ,但您可以选择您最熟悉的堆栈和工具。
在 Next.js 中,可以通过在 pages/api 文件夹中创建一个文件并导出一个处理请求和响应的函数来实现:
import { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
request: NextApiRequest,
response: NextApiResponse,
){
// Handle the requlest
}
钱包在消费交易请求 URL 时会首先发起一个 GET 请求到该端点。你的端点需要返回一个包含以下两个字段的 JSON 对象:
1. label:描述交易请求来源的字符串。
2. icon:一个 URL,指向可供用户查看的图标。
以下是实现的示例代码:
import { NextApiRequest, NextApiResponse } from"next";
exportdefaultasyncfunctionhandler(
request: NextApiRequest,
response: NextApiResponse,
) {
if (request.method === "GET") {
returnget(response);
}
return response.status(405).json({ error: "Method not allowed" });
}
functionget(response: NextApiResponse) {
response.status(200).json({
label: "Store Name",
icon: "https://solana.com/src/img/branding/solanaLogoMark.svg",
});
}
当钱包对 API 端点发起 GET 请求时,get 函数会被调用,返回状态码为 200 的响应以及包含 label 和 icon 的 JSON 对象。
在发出 GET 请求后,钱包会对同一 URL 发起 POST 请求。POST 请求的 body 会包含一个 JSON 对象,其中 account 字段是请求钱包提供的用户公钥字符串。
以下是处理 POST 请求的步骤:
1. 连接到 Solana 网络并获取最新的 Blockhash;
2. 使用区块哈希创建新交易;
3. 向交易中添加指令(instructions);
4. 将交易序列化,并与提示用户的信息一同封装到 PostResponse 对象中返回。
以下是完整代码示例:
import { NextApiRequest, NextApiResponse } from"next";
exportdefaultasyncfunctionhandler(
request: NextApiRequest,
response: NextApiResponse,
) {
if (request.method === "GET") {
returnget(response);
}
if (request.method === "POST") {
returnpost(request, response);
}
return response.status(405).json({ error: "Method not allowed" });
}
functionget(response: NextApiResponse) {
response.status(200).json({
label: "Store Name",
icon: "https://solana.com/src/img/branding/solanaLogoMark.svg",
});
}
asyncfunctionpost(request: NextApiRequest, response: NextApiResponse) {
const { account, reference } = request.body;
const connection = newConnection(clusterApiUrl("devnet"));
const { blockhash } = await connection.getLatestBlockhash();
const transaction = newTransaction({
recentBlockhash: blockhash,
feePayer: account,
});
const instruction = SystemProgram.transfer({
fromPubkey: newPublicKey(account),
toPubkey: Keypair.generate().publicKey,
lamports: 0.001 * LAMPORTS_PER_SOL,
});
instruction.keys.push({
pubkey: reference,
isSigner: false,
isWritable: false,
});
transaction.add(instruction);
const serializedTransaction = transaction.serialize({
requireAllSignatures: false,
});
const base64 = serializedTransaction.toString("base64");
const message = "Simple transfer of 0.001 SOL";
response.status(200).json({
transaction: base64,
message,
});
}
这部分没有什么特别之处,它采用的交易构建方式与标准的客户端应用中所用的完全相同。唯一的区别在于: 你并不是将交易签名后提交到链上,而是将其作为一个 Base64 编码的字符串,通过 HTTP 响应返回。
随后,发起请求的钱包可以将这笔交易呈现给用户进行签名。
您可能已经注意到,前面的示例假定提供了一个 reference 作为查询参数。虽然这不是请求钱包提供的值,但设置初始交易请求 URL 以包含此查询参数是有用的。
由于您的应用程序不是向网络提交交易的应用程序,因此您的代码将无法访问交易签名。这通常是您的应用程序在网络上定位交易并查看其状态的方式。
为了解决这个问题,您可以在每个交易请求中包含一个 reference 参数(一个 Base58 编码的 32 字节数组),并将其作为非签名密钥添加到交易中。这允许您的应用程序使用
getSignaturesForAddress RPC 方法来定位交易。随后,您的应用可以根据交易状态调整其用户界面。
如果使用 @solana/pay 库,可以使用 findReference 辅助函数,而不是直接调用 getSignaturesForAddress。
我们之前提到过,Solana Pay 是如何通过创造性使用现有功能实现创新网络用例的典型案例。在 Solana Pay 框架下的另一个小例子是:仅在满足特定条件时开放某些交易。
由于您控制着构建交易的端点,可以自主决定交易生效前需要满足的条件。例如,您可以通过 POST 请求中的 account 字段检查终端用户是否持有某个系列的 NFT,或验证该公钥是否位于允许执行此交易的预设账户列表中。
// retrieve array of nfts owned by the given wallet
const nfts = await metaplex.nfts().findAllByOwner({ owner: account }).run();
// iterate over the nfts array
for (let i = 0; i < nfts.length; i++) {
// check if the current nft has a collection field with the desired value
if (nfts[i].collection?.address.toString() == collection.toString()) {
// build transaction
} else {
// return an error
}
}
如果您希望通过某种限制机制控制交易权限,该功能必须在链上强制执行。虽然从 Solana Pay 端点返回错误会增加终端用户手动发起交易的难度,但他们仍可绕过限制直接构建交易。
这意味着被调用的指令需要某种仅由您的应用程序提供的 "管理员" 签名。然而,这样做会导致之前的示例失效——交易虽然能被构建并发送到请求钱包等待用户签名,但由于缺少管理员签名,提交的交易最终会失败。
幸运的是,Solana 通过部分签名实现了签名可组合性。
在多重签名交易中进行部分签名,允许签名者在交易广播到网络前添加自己的签名。这在以下场景中非常实用:
• 多方授权交易: 例如需要商户和买家共同确认支付细节的场景
• 定制化程序调用:要求用户和管理员共同签名的场景,可限制程序指令的访问权限
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash()
const transaction = new Transaction({
feePayer: account,
blockhash,
lastValidBlockHeight,
})
...
transaction.partialSign(adminKeypair)
partialSign 函数可在不覆盖已有签名的情况下为交易添加签名。注意:若未指定 feePayer,系统将默认首个签名者作为手续费支付方。为避免混淆,建议始终显式设置该参数。
在前文的 NFT 验证示例中,您只需在将交易编码为 base64 字符串并通过 HTTP 响应返回之前,使用 partialSign 添加管理员签名即可。
Solana Pay 的突出特性之一是便捷的二维码集成。由于转账和交易请求本质上是 URL,您可以直接将其嵌入应用程序内外的二维码中。
而 @solana/pay 库通过提供的 createQR 帮助函数简化了这一过程。该函数需要以下参数:
• url:交易请求的 URL(必填)
• size:二维码尺寸(像素,可选,默认 512)
• background:背景色(可选,默认白色)
• color:前景色(可选,默认黑色)
$const$ $qr=createQR(url$ ,400,"transparent");
现在您已经掌握了 Solana Pay 的概念,让我们通过实践来加深理解。我们将使用 Solana Pay 生成一系列二维码,打造一个顺序寻宝游戏。参与者必须按顺序访问每个藏宝点,并使用提供的二维码向追踪用户进度的智能合约提交对应交易。
第一步: 从 代码仓库 的 starter 分支下载初始代码。这个基于 Next.js 的应用已包含 Solana Pay 二维码展示功能。注意菜单栏可切换不同二维码(默认展示 SOL 转账示例)。本实验将逐步为各个藏宝点添加功能。
为此,我们将为一个事务请求创建一个新的端点,该端点构建一个用于在 Devnet 上调用 Anchor 程序的事务。该程序专门为这个 “寻宝游戏” 应用程序制作,包含两条指令:
initialize:初始化用户状态
check_in:记录寻宝游戏中某个位置的签入。
程序代码已预置,本实验无需修改,但如果您想熟悉该程序,请随时查看源代码[3]。
在继续之前,请确保熟悉寻宝游戏应用程序的起始代码。
查看 pages/index.tsx、utils/createQrCode/simpleTransfer 和 utils/checkTransaction,以了解发送 SOL 的交易请求是如何设置的。我们将在为签到位置创建交易请求时遵循类似的模式。
在继续之前,请确保您可以在本地运行该应用程序。
首先,将 frontend 目录中的 .env.example 文件重命名为 .env。此文件包含一个密钥对,将在本次实验中用于部分签署交易。
接下来,使用 yarn 安装依赖项,然后运行 yarn dev 并在浏览器中打开 localhost:3000(如果端口 3000 已被占用,请使用控制台中显示的端口)。
现在,如果您尝试从移动设备扫描页面上显示的二维码,您会收到一个错误。这是因为二维码设置为将您引导至计算机的 localhost:3000,而这并不是手机可以访问的地址。此外,Solana Pay 需要使用 HTTPSURL 才能正常工作。
为了解决这个问题,你可以使用 ngrok[4]。如果您之前未使用过它,则需要先安装。安装完成后,在终端中运行以下命令,将 3000 替换为您用于此项目的任何端口:
ngrok http 3000
这将为您提供一个唯一的 URL,您可以使用它远程访问本地服务器。输出示例如下:
Session Status online
Account your_email@gmail.com (Plan: Free)
Update update available (version 3.1.0, Ctrl-U to update)
Version 3.0.6
Region United States (us)
Latency 45ms
Web Interface http://127.0.0.1:4040
Forwarding https://7761-24-28-107-82.ngrok.io -> http://localhost:3000
现在,在浏览器中打开控制台中显示的 HTTPS ngrokURL(例如 https://7761-24-28-107-82.ngrok.io[5])。这将允许您在本地测试时从移动设备扫描二维码。
在撰写本文时,这个实验在 Solflare 钱包中效果最佳。一些钱包在扫描 Solana Pay 二维码时可能会显示错误警告信息。无论您使用哪个钱包,请确保将钱包切换到 Devnet。
然后扫描主页上标记为 "SOL Transfer" 的二维码。此二维码是一个参考实现,用于执行简单的 SOL 转账交易请求。它还调用了 requestAirdrop 函数,为您的移动钱包提供 Devnet SOL,因为大多数人没有可用于测试的 Devnet SOL。
如果您能够成功使用二维码执行交易,那么您可以继续进行下一步了!
现在您已准备就绪,是时候创建一个支持签到交易请求的端点了,该端点将使用寻宝游戏程序。
首先在 page/api/checkIn.ts 打开文件。请注意,它有一个帮助函数,用于从密钥环境变量初始化 eventOrganer。我们将在这个文件中做的第一件事是:
1. 导出处理程序 函数以处理任意 HTTP 请求
2. 添加 get 和 post 函数来处理这些 HTTP 方法
3. 在 handler 函数体中添加逻辑,根据 HTTP 请求方法调用 get、post 或返回 405 错误
import { NextApiRequest, NextApiResponse } from"next";
exportdefaultasyncfunctionhandler(
request: NextApiRequest,
response: NextApiResponse,
) {
if (request.method === "GET") {
returnget(response);
}
if (request.method === "POST") {
returnawaitpost(request, response);
}
return response.status(405).json({ error: "Method not allowed" });
}
functionget(response: NextApiResponse) {}
asyncfunctionpost(request: NextApiRequest, response: NextApiResponse) {}
请记住,钱包的第一个请求将是一个 GET 请求,期望端点返回一个标签和图标。更新 get 函数以发送带有 “Scavenger Hunt!” 标签和 Solana 图标的响应。
function get(response: NextApiResponse) {
response.status(200).json({
label: "Scavenger Hunt!",
icon: "https://solana.com/src/img/branding/solanaLogoMark.svg",
});
}
在 GET 请求之后,钱包将向端点发出一个 POST 请求。请求的正文将包含一个 JSON 对象,其中包含一个表示最终用户公钥的 account 字段。
此外,查询参数将包含您编码到二维码中的任何内容。
如果您查看 utils/createQrCode/checkIn.ts,您会注意到此特定应用程序包括以下参数:
1. reference:用于标识交易的随机生成公钥
2. id:位置 ID,表示为整数
继续更新 post 函数以从请求中提取 account、reference 和 id。如果缺少其中任何一个,您应该会返回错误。
接下来,添加一个 try catch 语句,其中 catch 块响应一个错误,try 块调用一个新函数 buildTransaction。如果 buildTransaction 成功,则返回状态码 200 和包含交易及用户已找到该位置的消息的 JSON 对象。暂时不用担心 buildTransaction 函数的逻辑,我们稍后会实现它。
请注意,您还需要从 @solana/web3.js 导入 PublicKey 和 Transaction。
import { NextApiRequest, NextApiResponse } from"next"
import { PublicKey, Transaction } from"@solana/web3.js"
...
asyncfunctionpost(request: NextApiRequest, response: NextApiResponse) {
const { account } = request.body;
const { reference, id } = request.query;
if (!account || !reference || !id) {
response.status(400).json({ error: "Missing required parameter(s)" });
return;
}
try {
const transaction = awaitbuildTransaction(
newPublicKey(account),
newPublicKey(reference),
id.toString(),
);
response.status(200).json({
transaction: transaction,
message: You've found location ${id}!,
});
} catch (error) {
console.log(error);
response.status(500).json({ transaction: "", message: error.message });
return;
}
}
async function buildTransaction(
account: PublicKey,
reference: PublicKey,
id: string
): Promise<string> {
return new Transaction()
}
接下来,让我们实现 buildTransaction 函数。它应该构建、部分签名并返回签入交易。它需要执行以下步骤:
1. 获取用户状态
2. 使用 locationAtIndex 辅助函数和位置 ID 获取一个 Location 对象
3. 验证用户是否在正确的位置
4. 从连接中获取最新的区块哈希和最后有效区块高度
5. 创建一个新的交易对象
6. 如果用户状态不存在,则向交易中添加 initialize 指令
7. 向交易中添加 check_in 指令
8. 将 reference 公钥添加到 check_in 指令
9. 使用交易组织者的密钥对部分签署交易
10. 使用 base64 编码序列化交易并返回事务
为了简化函数,我们将创建空的辅助函数,稍后将为步骤 1、3、6 和 7-8 填充这些函数。
我们将分别称这些为 fetchUserState 、 verifyCorrectLocation 、 createInitUserInstruction 和 createCheckInInstruction 。
此外,我们需要添加以下导入:
import { NextApiRequest, NextApiResponse } from "next";
import {
PublicKey,
Transaction,
TransactionInstruction,
} from "@solana/web3.js";
import { locationAtIndex, Location, locations } from "../../utils/locations";
import { connection, gameId, program } from "../../utils/programSetup";
使用这些空的辅助函数和新的导入,我们可以填写 buildTransaction 函数:
async functionbuildTransaction(
account: PublicKey,
reference: PublicKey,
id: string,
): Promise<string> {
const userState = awaitfetchUserState(account);
const currentLocation = locationAtIndex(newNumber(id).valueOf());
if (!currentLocation) {
throw { message: "Invalid location id" };
}
if (!verifyCorrectLocation(userState, currentLocation)) {
throw { message: "You must visit each location in order!" };
}
const { blockhash, lastValidBlockHeight } =
await connection.getLatestBlockhash();
const transaction = newTransaction({
feePayer: account,
blockhash,
lastValidBlockHeight,
});
if (!userState) {
transaction.add(awaitcreateInitUserInstruction(account));
}
transaction.add(
awaitcreateCheckInInstruction(account, reference, currentLocation),
);
transaction.partialSign(eventOrganizer);
const serializedTransaction = transaction.serialize({
requireAllSignatures: false,
});
const base64 = serializedTransaction.toString("base64");
return base64;
}
interfaceUserState {
user: PublicKey;
gameId: PublicKey;
lastLocation: PublicKey;
}
asyncfunctionfetchUserState(account: PublicKey): Promise<UserState | null> {
returnnull;
}
functionverifyCorrectLocation(
userState: UserState | null,
currentLocation: Location,
): boolean {
returnfalse;
}
asyncfunctioncreateInitUserInstruction(
account: PublicKey,
): Promise<TransactionInstruction> {
throw"";
}
asyncfunctioncreateCheckInInstruction(
account: PublicKey,
reference: PublicKey,
location: Location,
): Promise<TransactionInstruction> {
throw"";
}
完成 buildTransaction 函数后,我们可以开始实现我们创建的空辅助函数,从 fetchUserState 开始。该函数使用 gameId 和用户的帐户来派生用户状态 PDA,然后获取该账户,如果不存在则返回 null。
async functionfetchUserState(account: PublicKey): Promise<UserState | null> {
const userStatePDA = PublicKey.findProgramAddressSync(
[gameId.toBuffer(), account.toBuffer()],
program.programId,
)[0];
try {
returnawait program.account.userState.fetch(userStatePDA);
} catch {
returnnull;
}
}
接下来,我们实现 verifyCorrectLocation 辅助函数。该函数用于验证用户在寻宝游戏中的正确位置。
如果 userState 为 null,这意味着用户应该访问第一个位置。否则,用户应该访问索引比其上次访问位置大 1 的位置。
如果满足这些条件,该函数将返回 true。否则,它将返回 false。
function verifyCorrectLocation(
userState: UserState | null,
currentLocation: Location,
): boolean {
if (!userState) {
return currentLocation.index === 1;
}
const lastLocation = locations.find(
(location) => location.key.toString() === userState.lastLocation.toString(),
);
if (!lastLocation || currentLocation.index !== lastLocation.index + 1) {
returnfalse;
}
returntrue;
}
最后,我们实现 createInitUserInstruction 和 createCheckInInstruction 函数。
它们可以使用 Anchor 来生成并返回相应的指令。唯一需要注意的是,createCheckInInstruction 需要将 reference 添加到指令的密钥列表中。
async functioncreateInitUserInstruction(
account: PublicKey,
): Promise<TransactionInstruction> {
const initializeInstruction = await program.methods
.initialize(gameId)
.accounts({ user: account })
.instruction();
return initializeInstruction;
}
asyncfunctioncreateCheckInInstruction(
account: PublicKey,
reference: PublicKey,
location: Location,
): Promise<TransactionInstruction> {
const checkInInstruction = await program.methods
.checkIn(gameId, location.key)
.accounts({
user: account,
eventOrganizer: eventOrganizer.publicKey,
})
.instruction();
checkInInstruction.keys.push({
pubkey: reference,
isSigner: false,
isWritable: false,
});
return checkInInstruction;
}
此时,您的应用程序应该可以正常工作了!继续使用您的移动钱包进行测试。首先扫描 Location 1 的二维码。记得确保您的前端使用 ngrokURL 而不是 localhost 运行。
扫描二维码后,您应该会看到一条消息,表明您在 Loacation 1。从那里,扫描 Location 2 页面上的二维码。在继续之前,您可能需要等待几秒钟才能完成上一笔交易。
恭喜,您已成功使用 Solana Pay 完成了寻宝游戏演示!根据您的背景,这可能感觉不直观或不直接。如果是这种情况,请随意再次进行实验或自己制作一些东西。Solana Pay 为弥合现实生活和链上交互之间的差距打开了很多大门。
如果您想查看最终的解决方案代码,您可以在同一存储库[6]的解决方案分支上找到它。
引用链接
[1] Solana Pay 规范:https://docs.solanapay.com/spec
[2]Next.jsAPIRoutes:https://nextjs.org/docs/api-routes/introduction
[3]源代码:https://github.com/Unboxed-Software/anchor-scavenger-hunt
[4]ngrok:https://ngrok.com/
[5]https://7761-24-28-107-82.ngrok.io:https://7761-24-28-107-82.ngrok.io/
[6]同一存储库:https://github.com/Unboxed-Software/solana-scavenger-hunt-app/tree/solution
【免责声明】市场有风险,投资需谨慎。本文不构成投资建议,用户应考虑本文中的任何意见、观点或结论是否符合其特定状况。据此投资,责任自负。