From 850ac96d03841cd4457c95ae4dc6da0967da7a08 Mon Sep 17 00:00:00 2001 From: v1xingyue Date: Fri, 1 Mar 2024 09:12:21 +0800 Subject: [PATCH 1/5] Update README.md --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 9af4d4c2e..57e07cd39 100644 --- a/README.md +++ b/README.md @@ -125,3 +125,7 @@ Rust 速览、Solana Local Node、Solana CLI Tools、Network Wallet 交互 - Metaplex SDK 发布 NFT - 获取不同的NFT + +# Links + +* [往期黑客松项目参考](https://hyperdrive.solana.com/projects/explore) From 8dc134522679bc5c5481939530918747d01e2133 Mon Sep 17 00:00:00 2001 From: Whitehare2023 Date: Sun, 3 Mar 2024 21:33:00 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=B8=AD=E6=96=87?= =?UTF-8?q?=E7=89=88=E8=AE=B2=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../module1-Introduction/start.md | 95 +++ ...1-1-cryptography-and-the-solana-network.md | 182 ++++++ .../1-2-read-data-from-the-solana-network.md | 191 ++++++ .../1-3-write-data-to-the-solana-network.md | 195 ++++++ .../1-4-using-custom-on-chain-programs.md | 252 ++++++++ .../1-5-interact-with-wallets.md | 487 +++++++++++++++ .../1-6-serialize-custom-instruction-data.md | 227 +++++++ .../1-7-deserialize-program-data.md | 164 +++++ .../1-8-page-order-filter-program-data.md | 438 ++++++++++++++ ...-1-Create-tokens-with-the-Token-Program.md | 510 ++++++++++++++++ .../2-2-Create-Solana-NFTs-With-Metaplex.md | 567 ++++++++++++++++++ 11 files changed, 3308 insertions(+) create mode 100755 solana-development-course-zh/01-Introduction/module1-Introduction/start.md create mode 100755 solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-1-cryptography-and-the-solana-network.md create mode 100755 solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-2-read-data-from-the-solana-network.md create mode 100755 solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-3-write-data-to-the-solana-network.md create mode 100755 solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-4-using-custom-on-chain-programs.md create mode 100755 solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-5-interact-with-wallets.md create mode 100755 solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-6-serialize-custom-instruction-data.md create mode 100755 solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-7-deserialize-program-data.md create mode 100755 solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-8-page-order-filter-program-data.md create mode 100755 solana-development-course-zh/02-dApp-Development/module2-Client-Interaction-with-Common-Solana-Programs/2-1-Create-tokens-with-the-Token-Program.md create mode 100755 solana-development-course-zh/02-dApp-Development/module2-Client-Interaction-with-Common-Solana-Programs/2-2-Create-Solana-NFTs-With-Metaplex.md diff --git a/solana-development-course-zh/01-Introduction/module1-Introduction/start.md b/solana-development-course-zh/01-Introduction/module1-Introduction/start.md new file mode 100755 index 000000000..17d037337 --- /dev/null +++ b/solana-development-course-zh/01-Introduction/module1-Introduction/start.md @@ -0,0 +1,95 @@ +## 欢迎来到 Solana 教程! + +这里是开发者学习Web3和区块链的最佳起点! + +## 什么是Web 3? + +在传统系统中,人们通过第三方平台进行相互交互: + +* 用户账户存储在像谷歌、X(前称Twitter)和Meta(Facebook、Instagram)这样的大型平台上。这些账户可能会被公司随意移除,账户中的物品也可能永久丢失。 + +* 存储价值的账户,如支付卡、银行账户和交易账户,由信用卡公司、货币转账机构和股票交易所这样的大型平台处理。这些公司通常会从平台上的每一笔交易中抽取一部分费用(大约1%到3%)。他们经常为了组织的利益而延缓交易结算。在某些情况下,转移的物品可能根本不属于接收者,而是代表接收者持有。 + +Web3是互联网的一次演进,允许人们直接进行交易: + +* 用户拥有自己的账户,由他们的钱包代表。 + +* 价值转移可以直接在用户之间发生。 + +* 代表货币、数字艺术、活动门票、房地产或其他物品的代币,完全由用户掌管。 + +Web3的常见用途包括: + +* 在线销售商品和服务,几乎零费用且即时结算。 + +* 销售数字或实体物品,确保每件物品都是真品,且原件与副本可区分。 + +* 即时全球支付,无需耗时耗资的“货币转账”公司。 + +## 什么是Solana? + +Solana是第一个可扩展的Layer 1区块链。 + +与比特币和以太坊等老平台相比,Solana具有: + +* 显著更快的速度 - 大多数交易在一两秒内完成。 + +* 极低的费用 - 交易费用(在老网络中称为“燃气费”)通常仅为$0.000025(是的,远低于一分钱),无论转移的价值多少。 + +* 高度去中心化,拥有任何权益证明(PoS)网络中最高的中本聪系数(Nakamoto coefficient,一种去中心化评分)。 + +由于老区块链的高成本和慢速度,许多在Solana上的常见用例只能在Solana上实现。 + +## 在这个课程中,您将学到什么? + +在本课程中,您将: + +* 创建允许人们使用Web3钱包登录的Web应用程序。 +* 在人们之间转移代币(如USDC,一种代表美元的代币)。 学习将诸如Solana pay之类的工具集成到您现有的应用程序中。 +* 构建一个实时运行在Solana区块链上的电影评论应用程序。 +* 您将构建Web前端和后端(链上)程序及数据库。 +* 铸造大规模NFT系列。 + +以及更多,随着新技术加入Solana生态系统,我们将会持续更新此课程,您会在这里找到后续的课程。 + +## 在开始之前我需要什么? + +您不需要之前的区块链经验就可以跟随这个课程! + +Linux、Mac或Windows电脑。Windows机器应安装Windows Terminal和WSL。 基础的JavaScript/TypeScript编程经验。我们还会使用一些Rust,但我们会随时解释Rust。 安装了node.js 18 安装了Rust 基本的命令行使用 + +## 这个课程是如何构建的? + +课程分为三个部分: + +1. dApp开发 - 构建与流行的链上Solana程序交互的Web和移动应用程序。这些涵盖了代币转移、铸造以及为任意程序创建客户端等内容。如果您想在应用程序中添加区块链支付、NFT、区块链溯源等,这是开始的最佳轨道。 + +2. 链上程序开发 - 创建在区块链上运行的自定义应用程序。如果您想制作新的金融或会计应用程序、使用来自Solana以外的数据链上,或使用区块链存储任意数据,那么这个轨道适合您。 + +3. 网络基础设施 - 涵盖运行Solana本身,作为RPC或验证者。 模块覆盖特定主题。这些分解为个别课程。 + +每节课开始时都会列出课程目标 - 即您将在课程中学到的内容。 + +然后有一个简短的“TL;DR”,这样您可以快速浏览,了解课程内容,并决定这节课是否适合您。 + +然后每节课分为三个部分: + +1. 正文 - 包括解释性文本、示例和代码片段。您无需跟随这里显示的任何示例进行编码。目标只是简单阅读并初步接触课程主题。 + +2. 实验 - 您绝对应该跟着做的实践项目。这是您第二次接触内容,也是您第一次深入并尝试做事的机会。 + +3. 挑战 - 另一个项目,只有几个简单的提示,您应该独立完成。 + +## 我如何有效使用这个课程? + +这里的课程非常有效,但每个人的背景和能力不同,无法全部概括。 + +考虑到这一点,这里有三个建议,帮助您最大限度地利用本课程: + +1. 诚实面对自己 - 这听起来可能有点模糊,但诚实面对自己对于掌握某个主题至关重要。阅读一件东西并想“是的,我懂了”很容易,但稍后你可能会意识到你实际上并没有懂。在学习每节课时都要诚实面对自己。如果需要,请不要犹豫重复部分课程,或者在课程措辞不适合您时进行外部研究。 + +2. 完成每个实验室和挑战 - 这支持第一点。当你让自己尝试做某事时,欺骗自己有多了解某件事是相当困难的。完成每个实验室和每个挑战,以测试你的水平,并根据需要重复它们。我们为所有内容提供解决方案代码,但请确保将其作为有用的资源而不是依赖。 + +3. 做到卓越 - 这听起来老套,但不要仅仅停留在实验和挑战要求你做的事情上。请你发挥创造力!拿起项目,让它们成为你自己的。超越它们。你练习得越多,就越擅长。 + +好的,那我们开始吧! \ No newline at end of file diff --git a/solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-1-cryptography-and-the-solana-network.md b/solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-1-cryptography-and-the-solana-network.md new file mode 100755 index 000000000..228d27a70 --- /dev/null +++ b/solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-1-cryptography-and-the-solana-network.md @@ -0,0 +1,182 @@ +# 简介 + +* 密钥对是一对相匹配的公钥和私钥。 + +- 公钥用作指向Solana网络上账户的“地址”。公钥可以与任何人共享。 +- 私钥用于验证对账户的控制权。顾名思义,您应该始终保密私钥。 + +* `@solana/web3.js` 提供了用于创建全新密钥对的辅助函数,或者使用现有的私钥构建密钥对。 + +# 正文 + +### 对称加密和非对称加密 + +「密码学」字面上是指隐藏信息的研究。我们常常会遇到两种主要类型的密码学: + +#### 对称加密 + +对称加密中,加密和解密使用同一个密钥。它有几百年的历史,从古埃及人到伊丽莎白一世女王都曾使用。 + +对称加密算法有很多种,目前常见的对称加密算法有AES和Chacha20。 + +#### 非对称加密 + +非对称加密,也称为「公钥密码学」,发展于20世纪70年代。在非对称加密中,参与者拥有一对密钥(或密钥对)。每个密钥对包括一个私钥和一个公钥。 + +非对称加密与对称加密的工作方式不同,用处也不同: + +- 加密:如果用公钥加密,只有同一密钥对的私钥才能读取它。 +- 签名:如果用私钥加密,可以使用同一密钥对的公钥证明私钥持有者进行了签名。 + +* 您甚至可以使用非对称密码学来确定用于对称密码学的好密钥!这被称为密钥交换,您可以使用您的公钥和收件人的公钥来想出一个'会话'密钥。 + +对称加密算法也有很多种,目前最常见的非对称加密算法是ECC或RSA的变种。 + +非对称加密非常流行: + +- 您的银行卡内部有一个私钥,用于签署交易。 +- 您的银行可以通过匹配的公钥确认您进行了交易。 +- 网站在其证书中包含一个公钥,您的浏览器将使用此公钥加密发送到网页的数据(如个人信息、登录详情和信用卡号)。 +- 网站拥有匹配的私钥,因此网站可以读取数据。 +- 您的电子护照由发行国签署,以确保护照不被伪造。 +- 电子护照闸门可以使用您的发行国的公钥来确认这一点。 +- 您手机上的消息应用使用密钥交换来生成会话密钥。 + +总之,密码学无处不在。Solana以及其他区块链只是密码学的一种用途。 + +### Solana使用公钥作为地址。 + +![Solana wallet addresses](https://www.soldev.app/assets/wallet-addresses.svg) + +### 在Solana网络中的参与者 + +参与Solana网络的人至少拥有一个密钥对。在Solana中: + +- 公钥用作指向Solana网络上账户的“地址”。即使是人们易读的文件名 - 如`example.sol` - 也指向像`dDCQNnDmNbFVi8cQhKAgXhyhXeJ625tvwsunRyRc7c8`这样的地址。 +- 私钥用于验证对该密钥对的控制权。如果您拥有某个地址的私钥,您就控制该地址内的代币。因此,正如「私钥」这个名字所提示的,您应该始终将私钥保密。 + +### 使用@solana/web3.js创建密钥对 + +您可以通过npm模块 `@solana/web3.js` 在浏览器或node.js中使用Solana区块链。按照您通常的方式设置项目,然后使用npm安装 `@solana/web3.js`: + +``` +npm i @solana/web3.js +``` + +我们将在本课程中逐步介绍 `web3.js` 的很多内容,但您也可以查看官方的[web3.js文档](https://docs.solana.com/developing/clients/javascript-reference)。 + +要发送代币、发送NFT或在Solana上读写数据,您需要自己的密钥对。要创建一个新的密钥对,请使用 `@solana/web3.js` 中的 `Keypair.generate()` 函数: + +``` +import { Keypair } from "@solana/web3.js"; + +const keypair = Keypair.generate(); + +console.log(`公钥是: `, keypair.publicKey.toBase58()); +console.log(`私钥是: `, keypair.secretKey); +``` + +### ⚠️ 不要在源代码中包含私钥 + +由于可以从私钥重新生成密钥对,我们通常只存储私钥,并从私钥恢复密钥对。 + +此外,由于私钥对地址有控制权,我们不会在源代码中存储私钥。相反,我们: + +- 将私钥放在 `.env` 文件中 +- 将 `.env` 添加到` .gitignore` 中,以便上传代码的时候可以不上传` .env` 文件。 + +### 加载现有的密钥对 + +如果您已经有一个想要使用的密钥对,您可以从文件系统或 ` .env` 文件中加载现有的私钥来创建Keypair。在node.js中,npm包 ` @solana-developers/node-helpers` 包括一些额外的函数: + +- 要使用 `.env` 文件,请使用 `getKeypairFromEnvironment()` +- 要使用Solana CLI文件,请使用 `getKeypairFromFile()` + +``` +import "dotenv/config"; +import { getKeypairFromEnvironment } from "@solana-developers/node-helpers"; + +const keypair = getKeypairFromEnvironment("SECRET_KEY"); +``` + +您现在知道如何制作和加载密钥对了!让我们做一些练习,实践一下我们刚才所学到的。 + +# 实验 + +### 安装 + +创建一个新目录,安装TypeScript, Solana web3.js 和 esrun: + +``` +mkdir generate-keypair +cd generate-keypair +npm init -y +npm install typescript @solana/web3.js esrun @solana-developers/node-helpers +``` + +创建一个名为 `generate-keypair.ts` 的新文件: + +``` +import { Keypair } from "@solana/web3.js"; +const keypair = Keypair.generate(); +console.log(`✅ Generated keypair!`) +``` + +运行 `npx esrun generate-keypair.ts`。你应该会看到文本: + +``` +✅ Generated keypair! +``` + +每个Keypair都有一个publicKey和secretKey属性。更新文件: + +``` +import { Keypair } from "@solana/web3.js"; + +const keypair = Keypair.generate(); + +console.log(`The public key is: `, keypair.publicKey.toBase58()); +console.log(`The secret key is: `, keypair.secretKey); +console.log(`✅ Finished!`); +``` + +运行 `npx esrun generate-keypair.ts`。你应该会看到文本: + +``` +The public key is: 764CksEAZvm7C1mg2uFmpeFvifxwgjqxj2bH6Ps7La4F +The secret key is: Uint8Array(64) [ + (a long series of numbers) +] +✅ Finished! +``` + +### 从 .env 文件加载现有的密钥对 + +为了确保你的秘密密钥安全,我们推荐使用 .env 文件来注入秘密密钥: + +创建一个名为 `.env` 的新文件,内容为你之前制作的密钥: + +``` +SECRET_KEY="[(a series of numbers)]" +``` + +我们可以从环境中加载密钥对。更新 `generate-keypair.ts` 文件: + +``` +import "dotenv/config" +import { getKeypairFromEnvironment } from "@solana-developers/node-helpers"; + +const keypair = getKeypairFromEnvironment("SECRET_KEY"); + +console.log( + `✅ Finished! We've loaded our secret key securely, using an env file!` +); +``` + +运行 `npx esrun generate-keypair.ts`。你应该会看到以下结果: + +``` +✅ Finished! We've loaded our secret key securely, using an env file! +``` + +我们已经学习了关于密钥对的知识,以及如何在Solana上安全地存储秘密密钥。在下一章节,我们将使用它们! \ No newline at end of file diff --git a/solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-2-read-data-from-the-solana-network.md b/solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-2-read-data-from-the-solana-network.md new file mode 100755 index 000000000..53635d989 --- /dev/null +++ b/solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-2-read-data-from-the-solana-network.md @@ -0,0 +1,191 @@ +# 简介 + +Solana 的原生代币名称为 SOL。每个 SOL 由 10 亿 Lamports 组成。 + +账户用于存储代币、NFT、程序和数据。目前我们将重点关注存储 SOL 的账户。 + +地址指向 Solana 网络上的账户。任何人都可以读取给定地址中的数据。大多数地址也是公钥。 + +# 正文 + +### 帐户 + +Solana上存储的所有数据都存储在账户中。账户可以存储: + +- SOL +- 其他代币,如 USDC +- NFT +- 程序,比如我们在本课程中制作的电影评论程序 +- 程序数据,比如上述程序的电影评论 + +### SOL + +SOL 是 Solana 的原生代币 - SOL 用于支付交易费用、支付账户租金等。SOL 有时用符号 `◎` 表示。每个 SOL 由 10 亿 Lamports 组成。 + +就像金融应用程序通常用美分(基于美元)、便士(基于英镑)进行计算一样,Solana 应用程序通常以 Lamports 的形式转移、支付、存储和处理 SOL,只在向用户显示时才显示为完整的 SOL。 + +### 地址 + +地址用于唯一标识账户。地址通常显示为类似 `dDCQNnDmNbFVi8cQhKAgXhyhXeJ625tvwsunRyRc7c8` 的 base-58 编码字符串。 + +Solana 上的大多数地址也是公钥。如前一章所述,谁控制了地址相对应的私钥,谁就控制了账户 - 例如,拥有私钥的人可以从账户转走代币。 + +### 从 Solana 区块链读取数据 + +#### 安装 + +我们使用一个名为 `@solana/web3.js` 的 npm 包来处理大部分与 Solana 的交互。我们还会安装 `TypeScript` 和 `esrun`,以便在命令行上运行 `.ts` 文件: + +``` +npm install typescript @solana/web3.js esrun +``` + +#### 连接到网络 + +使用 `@solana/web3.js` 与 Solana 网络的每次交互都将通过一个 `Connection` 对象进行。`Connection` 对象建立与特定 Solana 网络(称为「集群」)的连接。 + +目前我们将使用 Devnet 集群而非 Mainnet。Devnet 旨在供开发者使用和测试,Devnet 代币没有实际价值,仅作为开发测试使用。 + +``` +import { Connection, clusterApiUrl } from "@solana/web3.js"; + +const connection = new Connection(clusterApiUrl("devnet")); +console.log(`✅ Connected!`) +``` + +运行此 TypeScript ,在命令行运行 `npx esrun example.ts` 你可以看到以下内容: + +``` +✅ Connected! +``` + +#### 从网络读取 + +要读取账户的余额: + +``` +import { Connection, PublicKey, clusterApiUrl } from "@solana/web3.js"; + +const connection = new Connection(clusterApiUrl("devnet")); +const address = new PublicKey('CenYq6bDRB7p73EjsPEpiYN7uveyPUTdXkDkgUduboaN'); +const balance = await connection.getBalance(address); + +console.log(`The balance of the account at ${address} is ${balance} lamports`); +console.log(`✅ Finished!`) +``` + +返回的余额以 Lamports 作为单位,如前所述。`Web3.js` 提供了一个常量 `LAMPORTS_PER_SOL` 用于将 Lamports 显示为 SOL: + +``` +import { Connection, PublicKey, clusterApiUrl, LAMPORTS_PER_SOL } from "@solana/web3.js"; + +const connection = new Connection(clusterApiUrl("devnet")); +const address = new PublicKey('CenYq6bDRB7p73EjsPEpiYN7uveyPUTdXkDkgUduboaN'); +const balance = await connection.getBalance(address); +const balanceInSol = balance / LAMPORTS_PER_SOL; + +console.log(`The balance of the account at ${address} is ${balanceInSol} SOL`); +``` + +运行 `npx esrun example.ts` 将显示类似以下内容: + +``` +The balance of the account at CenYq6bDRB7p73EjsPEpiYN7uveyPUTdXkDkgUduboaN is 0.00114144 SOL +✅ Finished! +``` + +就这样,我们从 Solana 区块链读取了数据! + +# 实验 + +让我们实践所学内容,并检查特定地址的余额。 + +### 加载密钥对 + +记住前一章节中的公钥。 + +创建一个名为 check-balance.ts 的新文件,并用你的公钥替换 ``。 + +脚本加载公钥,连接到 DevNet,并检查余额: + +``` +import { Connection, LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js"; + +const publicKey = new PublicKey(""); + +const connection = new Connection("https://api.devnet.solana.com", "confirmed"); + +const balanceInLamports = await connection.getBalance(publicKey); + +const balanceInSOL = balanceInLamports / LAMPORTS_PER_SOL; + +console.log( + `💰 Finished! The balance for the wallet at address ${publicKey} is ${balanceInSOL}!` +); +``` + +将此保存到一个文件中,并运行 npx esrun check-balance.ts。你应该看到类似以下内容: + +``` +💰 Finished! The balance for the wallet at address 31ZdXAvhRQyzLC2L97PC6Lnf2yWgHhQUKKYoUo9MLQF5 is 0! +``` + +### 获取 Devnet Sol + +在 Devnet 中,你可以获取免费的 SOL 进行开发。可以将 Devnet SOL 视为棋盘游戏货币 - 它看起来有价值,但实际上没有价值。 + +[获取一些 Devnet SOL](https://faucet.solana.com/),并使用你的密钥对的公钥作为地址。 + +你可以选择任意数量的 SOL。 + +### 检查你的余额 + +重新运行脚本。你应该看到你的余额已更新: + +``` +💰 Finished! The balance for the wallet at address 31ZdXAvhRQyzLC2L97PC6Lnf2yWgHhQUKKYoUo9MLQF5 is 0.5! +``` + +### 检查其他学生的余额 + +你可以修改脚本以检查任何钱包的余额。 + +``` +import { Connection, LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js"; + +const suppliedPublicKey = process.argv[2]; +if (!suppliedPublicKey) { + throw new Error("Provide a public key to check the balance of!"); +} + +const connection = new Connection("https://api.devnet.solana.com", "confirmed"); + +const publicKey = new PublicKey(suppliedPublicKey); + +const balanceInLamports = await connection.getBalance(publicKey); + +const balanceInSOL = balanceInLamports / LAMPORTS_PER_SOL; + +console.log( + `✅ Finished! The balance for the wallet at address ${publicKey} is ${balanceInSOL}!` +); +``` + +与同学们在聊天中交换钱包地址,并检查他们的余额。 + +``` +% npx esrun check-balance.ts (some wallet address) +✅ Finished! The balance for the wallet at address 31ZdXAvhRQyzLC2L97PC6Lnf2yWgHhQUKKYoUo9MLQF5 is 3! +``` + +并检查几个同学的余额。 + +# 挑战 + +按照以下方式修改脚本: + +* 添加处理无效钱包地址的指令。 + +* 修改脚本以连接到 mainNet,并查找一些著名的 Solana 钱包。尝试 `toly.sol`、`shaq.sol` 或 `mccann.sol`。 + +我们将在下一课中学习转移 SOL! \ No newline at end of file diff --git a/solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-3-write-data-to-the-solana-network.md b/solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-3-write-data-to-the-solana-network.md new file mode 100755 index 000000000..984e0b687 --- /dev/null +++ b/solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-3-write-data-to-the-solana-network.md @@ -0,0 +1,195 @@ +# 简要概述 + +所有链上数据的修改都是通过交易来实现的。交易主要是一组调用Solana程序的指令。 + +交易是原子性的,这意味着它们要么成功——如果所有指令都正确执行了;要么失败——就好像交易根本没有运行过一样。 + +# 正文 + +### 交易是原子性的 + +任何对链上数据的修改都是通过发送到程序的交易来实现的。 + +Solana上的交易与其他地方的交易类似:它是原子性的。原子性意味着整个交易要么全部成功,要么全部失败。 + +想象一下在线支付: + +1. 你的账户余额会被扣除 +2. 银行将资金转给商家 + +这两件事都需要发生,交易才算成功。如果其中任何一个失败,那么最好是两者都不发生,而不是发生其中一个——支付给商家而不扣除你的账户,或者扣除账户但不支付给商家,这都不是我们希望看到的。 + +原子性意味着要么交易发生——意味着所有个别步骤都成功——要么整个交易全都失败。 + +### 交易包含指令 + +Solana上交易内的步骤被称为指令。 + +每个指令包含: + +- 将要从中读取和/或写入的账户数组。这就是Solana快速处理的原因——影响不同账户的交易可以同时处理 +- 要调用的程序的公钥 +- 传递给被调用程序的数据,以字节数组的形式结构化 + +当运行交易时,将调用一个或多个Solana程序,并使用交易中包含的指令。 + +正如你所期望的,`@solana/web3.js` 提供了创建交易和指令的辅助函数。你可以使用构造函数 `new Transaction()` 创建一个新的交易。一旦创建,你就可以使用 `add()` 方法向交易中添加指令。 + +其中一个辅助函数是 `SystemProgram.transfer()`,它创建了一个让 `SystemProgram` 转移一些SOL的指令: + +``` +const transaction = new Transaction() + +const sendSolInstruction = SystemProgram.transfer({ + fromPubkey: sender, + toPubkey: recipient, + lamports: LAMPORTS_PER_SOL * amount +}) + +transaction.add(sendSolInstruction) +``` + +`SystemProgram.transfer()` 函数需要: + +- 发送者账户对应的公钥 +- 收件人账户对应的公钥 +- 以lamports为单位的要发送的SOL数量。 + +`SystemProgram.transfer()` 返回用于将SOL从发送者发送给接收者的指令。 + +在这个指令中使用的程序将是系统程序(地址为 `11111111111111111111111111111111`),数据将是要转移的SOL金额(以Lamports为单位),账户将基于发送者和接收者。 + +然后,指令可以被添加到交易中。 + +一旦所有指令都被添加,就需要将交易发送到集群并确认: + +``` +const signature = sendAndConfirmTransaction( + connection, + transaction, + [senderKeypair] +) +``` + +`sendAndConfirmTransaction()` 函数作为参数接受 + +- 一个集群连接 +- 一个交易 +- 将作为交易上签名者的密钥对数组——在这个例子中,我们只有一个签名者:发送者。 + +### 交易费用 + +交易费用是内置于Solana经济中的,作为对验证器网络在处理交易时所需的CPU和GPU资源的补偿。Solana的交易费用是确定的。 + +在交易上的签名者数组中的第一个签名者负责支付交易费用。如果这个签名者的账户中没有足够的SOL来支付交易费用,交易将被丢弃。 + +在测试时,无论是本地还是在devnet上,你可以使用Solana CLI命令 `solana airdrop 1` 来获取免费的测试SOL,用于支付交易费用。 + +### Solana Explorer + +![Screenshot of Solana Explorer set to Devnet](https://www.soldev.app/assets/solana-explorer-devnet.png) + +区块链上的所有交易都可以在[Solana Explorer](https://explorer.solana.com/)上公开查看。例如,你可以取上面示例中 `sendAndConfirmTransaction()` 返回的签名,在Solana Explorer中搜索该签名,然后查看: + +- 它发生的时间 +- 它包含在哪个区块中 +- 交易费用 +- 以及更多信息 + +![Screenshot of Solana Explorer with details about a transaction](https://www.soldev.app/assets/solana-explorer-transaction-overview.png) + +# 实验 + +我们将创建一个脚本,用于向其他学生发送SOL。 + +1. **基础框架** 我们将开始使用我们之前在“加密学入门”中创建的相同的包和 `.env` 文件。 + +创建一个名为 `transfer.ts` 的文件: + +``` +import { + Connection, + Transaction, + SystemProgram, + sendAndConfirmTransaction, + PublicKey, +} from "@solana/web3.js"; +import "dotenv/config" +import { getKeypairFromEnvironment } from "@solana-developers/node-helpers"; + +const suppliedToPubkey = process.argv[2] || null; + +if (!suppliedToPubkey) { + console.log(`Please provide a public key to send to`); + process.exit(1); +} + +const senderKeypair = getKeypairFromEnvironment("SECRET_KEY"); + +console.log(`suppliedToPubkey: ${suppliedToPubkey}`); + +const toPubkey = new PublicKey(suppliedToPubkey); + +const connection = new Connection("https://api.devnet.solana.com", "confirmed"); + +console.log( + `✅ Loaded our own keypair, the destination public key, and connected to Solana` +); +``` + +运行脚本以确保它连接、加载了你的密钥对,并加载了: + +``` +npx esrun transfer.ts (目标钱包地址) +``` + +### 创建交易并运行 + +添加以下内容以完成交易并发送: + +``` +console.log( + `✅ Loaded our own keypair, the destination public key, and connected to Solana` +); + +const transaction = new Transaction(); + +const LAMPORTS_TO_SEND = 5000; + +const sendSolInstruction = SystemProgram.transfer({ + fromPubkey: senderKeypair.publicKey, + toPubkey, + lamports: LAMPORTS_TO_SEND, +}); + +transaction.add(sendSolInstruction); + +const signature = await sendAndConfirmTransaction(connection, transaction, [ + senderKeypair, +]); + +console.log( + `💸 Finished! Sent ${LAMPORTS_TO_SEND} to the address ${toPubkey}. ` +); +console.log(`Transaction signature is ${signature}!`); +``` + +### 尝试 + + 向班上其他学生发送SOL。 + +``` +npx esrun transfer.ts (目标钱包地址) +``` + +# 挑战 + +回答以下问题: + +* 转账花费了多少Solana?换算成美元是多少钱? + +* 你能在 [https://explorer.solana.com](https://explorer.solana.com/) 找到你的交易记录吗?注意,我们正在使用的是 devnet 网络。 + +* 转账需要多长时间? + +* 你认为「confirmed」是什么意思? \ No newline at end of file diff --git a/solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-4-using-custom-on-chain-programs.md b/solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-4-using-custom-on-chain-programs.md new file mode 100755 index 000000000..dda28fa00 --- /dev/null +++ b/solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-4-using-custom-on-chain-programs.md @@ -0,0 +1,252 @@ +# 太长不看版 + +Solana有多个链上程序供您使用。 + +使用这些程序的指令需要有程序确定的自定义格式数据。 + +# 概览 + +指令 在前几章中,我们使用了 `SystemProgram.transfer()` 函数来创建指令并发送Solana。 + +但是,当处理非原生程序时,您需要更具体地创建与相应程序匹配的指令。 + +使用 `@solana/web3.js` ,您可以使用 `TransactionInstruction` 构造函数创建非原生指令。这个构造函数接受一个 `TransactionInstructionCtorFields` 数据类型的单一参数。 + +``` +export type TransactionInstructionCtorFields = { + keys: Array; + programId: PublicKey; + data?: Buffer; +}; +``` + +根据上述定义,传递给TransactionInstruction构造函数的对象需要: + +一个类型为AccountMeta的键数组 被调用程序的公钥 一个可选的Buffer,包含传递给程序的数据。 我们现在将忽略数据字段,并将在未来的课程中重新讨论。 + +programId字段相当直接:它是与程序关联的公钥。在调用程序之前,您需要提前知道这个,就像您需要提前知道要向其发送SOL的人的公钥一样。 + +keys数组需要更多的解释。数组中的每个对象代表一个在交易执行期间将被读取或写入的账户。这意味着您需要了解所调用程序的行为,并确保在数组中提供所有必要的账户。 + +keys数组中的每个对象都必须包括以下内容: + +pubkey - 账户的公钥 isSigner - 一个布尔值,表示账户是否为交易的签名者 isWritable - 一个布尔值,表示在交易执行期间账户是否会被写入 将这一切综合起来,我们可能最终会得到类似以下的内容: + +``` +async function callProgram( + connection: web3.Connection, + payer: web3.Keypair, + programId: web3.PublicKey, + programDataAccount: web3.PublicKey, +) { + const instruction = new web3.TransactionInstruction({ + keys: [ + { + pubkey: programDataAccount, + isSigner: false, + isWritable: true, + }, + ], + programId, + }); + + const transaction = new web3.Transaction().add(instruction) + + const signature = await web3.sendAndConfirmTransaction( + connection, + transaction, + [payer], + ); + + console.log(`✅ Success! Transaction signature is: ${signature}`); +} +``` + +**交易费用** + +交易费用是Solana经济体系的一部分,作为对验证器网络在处理交易时所需的CPU和GPU资源的补偿。Solana的交易费用是确定的。 + +在交易的签名者数组中,第一个包含的签名者负责支付交易费用。如果这个签名者的账户中没有足够的SOL来支付交易费用,交易将被丢弃。 + +在测试时,无论是本地还是在devnet上,您可以使用Solana CLI命令solana airdrop 1来获取免费的测试SOL以支付交易费用。 + +**Solana浏览器** + +![Screenshot of Solana Explorer set to Devnet](https://www.soldev.app/assets/solana-explorer-devnet.png) + +区块链上的所有交易都可以在Solana浏览器上公开查看。例如,您可以拿上面例子中sendAndConfirmTransaction()返回的签名,在Solana浏览器中搜索该签名,然后查看: + +- 它发生的时间 +- 它被包含在哪个区块中 +- 交易费用 +- 以及更多! + +![Screenshot of Solana Explorer with details about a transaction](https://www.soldev.app/assets/solana-explorer-transaction-overview.png) + +# 实验室 - 为ping计数器程序编写交易 + +我们将创建一个脚本来ping一个链上程序,该程序每次被ping时都会增加一个计数器。这个程序存在于Solana Devnet上,地址为 `ChT1B39WKLS8qUrkLvFDXMhEJ4F1XZzwUNHUt4AU9aVa`。该程序将其数据存储在 `Ah9K7dQ8EHaZqcAsgBW8w37yN2eAy3koFmUn4x3CJtod` 地址的特定账户中。 + +![Solana stores programs and data in seperate accounts](https://www.soldev.app/assets/pdas-note-taking-program.svg) + +### 1.基础框架 + +我们将从使用我们在“入门到密码学”中制作的相同包和.env文件开始: + +``` +import { Keypair } from "@solana/web3.js"; +import "dotenv/config" +import base58 from "bs58"; +import { getKeypairFromEnvironment } from "@solana-developers/node-helpers" + +const payer = getKeypairFromEnvironment('SECRET_KEY') +const connection = new web3.Connection(web3.clusterApiUrl('devnet')) +``` + +### 2.Ping程序 + +现在我们已经加载了我们的密钥对,我们需要连接到Solana的Devnet。让我们创建一个连接: + +``` +const connection = new web3.Connection(web3.clusterApiUrl('devnet')) +``` + +现在创建一个名为sendPingTransaction()的异步函数,它有两个参数,分别需要一个连接和付款者的密钥对作为参数: + +``` +async function sendPingTransaction(connection: web3.Connection, payer: web3.Keypair) { } +``` + +在这个函数中,我们需要: + +- 创建一个交易 +- 创建一个指令 +- 将指令添加到交易中 +- 发送交易。 + +记住,这里最具挑战性的部分是在指令中包含正确的信息。我们知道我们正在调用的程序的地址。我们也知道该程序将数据写入到另一个我们也有地址的单独账户中。让我们在index.ts文件的顶部添加两个作为常量的字符串版本: + +``` +const PING_PROGRAM_ADDRESS = new web3.PublicKey('ChT1B39WKLS8qUrkLvFDXMhEJ4F1XZzwUNHUt4AU9aVa') +const PING_PROGRAM_DATA_ADDRESS = new web3.PublicKey('Ah9K7dQ8EHaZqcAsgBW8w37yN2eAy3koFmUn4x3CJtod') +``` + +现在,在sendPingTransaction()函数中,让我们创建一个新的交易,然后为程序账户初始化一个PublicKey,以及另一个用于数据账户的PublicKey。 + +``` +const transaction = new web3.Transaction() +const programId = new web3.PublicKey(PING_PROGRAM_ADDRESS) +const pingProgramDataId = new web3.PublicKey(PING_PROGRAM_DATA_ADDRESS) +``` + +接下来,让我们创建指令。记住,指令需要包括Ping程序的公钥,还需要包括一个包含所有将被读取或写入的账户的数组。在这个示例程序中,只需要上面提到的数据账户。 + +``` +const transaction = new web3.Transaction() + +const programId = new web3.PublicKey(PING_PROGRAM_ADDRESS) +const pingProgramDataId = new web3.PublicKey(PING_PROGRAM_DATA_ADDRESS) + +const instruction = new web3.TransactionInstruction({ + keys: [ + { + pubkey: pingProgramDataId, + isSigner: false, + isWritable: true + }, + ], + programId +}) +``` + +接下来,让我们将指令添加到我们创建的交易中。然后,通过传入连接、交易和付款者,调用sendAndConfirmTransaction()。最后,让我们记录该函数调用的结果,这样我们就可以在Solana Explorer上查找它。 + +``` +const transaction = new web3.Transaction() + +const programId = new web3.PublicKey(PING_PROGRAM_ADDRESS) +const pingProgramDataId = new web3.PublicKey(PING_PROGRAM_DATA_ADDRESS) + +const instruction = new web3.TransactionInstruction({ + keys: [ + { + pubkey: pingProgramDataId, + isSigner: false, + isWritable: true + }, + ], + programId +}) + +transaction.add(instruction) + +const signature = await web3.sendAndConfirmTransaction( + connection, + transaction, + [payer] +) + +console.log(`✅ Transaction completed! Signature is ${signature}`) +``` + +### 3.Airdrop + +现在使用 `npx esrun send-ping-instruction.ts` 运行代码,看看是否有效。您可能会在控制台中遇到以下错误: + +``` +> Transaction simulation failed: Attempt to debit an account but found no record of a prior credit. +``` + +如果您遇到这个错误,那是因为您的密钥对是全新的,没有SOL来支付交易费用。让我们通过在调用 `sendPingTransaction()` 之前添加以下行来解决这个问题: + +``` +await connection.requestAirdrop(payer.publicKey, web3.LAMPORTS_PER_SOL*1) +``` + +这将在您的账户中存入1 SOL,您可以用它来进行测试。这在Mainnet上是不行的,因为它实际上有价值。但是在本地和Devnet上测试时非常方便。 + +### 4. 查看Solana浏览器 + +现在再次运行代码。可能需要一两分钟,但现在代码应该可以工作了,您应该会看到控制台打印出一个长字符串,如下所示: + +``` +✅ Transaction completed! Signature is 55S47uwMJprFMLhRSewkoUuzUs5V6BpNfRx21MpngRUQG3AswCzCSxvQmS3WEPWDJM7bhHm3bYBrqRshj672cUSG +``` + +复制交易签名。打开浏览器,前往 https://explorer.solana.com/?cluster=devnet(URL末尾的查询参数将确保您在Devnet而不是Mainnet上探索交易)。将签名粘贴到Solana的Devnet浏览器顶部的搜索栏中,然后按回车。您应该会看到有关交易的所有细节。如果您滚动到底部,那么您将看到程序日志,显示程序被ping的次数,包括您的ping。 + +![Screenshot of Solana Explorer with logs from calling the Ping program](https://www.soldev.app/assets/solana-explorer-ping-result.png) + +在浏览器中四处浏览,看看您正在看的内容: + +账户输入将包括: + +- 您的付款者地址 - 为交易被扣除5000 lamports +- ping程序的程序地址 +- ping程序的数据地址 + +指令部分将包含一个没有数据的单一指令 - ping程序是一个非常简单的程序,所以它不需要任何数据。 + +程序指令日志显示了来自ping程序的日志。 + +如果您希望将来更容易地在Solana浏览器上查看交易,只需将 `sendPingTransaction()` 中的 `console.log` 更改为以下内容: + +``` +console.log(`You can view your transaction on the Solana Explorer at:\nhttps://explorer.solana.com/tx/${signature}?cluster=devnet`) +``` + +就这样,您正在Solana网络上调用程序并将数据写入链上! + +### 接下来 + +在接下来的几节课中,您将学习如何 + +- 从浏览器而不是从运行脚本中安全地发送交易 +- 向您的指令中添加自定义数据 +- 从链上反序列化数据 + +# 挑战 + +继续创建一个从头开始的脚本,使您能够在Devnet上将SOL从一个账户转移到另一个账户。确保打印出交易签名,这样您就可以在Solana Explorer上查看它。 + +如果您遇到困难,可以参考[解决方案代码](https://github.com/Unboxed-Software/solana-ping-client)。 \ No newline at end of file diff --git a/solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-5-interact-with-wallets.md b/solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-5-interact-with-wallets.md new file mode 100755 index 000000000..9d2f79386 --- /dev/null +++ b/solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-5-interact-with-wallets.md @@ -0,0 +1,487 @@ +# 简介 + +钱包用于存储您的秘密密钥,并处理安全的交易签名。硬件钱包将您的秘密密钥存储在独立设备上。软件钱包使用您的计算机进行安全存储,并且通常是浏览器扩展程序,方便连接到网站。Solana的Wallet-Adapter库简化了对钱包浏览器扩展的支持,允许您构建可以请求用户钱包地址并为他们提出签名交易的网站。 + +# 正文 + +### 钱包 + +在前两课中,我们讨论了密钥对。密钥对用于定位账户和签署交易。虽然密钥对的公钥是完全安全共享的,但秘密密钥应始终保持在安全的地方。如果用户的秘密密钥暴露,则恶意行为者可以清空其账户中的所有资产,并以该用户的身份执行交易。 + +“钱包”指的是存储秘密密钥以保持其安全的任何东西。这些安全存储选项通常可以描述为“硬件”或“软件”钱包。硬件钱包是与您的计算机分开的存储设备。软件钱包是您可以在现有设备上安装的应用程序。 + +软件钱包通常以浏览器扩展的形式出现。这使得网站可以轻松与钱包互动。此类互动通常限于: + +- 查看钱包的公钥(地址) +- 提交用户批准的交易 +- 将批准的交易发送到网络 一旦提交了交易,最终用户可以“确认”交易并用他们的“签名”发送到网络。 + +签署交易需要使用您的秘密密钥。通过让网站提交交易到您的钱包,并让钱包处理签名,您确保永远不会将您的秘密密钥暴露给网站。相反,您只与钱包应用程序共享秘密密钥。 + +除非您自己正在创建一个钱包应用程序,否则您的代码永远不需要请求用户的秘密密钥。相反,您可以要求用户使用声誉良好的钱包连接到您的网站。 + +### Phantom Wallet + +在Solana生态系统中最广泛使用的软件钱包之一是Phantom。Phantom支持几个最受欢迎的浏览器,并有一个移动应用程序,用于在外出时连接。您可能希望您的去中心化应用支持多个钱包,但本课程将专注于Phantom。 + +### Solana的Wallet-Adapter + +Solana的Wallet-Adapter是一套您可以用来简化支持钱包浏览器扩展的过程的库。 + +Solana的Wallet-Adapter包含多个模块化包。核心功能在@solana/wallet-adapter-base和@solana/wallet-adapter-react中找到。 + +还有一些包提供了常用UI框架的组件。在本课程和整个课程中,我们将使用@solana/wallet-adapter-react-ui中的组件。 + +最后,还有一些适配特定钱包的包,包括Phantom。您可以使用@solana/wallet-adapter-wallets来包含所有支持的钱包,或者您可以选择像@solana/wallet-adapter-phantom这样的特定钱包包。 + +#### 安装Wallet-Adapter库 + +当向现有的react应用添加钱包支持时,您首先需要安装适当的包。您将需要@solana/wallet-adapter-base、@solana/wallet-adapter-react。如果您计划使用提供的react组件,您还需要添加@solana/wallet-adapter-react-ui。 + +所有支持钱包标准的钱包都默认受支持,几乎所有当前的Solana钱包都支持钱包标准。然而,如果您希望添加对不支持标准的任何钱包的支持,请添加它们的包。 + +``` +npm install @solana/wallet-adapter-base \ + @solana/wallet-adapter-react \ + @solana/wallet-adapter-react-ui +``` + +#### 连接到钱包 + +`@solana/wallet-adapter-react`允许我们通过钩子(hooks)和上下文提供者(context providers)持久化和访问钱包连接状态,主要包括: + +- `useWallet` +- `WalletProvider` +- `useConnection` +- `ConnectionProvider` + +为了确保这些功能正常工作,任何使用`useWallet`和`useConnection`的地方都应该被`WalletProvider`和`ConnectionProvider`包裹。确保这一点的最佳方式之一是将您的整个应用程序包裹在`ConnectionProvider`和`WalletProvider`中: + +``` +import { NextPage } from "next"; +import { FC, ReactNode } from "react"; +import { + ConnectionProvider, + WalletProvider, +} from "@solana/wallet-adapter-react"; +import { PhantomWalletAdapter } from "@solana/wallet-adapter-phantom"; +import * as web3 from "@solana/web3.js"; + +export const Home: NextPage = (props) => { + const endpoint = web3.clusterApiUrl("devnet"); + const wallet = new PhantomWalletAdapter(); + + return ( + + +

Put the rest of your app here

+
+
+ ); +}; +``` + +请注意,`ConnectionProvider`需要一个`endpoint`属性,而`WalletProvider`需要一个`wallets`属性。我们继续使用Devnet集群的`endpoint`,现在我们只使用`PhantomWalletAdapter`作为钱包。 + +此时,您可以使用`wallet.connect()`来连接,这将指示钱包提示用户授权查看他们的公钥并请求交易批准。 + +![Screenshot of wallet connection prompt](https://www.soldev.app/assets/wallet-connect-prompt.png) + +虽然您可以在`useEffect`钩子中执行此操作,但通常您会想提供更复杂的功能。例如,您可能希望用户能够从支持的钱包列表中选择,或在他们已经连接后断开连接。 + +### @solana/wallet-adapter-react-ui + +您可以为此创建自定义组件,或者利用`@solana/wallet-adapter-react-ui`提供的组件。提供广泛选项的最简单方式是使用`WalletModalProvider`和`WalletMultiButton`: + +``` +import { NextPage } from "next"; +import { FC, ReactNode } from "react"; +import { + ConnectionProvider, + WalletProvider, +} from "@solana/wallet-adapter-react"; +import { + WalletModalProvider, + WalletMultiButton, +} from "@solana/wallet-adapter-react-ui"; +import { PhantomWalletAdapter } from "@solana/wallet-adapter-phantom"; +import * as web3 from "@solana/web3.js"; + +const Home: NextPage = (props) => { + const endpoint = web3.clusterApiUrl("devnet"); + const wallet = new PhantomWalletAdapter(); + + return ( + + + + +

Put the rest of your app here

+
+
+
+ ); +}; + +export default Home; +``` + +`WalletModalProvider`为用户提供了一个模态屏幕,用于选择他们想使用的钱包。`WalletMultiButton`的行为会根据连接状态发生变化: + +![Screenshot of multi button select wallet option](https://www.soldev.app/assets/multi-button-select-wallet.png) + +![Screenshot of connect wallet modal](https://www.soldev.app/assets/connect-wallet-modal.png) + +![Screenshot of multi button connect options](https://www.soldev.app/assets/multi-button-connect.png) + +![Screenshot of multi button connected state](https://www.soldev.app/assets/multi-button-connected.png) + +如果您需要更具体的功能,也可以使用更细粒度的组件: + +- `WalletConnectButton` +- `WalletModal` +- `WalletModalButton` +- `WalletDisconnectButton` +- `WalletIcon` + +### 访问账户信息 + +一旦您的网站连接到一个钱包,`useConnection`将检索一个`Connection`对象,而`useWallet`将获取`WalletContextState`。`WalletContextState`有一个`publicKey`属性,当未连接到钱包时为null,当钱包连接时则有用户账户的公钥。拥有公钥和连接后,您可以获取账户信息等。 + +``` +import { useConnection, useWallet } from "@solana/wallet-adapter-react"; +import { LAMPORTS_PER_SOL } from "@solana/web3.js"; +import { FC, useEffect, useState } from "react"; + +export const BalanceDisplay: FC = () => { + const [balance, setBalance] = useState(0); + const { connection } = useConnection(); + const { publicKey } = useWallet(); + + useEffect(() => { + if (!connection || !publicKey) { + return; + } + + connection.onAccountChange( + publicKey, + (updatedAccountInfo) => { + setBalance(updatedAccountInfo.lamports / LAMPORTS_PER_SOL); + }, + "confirmed", + ); + + connection.getAccountInfo(publicKey).then((info) => { + setBalance(info.lamports); + }); + }, [connection, publicKey]); + + return ( +
+

{publicKey ? `Balance: ${balance / LAMPORTS_PER_SOL} SOL` : ""}

+
+ ); +}; +``` + +注意调用`connection.onAccountChange()`,它在网络确认交易后更新显示的账户余额。 + +### 发送交易 + +`WalletContextState`还提供了一个`sendTransaction`函数,您可以用它来提交交易以供审批。 + +``` +const { publicKey, sendTransaction } = useWallet(); +const { connection } = useConnection(); + +const sendSol = (event) => { + event.preventDefault(); + + const transaction = new web3.Transaction(); + const recipientPubKey = new web3.PublicKey(event.target.recipient.value); + + const sendSolInstruction = web3.SystemProgram.transfer({ + fromPubkey: publicKey, + toPubkey: recipientPubKey, + lamports: LAMPORTS_PER_SOL * 0.1, + }); + + transaction.add(sendSolInstruction); + sendTransaction(transaction, connection).then((sig) => { + console.log(sig); + }); +}; +``` + +当调用此函数时,已连接的钱包将显示交易以供用户批准。如果获得批准,那么交易将被发送。 + +![Screenshot of wallet transaction approval prompt](https://www.soldev.app/assets/wallet-transaction-approval-prompt.png) + + + +# 实验 + +让我们从上一课的Ping程序开始,并构建一个前端,让用户批准一个向该程序发送ping的交易。作为提醒,程序的公钥是 `ChT1B39WKLS8qUrkLvFDXMhEJ4F1XZzwUNHUt4AU9aVa`,数据账户的公钥是 `Ah9K7dQ8EHaZqcAsgBW8w37yN2eAy3koFmUn4x3CJtod`。 + +![Screenshot of Solana Ping App](https://www.soldev.app/assets/solana-ping-app.png) + +### 1.下载Phantom浏览器扩展并将其设置为Devnet + +如果您还没有,下载Phantom浏览器扩展。在撰写本文时,它支持Chrome、Brave、Firefox和Edge浏览器,因此您还需要安装其中一个浏览器。按照Phantom的指示创建一个新账户和一个新钱包。 + +一旦您有了一个钱包,在Phantom UI的右下角点击设置齿轮。向下滚动并点击“更改网络”这一行项目,然后选择“Devnet”。这确保Phantom将连接到我们在这个实验室中将要使用的同一个网络。 + +### 2.下载起始代码 + +下载这个项目的起始代码。这个项目是一个简单的Next.js应用程序。除了AppBar组件外,它几乎是空的。我们将在本实验室中构建其余部分。 + +您可以在控制台中使用命令`npm run dev`查看其当前状态。 + +### 3.用上下文提供者包裹应用 + +首先,我们将创建一个新组件来包含我们将使用的各种Wallet-Adapter提供者。在components文件夹内创建一个名为 `WalletContextProvider.tsx` 的新文件。 + +让我们从一个功能组件的样板开始: + +``` +import { FC, ReactNode } from "react"; + +const WalletContextProvider: FC<{ children: ReactNode }> = ({ children }) => { + return ( + + )); +}; + +export default WalletContextProvider; +``` + +为了正确连接到用户的钱包,我们需要一个`ConnectionProvider`、`WalletProvider`和`WalletModalProvider`。首先从`@solana/wallet-adapter-react`和`@solana/wallet-adapter-react-ui`中导入这些组件。然后将它们添加到`WalletContextProvider`组件中。请注意,`ConnectionProvider`需要一个`endpoint`参数,而`WalletProvider`需要一个钱包数组。现在,分别使用一个空字符串和一个空数组即可。 + +``` +import { FC, ReactNode } from "react"; +import { + ConnectionProvider, + WalletProvider, +} from "@solana/wallet-adapter-react"; +import { WalletModalProvider } from "@solana/wallet-adapter-react-ui"; + +const WalletContextProvider: FC<{ children: ReactNode }> = ({ children }) => { + return ( + + + {children} + + + ); +}; + +export default WalletContextProvider; +``` + +我们还需要的最后两件事是`ConnectionProvider`的实际端点和`WalletProvider`的支持钱包。 + +对于端点,我们将使用与之前相同的`@solana/web3.js`库中的`clusterApiUrl`函数,因此您需要导入它。对于钱包数组,您还需要导入`@solana/wallet-adapter-wallets`库。 + +在导入这些库之后,创建一个使用`clusterApiUrl`函数获取Devnet的url的常量`endpoint`。然后创建一个常量`wallets`,并将其设置为包含新构造的`PhantomWalletAdapter`的数组。最后,分别替换`ConnectionProvider`和`WalletProvider`中的空字符串和空数组。 + +要完成此组件,请在导入下面添加`require('@solana/wallet-adapter-react-ui/styles.css')`,以确保`Wallet Adapter`库组件的正确样式和行为。 + +``` +import { FC, ReactNode } from "react"; +import { + ConnectionProvider, + WalletProvider, +} from "@solana/wallet-adapter-react"; +import { WalletModalProvider } from "@solana/wallet-adapter-react-ui"; +import * as web3 from "@solana/web3.js"; +import * as walletAdapterWallets from "@solana/wallet-adapter-wallets"; +require("@solana/wallet-adapter-react-ui/styles.css"); + +const WalletContextProvider: FC<{ children: ReactNode }> = ({ children }) => { + const endpoint = web3.clusterApiUrl("devnet"); + const wallets = [new walletAdapterWallets.PhantomWalletAdapter()]; + + return ( + + + {children} + + + ); +}; + +export default WalletContextProvider; +``` + +### 4. 添加钱包多按钮 + +接下来,让我们设置“连接”按钮。当前的按钮只是一个占位符,因为我们不是使用标准按钮或创建自定义组件,而是将使用`Wallet-Adapter`的“多按钮”。这个按钮与我们在`WalletContextProvider`中设置的提供者进行交互,并允许用户选择钱包、连接到钱包以及从钱包断开连接。如果您需要更多自定义功能,可以创建自定义组件来处理这个。 + +在我们添加“多按钮”之前,我们需要在`index.tsx`中导入`WalletContextProvider`并在``标签的关闭标签后添加它: + +``` +import { NextPage } from "next"; +import styles from "../styles/Home.module.css"; +import WalletContextProvider from "../components/WalletContextProvider"; +import { AppBar } from "../components/AppBar"; +import Head from "next/head"; +import { PingButton } from "../components/PingButton"; + +const Home: NextPage = (props) => { + return ( +
+ + Wallet-Adapter Example + + + + +
+ +
+
+
+ ); +}; + +export default Home; +``` + +如果您运行应用程序,一切看起来应该还是一样的,因为当前右上角的按钮仍然只是一个占位符。为了解决这个问题,请打开`AppBar.tsx`并用``替换``。您需要从`@solana/wallet-adapter-react-ui`导入`WalletMultiButton`。 + +``` +import { FC } from "react"; +import styles from "../styles/Home.module.css"; +import Image from "next/image"; +import { WalletMultiButton } from "@solana/wallet-adapter-react-ui"; + +export const AppBar: FC = () => { + return ( +
+ + Wallet-Adapter Example + +
+ ); +}; +``` + +此时,您应该能够运行应用程序并与屏幕右上角的多按钮互动。它现在应该显示为“选择钱包”。如果您安装了Phantom扩展并已登录,您应该能够使用这个新按钮将您的Phantom钱包连接到该网站。 + +### 5. 创建按钮以ping程序 + +既然我们的应用程序可以连接到Phantom钱包,让我们让“Ping!”按钮实际做些事情。 + +首先打开`PingButton.tsx`文件。我们将替换`onClick`内的`console.log`,用创建交易并提交到Phantom扩展以供最终用户批准的代码。 + +首先,我们需要一个连接、钱包的公钥和`Wallet-Adapter`的`sendTransaction`函数。要获得这些,我们需要从`@solana/wallet-adapter-react`导入`useConnection`和`useWallet`。在这里,我们还将导入`@solana/web3.js`,因为我们需要它来创建我们的交易。 + +``` +import { useConnection, useWallet } from '@solana/wallet-adapter-react' +import * as web3 from '@solana/web3.js' +import { FC, useState } from 'react' +import styles from '../styles/PingButton.module.css' + +export const PingButton: FC = () => { + + const onClick = () => { + console.log('Ping!') + } + + return ( +
+ +
+ ) +} +``` + +现在使用`useConnection`钩子创建一个`connection`常量,并使用`useWallet`钩子创建`publicKey`和`sendTransaction`常量。 + +``` +import { useConnection, useWallet } from "@solana/wallet-adapter-react"; +import * as web3 from "@solana/web3.js"; +import { FC, useState } from "react"; +import styles from "../styles/PingButton.module.css"; + +export const PingButton: FC = () => { + const { connection } = useConnection(); + const { publicKey, sendTransaction } = useWallet(); + + const onClick = () => { + console.log("Ping!"); + }; + + return ( +
+ +
+ ); +}; +``` + +有了这些,我们可以填写`onClick`的主体。 + +首先,检查`connection`和`publicKey`是否存在(如果其中任何一个不存在,则意味着用户的钱包尚未连接)。 + +接下来,构造两个`PublicKey`实例,一个用于程序ID `ChT1B39WKLS8qUrkLvFDXMhEJ4F1XZzwUNHUt4AU9aVa`,另一个用于数据账户`Ah9K7dQ8EHaZqcAsgBW8w37yN2eAy3koFmUn4x3CJtod`。 + +接下来,构造一个`Transaction`,然后是一个包含数据账户作为可写密钥的新`TransactionInstruction`。 + +接下来,将指令添加到交易中。 + +最后,调用 `sendTransaction`。 + +``` +const onClick = () => { + if (!connection || !publicKey) { + return; + } + + const programId = new web3.PublicKey(PROGRAM_ID); + const programDataAccount = new web3.PublicKey(DATA_ACCOUNT_PUBKEY); + const transaction = new web3.Transaction(); + + const instruction = new web3.TransactionInstruction({ + keys: [ + { + pubkey: programDataAccount, + isSigner: false, + isWritable: true, + }, + ], + programId, + }); + + transaction.add(instruction); + sendTransaction(transaction, connection).then((sig) => { + console.log(sig); + }); +}; +``` + +就这样!如果您刷新页面,连接您的钱包,并点击ping按钮,Phantom应该会弹出一个窗口,用于确认交易。 + +### 6. 对边缘进行一些润色 + +您可以做很多事情来改善这里的用户体验。例如,您可以更改UI,只在连接了钱包时显示Ping按钮,否则显示其他提示。用户确认交易后,您可以链接到Solana Explorer上的交易,以便他们轻松查看交易详情。您越多地尝试,就越熟悉它,所以发挥创意! + +您还可以下载本实验的[完整源代码](https://github.com/Unboxed-Software/solana-ping-frontend),以了解所有这些的上下文。 + +# 挑战 + +现在轮到您独立构建一些东西了。 + +创建一个应用程序,允许用户连接他们的Phantom钱包并向另一个账户发送SOL。 + +![Screenshot of Send SOL App](https://www.soldev.app/assets/solana-send-sol-app.png) + +* 您可以从头开始构建,也可以下载[起始代码](https://github.com/Unboxed-Software/solana-send-sol-frontend/tree/starter)。 + +* 用适当的上下文提供者包裹起始应用程序。 + +* 在表单组件中,设置交易并将其发送到用户的钱包以供批准。 + +* 对用户体验发挥创意。添加一个链接,让用户在Solana Explorer上查看交易,或者添加一些您觉得很酷的其他内容! + +如果您真的感到困惑,随时可以查看[解决方案代码](https://github.com/Unboxed-Software/solana-send-sol-frontend/tree/main)。 \ No newline at end of file diff --git a/solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-6-serialize-custom-instruction-data.md b/solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-6-serialize-custom-instruction-data.md new file mode 100755 index 000000000..ee71702eb --- /dev/null +++ b/solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-6-serialize-custom-instruction-data.md @@ -0,0 +1,227 @@ +# 简介 + +交易由一系列指令组成,一个交易可以包含任意数量的指令,每个指令针对其自己的程序。当提交交易时,Solana运行时将按顺序并且原子性地处理其指令,这意味着如果任何指令由于任何原因失败,整个交易将无法处理。 每个指令由3个部分组成:目标程序的ID、涉及的所有账户的数组和指令数据的字节缓冲区。 每个交易包含:打算读取或写入的所有账户的数组、一个或多个指令、一个最近的区块哈希和一个或多个签名。 为了从客户端传递指令数据,必须将其序列化成字节缓冲区。为了促进序列化过程,我们将使用Borsh。 交易可能因为任何原因而无法由区块链处理,我们将在这里讨论一些最常见的原因。 + +# 正文 + +### 交易 + +交易是我们向区块链发送信息以便处理的方式。到目前为止,我们已经学会了如何创建具有有限功能的非常基本的交易。但是,交易和它们发送到的程序可以设计得更加灵活,处理的复杂性远超我们到目前为止所处理的。 + +### 交易内容 + +每个交易包含: + +* 一个包括其打算读取或写入的每个账户的数组 +* 一个或多个指令 +* 一个最近的区块哈希 +* 一个或多个签名 + +`@solana/web3.js` 简化了这个过程,所以你真正需要关注的只是添加指令和签名。这个库根据这些信息构建账户数组,并处理包含最近区块哈希的逻辑。 + +### 指令 + +每个指令包含: + +目标程序的程序ID(公钥) 列出在执行期间将被读取或写入的每个账户的数组 指令数据的字节缓冲区 通过其公钥确定程序可以确保指令由正确的程序执行。 + +包含一个将被读取或写入的每个账户的数组,允许网络执行许多优化,从而允许高交易负载和更快的执行。 + +字节缓冲区让你可以向程序传递外部数据。 + +你可以在单个交易中包含多个指令。Solana运行时将按顺序并且原子性地处理这些指令。换句话说,如果每个指令都成功,那么整个交易将会成功,但如果单个指令失败,那么整个交易将立即失败,且没有副作用。 + +账户数组不仅仅是账户公钥的数组。数组中的每个对象都包括账户的公钥、它是否是交易上的签名者、以及它是否可写。在指令执行期间包含账户是否可写,允许运行时促进智能合约的并行处理。因为你必须定义哪些账户是只读的,哪些你将写入,运行时可以确定哪些交易是非重叠的或只读的,并允许它们并发执行。要了解更多关于Solana运行时的信息,请查看这篇博客文章。 + +**指令数据** + +能够向指令添加任意数据确保了程序可以为广泛的用例动态灵活地使用,就像HTTP请求的主体让你构建动态灵活的REST API一样。 + +就像HTTP请求的主体结构取决于你打算调用的端点一样,用作指令数据的字节缓冲区的结构完全取决于接收程序。如果你正在独立构建一个全栈dApp,那么你需要将你在构建程序时使用的相同结构复制到客户端代码中。如果你正在与处理程序开发的其他开发人员合作,你可以协调以确保匹配的缓冲区布局。 + +让我们考虑一个具体的例子。想象一下在一个Web3游戏上工作,负责编写与玩家库存程序交互的客户端代码。这个程序被设计为允许客户端: + +根据玩家的游戏结果添加库存 将库存从一个玩家转移到另一个玩家 装备玩家选择的库存物品 这个程序将被设计成这样,每个功能都封装在自己的函数中。 + +然而,每个程序只有一个入口点。你会通过指令数据指导程序运行其中哪个函数。 + +你还会在指令数据中包含函数执行所需的任何信息,例如库存物品的ID、要转移库存的玩家等。 + +这些数据的确切结构取决于程序的编写方式,但通常情况下,指令数据中的第一个字段是一个数字,程序可以将其映射到一个函数,之后的附加字段充当函数参数。 + +**序列化** + +除了知道要在指令数据缓冲区中包含哪些信息外,您还需要正确地序列化它。在Solana中最常用的序列化器是Borsh。根据其网站: + +Borsh代表二进制对象表示哈希序列化器。它旨在用于安全关键的项目,因为它优先考虑一致性、安全性、速度,并且有严格的规范。 + +Borsh维护了一个JS库,该库处理将常见类型序列化为缓冲区。还有一些基于borsh构建的其他包试图使这个过程更加简单。我们将使用可以通过npm安装的@coral-xyz/borsh库。 + +基于之前的游戏库存示例,让我们来看一个假设的场景,我们指示程序为玩家装备一个给定的物品。假设程序被设计为接受代表具有以下属性的结构的缓冲区: + +- `variant` 作为一个无符号的8位整数,指示程序执行哪个指令或功能。 +- `playerId` 作为一个无符号的16位整数,代表将装备给定物品的玩家的玩家ID。 +- `itemId` 作为一个无符号的256位整数,代表将被装备给给定玩家的物品ID。 + +所有这些都将作为一个字节缓冲区传递,将按顺序读取,因此确保正确的缓冲区布局顺序是至关重要的。你可以按照以下方式为上述创建缓冲区布局模式或模板: + +``` +import * as borsh from '@coral-xyz/borsh' + +const equipPlayerSchema = borsh.struct([ + borsh.u8('variant'), + borsh.u16('playerId'), + borsh.u256('itemId') +]) +``` + +然后,你可以使用这个模式和`encode`方法对数据进行编码。这个方法接受代表要序列化的数据的对象和一个缓冲区作为参数。在下面的例子中,我们分配了一个比需要的大得多的新缓冲区,然后将数据编码到该缓冲区中,并将原始缓冲区切割成一个只有所需大小的新缓冲区。 + +``` +import * as borsh from '@coral-xyz/borsh' + +const equipPlayerSchema = borsh.struct([ + borsh.u8('variant'), + borsh.u16('playerId'), + borsh.u256('itemId') +]) + +const buffer = Buffer.alloc(1000) +equipPlayerSchema.encode({ variant: 2, playerId: 1435, itemId: 737498 }, buffer) + +const instructionBuffer = buffer.slice(0, equipPlayerSchema.getSpan(buffer)) +``` + +一旦缓冲区被正确创建并且数据被序列化,剩下的就是构建交易。这类似于您在之前的课程中所做的。下面的例子假设: + +- `player`、`playerInfoAccount` 和 `PROGRAM_ID` 已经在代码片段之外的某处定义 +- `player` 是用户的公钥 +- `playerInfoAccount` 是将编写库存更改的账户的公钥 +- `SystemProgram` 将在执行指令的过程中使用。 + +``` +import * as borsh from '@coral-xyz/borsh' +import * as web3 from '@solana/web3.js' + +const equipPlayerSchema = borsh.struct([ + borsh.u8('variant'), + borsh.u16('playerId'), + borsh.u256('itemId') +]) + +const buffer = Buffer.alloc(1000) +equipPlayerSchema.encode({ variant: 2, playerId: 1435, itemId: 737498 }, buffer) + +const instructionBuffer = buffer.slice(0, equipPlayerSchema.getSpan(buffer)) + +const endpoint = web3.clusterApiUrl('devnet') +const connection = new web3.Connection(endpoint) + +const transaction = new web3.Transaction() +const instruction = new web3.TransactionInstruction({ + keys: [ + { + pubkey: player.publicKey, + isSigner: true, + isWritable: false, + }, + { + pubkey: playerInfoAccount, + isSigner: false, + isWritable: true, + }, + { + pubkey: web3.SystemProgram.programId, + isSigner: false, + isWritable: false, + } + ], + data: instructionBuffer, + programId: PROGRAM_ID +}) + +transaction.add(instruction) + +web3.sendAndConfirmTransaction(connection, transaction, [player]).then((txid) => { + console.log(`Transaction submitted: https://explorer.solana.com/tx/${txid}?cluster=devnet`) +}) +``` + +# 实验室 + +让我们一起实践,通过构建一个电影评论应用,该应用允许用户提交电影评论并将其存储在Solana网络上。在接下来的几课中,我们会逐步构建这个应用,每节课增加新功能。 + +这是我们将要构建的程序的简要图示: + +我们将用于此应用程序的Solana程序的公钥是 `CenYq6bDRB7p73EjsPEpiYN7uveyPUTdXkDkgUduboaN`。 + +1. **下载起始代码** 在我们开始之前,先下载起始代码。 + + 该项目是一个相当简单的Next.js应用程序。它包括我们在“钱包”课程中创建的WalletContextProvider,用于显示电影评论的Card组件,用于以列表形式显示评论的MovieList组件,用于提交新评论的Form组件,以及包含Movie对象类定义的Movie.ts文件。 + + 注意,目前当你运行npm run dev时,页面上显示的电影是模拟的。在这节课中,我们将重点放在添加新评论上,但实际上我们将无法看到该评论显示。下一节课,我们将重点放在从链上账户反序列化自定义数据上。 + +2. **创建缓冲区布局** 记住,要正确与Solana程序交互,您需要知道它期望数据如何结构化。我们的电影评论程序期望指令数据包含: + + - `variant` 作为无符号的8位整数,表示应执行哪个指令(换句话说,哪个程序上的函数应该被调用)。 + - `title` 作为字符串,代表您正在评论的电影的标题。 + - `rating` 作为无符号的8位整数,表示您给正在评论的电影的评分(满分为5)。 + - `description` 作为字符串,代表您为电影留下的书面评论部分。 + + 让我们在Movie类中配置一个borsh布局。首先导入@coral-xyz/borsh。接下来,创建一个borshInstructionSchema属性,并将其设置为包含上述属性的适当borsh结构。 + + 请记住,顺序很重要。如果这里的属性顺序与程序的结构不同,交易将会失败。 + +3. **创建序列化数据的方法** 现在我们已经设置好了缓冲区布局,让我们在Movie中创建一个名为serialize()的方法,该方法将返回一个Buffer,其中包含Movie对象属性编码成适当布局的数据。 + + 上面显示的方法首先为我们的对象创建一个足够大的缓冲区,然后将`{ ...this, variant: 0 }`编码到缓冲区中。因为Movie类定义包含了缓冲区布局所需的4个属性中的3个,并使用相同的命名,我们可以直接使用展开运算符并只添加variant属性。最后,该方法返回一个新的缓冲区,省略了原始缓冲区的未使用部分。 + +4. **用户提交表单时发送交易** + + 现在我们有了指令数据的构建块,当用户提交表单时,我们可以创建并发送交易。打开Form.tsx并找到handleTransactionSubmit函数。每次用户提交电影评论表单时,都会调用此函数。 + + 在此函数中,我们将创建并发送包含通过表单提交的数据的交易。 + + 首先导入@solana/web3.js,并从@solana/wallet-adapter-react中导入useConnection和useWallet。 + + 接下来,在handleSubmit函数之前,调用useConnection()获取连接对象,并调用useWallet()获取publicKey和sendTransaction。 + + 在我们实现handleTransactionSubmit之前,让我们谈谈需要做什么。我们需要: + + - 检查publicKey是否存在,以确保用户已连接他们的钱包。 + - 调用movie上的serialize()以获取代表指令数据的缓冲区。 + - 创建一个新的Transaction对象。 + - 获取交易将读取或写入的所有账户。 + - 创建一个新的Instruction对象,该对象在keys参数中包含所有这些账户,在data参数中包含缓冲区,并在programId参数中包含程序的公钥。 + - 将最后一步中的指令添加到交易中。 + - 调用sendTransaction,传入组装好的交易。 + + 这是一个相当多的过程!但别担心,做得越多就越容易。让我们从上面的前3个步骤开始: + +接下来的步骤是获取交易将读取或写入的所有账户。在过去的课程中,已经给您提供了将存储数据的账户。这次,账户的地址更为动态,因此需要计算。我们将在下一课中深入讨论这一点,但现在您可以使用以下方法,其中pda是将存储数据的账户的地址: + +接下来的步骤是获取交易将读取或写入的所有账户。在过去的课程中,已经给您提供了将存储数据的账户。这次,账户的地址更为动态,因此需要计算。我们将在下一课中深入讨论这一点,但现在您可以使用以下方法,其中pda是将存储数据的账户的地址: + +就这样!您现在应该能够使用网站上的表单提交电影评论。虽然您不会看到UI更新以反映新的评论,但您可以在Solana Explorer上查看交易的程序日志,以确认它是否成功。 + +如果您需要更多时间来完成这个项目以便感到舒适,可以查看完整的解决方案代码。 + +# 挑战 + +现在轮到你独立构建一些东西了。创建一个应用程序,让这门课程的学生介绍自己!支持此应用程序的Solana程序位于 `HdE95RSVsdb315jfJtaykXhXY478h53X6okDupVfY9yf`。 + +![Screenshot of Student Intros frontend](https://www.soldev.app/assets/student-intros-frontend.png) + +1. 您可以从头开始构建,也可以下载起始代码。 +2. 在 `StudentIntro.ts` 中创建指令缓冲区布局。 + 1. 程序期望指令数据包含: variant作为无符号的8位整数,代表要运行的指令(应为0)。 + 2. name作为字符串,代表学生的名字。 + 3. message作为字符串,代表学生分享他们的Solana之旅的信息。 + +3. 在StudentIntro.ts中创建一个方法,将使用缓冲区布局来序列化StudentIntro对象。 +4. 在Form组件中,实现handleTransactionSubmit函数,使其序列化StudentIntro,构建适当的交易和交易指令,并将交易提交给用户的钱包。 +5. 您现在应该能够提交介绍,并将信息存储在链上!一定要记录交易ID,并在Solana Explorer中查看以验证它是否有效。 + +如果您感觉很困惑,可以查看解决方案代码。 + +请随意发挥这些挑战的创造力,将它们进一步发展。这些指令并不是为了阻止你! \ No newline at end of file diff --git a/solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-7-deserialize-program-data.md b/solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-7-deserialize-program-data.md new file mode 100755 index 000000000..61e97bb89 --- /dev/null +++ b/solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-7-deserialize-program-data.md @@ -0,0 +1,164 @@ +# 简介 + +程序将数据存储在PDA中,即Program Derived Address(程序派生地址)。 PDA没有对应的秘密密钥。 为了存储和定位数据,使用 `findProgramAddress(seeds, programid)` 方法来派生一个PDA。 你可以使用 `getProgramAccounts(programId)` 方法获取属于某个程序的账户。 账户数据需要使用最初存储它们时相同的布局来进行反序列化。 + +你可以使用 `@coral-xyz/borsh` 来创建一个模式。 + +# 正文 + +在上一节中,我们序列化了程序数据,随后这些数据被Solana程序存储在链上。在本课中,我们将更详细地介绍程序如何在链上存储数据、如何检索数据以及如何反序列化它们存储的数据。 + +### 程序 + +正如俗话所说,Solana中的一切都是账户。甚至包括程序。程序是存储代码的账户,并被标记为可执行。这些代码可以在Solana运行时接到指令时执行。程序地址是基于Ed25519椭圆曲线的公钥。就像所有公钥一样,它们有对应的秘密密钥。 + +程序将数据与代码分开存储。程序在PDA中存储数据,即Program Derived Address(程序派生地址)。PDA是Solana特有的一个概念,但这种模式很熟悉: + +* 你可以将PDA看作是一个键值存储,其中地址是键,账户内的数据是值。 + +* 你也可以将PDA视为数据库中的记录,地址作为用于查找内部值的主键。 + +PDA结合了程序地址和开发者选择的种子,以创建存储各个数据片段的地址。由于PDA是位于Ed25519椭圆曲线之外的地址,PDA没有秘密密钥。相反,PDA可以由用来创建它们的程序地址进行签名。 + +基于程序地址、bump和种子,可以一致地找到PDA及其内部的数据。要找到一个PDA,需要通过findProgramAddress()函数传入程序ID和开发者选择的种子(如一串文本)。 + +让我们看一些例子... + +#### 示例:具有全局状态的程序 + +一个具有全局状态的简单程序 - 像我们的ping计数器 - 可能希望只使用一个基于简单种子短语(如"GLOBAL_STATE")的PDA。如果客户端想要从这个PDA读取数据,它可以使用程序ID和这个相同的种子来派生地址。 + +``` +const [pda, bump] = await findProgramAddress(Buffer.from("GLOBAL_STATE"), programId) +``` + +![Global state using a PDA](https://www.soldev.app/assets/pdas-global-state.svg) + +#### 示例:具有用户特定数据的程序 + +在存储用户特定数据的程序中,通常使用用户的公钥作为种子。这样将每个用户的数据分离到各自的PDA中。这种分离使得客户端能够通过使用程序ID和用户的公钥来找到每个用户的数据地址。 + +``` +const [pda, bump] = await web3.PublicKey.findProgramAddress( + [ + publicKey.toBuffer() + ], + programId +) +``` + +![Per user state](https://www.soldev.app/assets/pdas-per-user-state.svg) + +#### 示例:每个用户有多个数据项的程序 + +当每个用户有多个数据项时,程序可能会使用更多种子来创建和识别账户。例如,在一个记事本应用中,可能每个笔记都有一个账户,每个PDA都是用用户的公钥和笔记标题派生的。 + +``` +const [pda, bump] = await web3.PublicKey.findProgramAddress( + [ + publicKey.toBuffer(), + Buffer.from("Shopping list") + ], + programId, +); +``` + +![Global state using a PDA](https://www.soldev.app/assets/pdas-note-taking-program.svg) + +在这个例子中,我们可以看到Alice和Bob都有一个名为'购物清单'的笔记,但由于我们使用了他们的钱包地址作为其中一个种子,这两个笔记可以同时存在。 + +### 获取多个程序账户 + +除了派生地址外,你还可以使用connection.getProgramAccounts(programId)获取程序创建的所有账户。这将返回一个对象数组,每个对象都有一个pubkey属性,代表账户的公钥,以及一个类型为AccountInfo的account属性。你可以使用account属性来获取账户数据。 + +``` +const accounts = connection.getProgramAccounts(programId).then(accounts => { + accounts.map(({ pubkey, account }) => { + console.log('Account:', pubkey) + console.log('Data buffer:', account.data) + }) +}) +``` + +### 反序列化程序数据 + +AccountInfo对象上的data属性是一个缓冲区。为了有效地使用它,你需要编写代码将其反序列化为更易用的形式。这类似于我们在上一课中涵盖的序列化过程。和之前一样,我们将使用Borsh和@coral-xyz/borsh。如果你需要复习这些内容,请查看上一课。 + +反序列化需要提前了解账户布局。当创建自己的程序时,你将定义如何完成这一过程。许多程序也有关于如何反序列化账户数据的文档。否则,如果程序代码可用,你可以查看源代码以确定结构。 + +为了正确地从链上程序反序列化数据,你将必须创建一个客户端模式,以反映数据在账户中的存储方式。例如,以下可能是一个存储链上游戏中玩家元数据的账户的模式。 + +``` +import * as borsh from "@coral-xyz/borsh"; + +borshAccountSchema = borsh.struct([ + borsh.bool("initialized"), + borsh.u16("playerId"), + borsh.str("name"), +]); +``` + +一旦你定义了布局,只需在模式上调用.decode(buffer)即可。 + +``` +import * as borsh from "@coral-xyz/borsh"; + +borshAccountSchema = borsh.struct([ + borsh.bool("initialized"), + borsh.u16("playerId"), + borsh.str("name"), +]); + +const { playerId, name } = borshAccountSchema.decode(buffer); +``` + +实验室 让我们继续从上一课的电影评论应用实践开始。如果你是新加入的,不用担心——无论如何,你都应该能够跟上。 + +作为回顾,这个项目使用了一个部署在Devnet的Solana程序,让用户可以评论电影。在上一课中,我们为前端框架添加了让用户提交电影评论的功能,但目前评论列表仍显示模拟数据。现在,我们来通过获取程序存储的账户并对存储的数据进行反序列化来解决这个问题。 + +1. 下载起始代码 如果你没有完成上一课的实验室活动,或者只是想确认你没有错过任何内容,你可以下载起始代码。 + +该项目是一个相对简单的Next.js应用程序。它包括了我们在钱包课程中创建的WalletContextProvider,一个用于展示电影评论的Card组件,一个显示评论列表的MovieList组件,一个用于提交新评论的Form组件,以及一个包含Movie对象类定义的Movie.ts文件。 + +请注意,当你运行npm run dev时,页面上展示的评论是模拟数据。我们接下来会用真实数据替换它们。 + +1. 创建缓冲区布局 要正确与Solana程序交互,你需要了解其数据结构。再次提醒一下: + +程序的可执行数据存储在程序账户中,但单独的评论存储在PDA中。我们使用findProgramAddress()来为每个钱包和每个电影标题创建独特的PDA。在PDA的数据中,我们将存储以下信息: + +- initialized作为布尔值,表示账户是否已初始化。 +- rating作为无符号的8位整数,表示评审者给电影的评分(最高5分)。 +- title作为字符串,表示被评论的电影标题。 +- description作为字符串,表示评论的书面部分。 + +让我们在Movie类中配置一个borsh布局,来表示电影账户数据的布局。首先导入@coral-xyz/borsh。接着,创建一个borshAccountSchema静态属性,并将其设置为包含上述属性的适当borsh结构。 + +记住,这里的顺序很重要。它需要与账户数据的结构相匹配。 + +1. 创建一个反序列化数据的方法 既然我们已经设置了缓冲区布局,现在让我们在Movie中创建一个名为deserialize的静态方法,这个方法将接受一个可选的Buffer,并返回一个Movie对象或null。 + +该方法首先检查缓冲区是否存在,如果不存在则返回null。接着,它使用我们创建的布局来解码缓冲区,然后使用这些数据来构建并返回一个Movie实例。如果解码失败,该方法将记录错误并返回null。 + +1. 获取电影评论账户 现在我们有了反序列化账户数据的方法,接下来我们需要实际获取这些账户。打开MovieList.tsx并导入@solana/web3.js。然后,在MovieList组件中创建一个新的Connection。最后,用connection.getProgramAccounts的调用替换useEffect中的setMovies(Movie.mocks)这一行。将得到的数组转换成电影数组,然后调用setMovies。 + +此时,你应该能够运行应用并看到从程序中检索到的电影评论列表! + +根据提交的评论数量,这可能需要一段时间来加载,或可能完全卡住你的浏览器。但不用担心——在下一课中,我们将学习如何对账户进行分页和过滤,以便你可以更精确地加载所需内容。 + +如果你需要更多时间来熟悉这个项目和这些概念,请在继续之前查看解决方案代码。 + +挑战 现在轮到你独立构建一些东西了。在上一课中,你在学生自我介绍应用上工作,对指令数据进行序列化并向网络发送了一条新的介绍。现在,是时候获取并反序列化程序的账户数据了。请记住,支持这个Solana程序的地址是HdE95RSVsdb315jfJtaykXhXY478h53X6okDupVfY9yf。 + +你可以从头开始构建,也可以下载起始代码。 + +- 在StudentIntro.ts中创建账户缓冲区布局。账户数据包括: + - initialized作为无符号的8位整数,表示要运行的指令(应为1)。 + - name作为字符串,表示学生的姓名。 + - message作为字符串,表示学生分享的关于他们的Solana之旅的信息。 +- 在StudentIntro.ts中创建一个静态方法,使用缓冲区布局将账户数据缓冲区反序列化为StudentIntro对象。 +- 在StudentIntroList组件的useEffect中,获取程序的账户并将其数据反序列化为StudentIntro对象列表。 +- 现在,你应该能看到从网络上获取的学生自我介绍,而不是模拟数据! + +如果你感到非常困难,可以查看解决方案代码。 + +一如既往,对这些挑战发挥创意,并根据需要超出指令进行拓展! \ No newline at end of file diff --git a/solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-8-page-order-filter-program-data.md b/solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-8-page-order-filter-program-data.md new file mode 100755 index 000000000..33049cb5a --- /dev/null +++ b/solana-development-course-zh/02-dApp-Development/module1-Introduction-to-cryptography-and-Solana-clients/1-8-page-order-filter-program-data.md @@ -0,0 +1,438 @@ +# 简介 + +这节课深入讲解了我们在“反序列化账户数据”课程中使用的一些RPC调用功能。为了节省计算时间,您可以在不获取数据的情况下获取大量账户,通过筛选它们仅返回一个公钥数组。一旦您有了筛选后的公钥列表,您可以对它们排序并获取它们所属的账户数据。 + +# 正文 + +您可能已经注意到,在上一课中,尽管我们可以获取并显示账户数据列表,但我们无法精确控制要获取的账户数量或它们的顺序。在这节课中,我们将了解一些`getProgramAccounts`函数的配置选项,这些选项可以实现分页、排序账户和筛选等功能。 + +### 只获取您需要的数据 + +想象一下,我们在过去课程中开发的电影评论应用有四百万条电影评论,平均每条评论500字节。这将使所有评论账户的总下载量超过2GB。显然,这不是您希望前端在每次页面刷新时都下载的内容。 + +幸运的是,您用来获取所有账户的`getProgramAccounts`函数接受一个配置对象作为参数。其中一个配置选项是`dataSlice`,它允许您提供两样东西: + +- `offset` - 从数据缓冲区开始的偏移量来开始切片 +- `length` - 从提供的偏移量开始返回的字节数量 + +当您在配置对象中包含一个`dataSlice`时,该函数只会返回您指定的数据缓冲区的子集。 + +#### 分页账户 + +这在分页时非常有用。如果您想要显示所有账户的列表,但有太多账户,您不想一次性拉取所有数据,您可以使用`{ offset: 0, length: 0 }`的`dataSlice`来获取所有账户,但不获取它们的数据。然后您可以将结果映射到一个账户密钥列表,只在需要时才获取它们的数据。 + +``` +const accountsWithoutData = await connection.getProgramAccounts( + programId, + { + dataSlice: { offset: 0, length: 0 } + } +) + +const accountKeys = accountsWithoutData.map(account => account.pubkey) +``` + +有了这些密钥列表,您可以使用`getMultipleAccountsInfo`方法分页获取账户数据: + +``` +const paginatedKeys = accountKeys.slice(0, 10) +const accountInfos = await connection.getMultipleAccountsInfo(paginatedKeys) +const deserializedObjects = accountInfos.map((accountInfo) => { + // 在这里放置反序列化accountInfo.data的逻辑 +}) +``` + +#### 排序账户 + +`dataSlice`选项在需要在分页时排序账户列表时也很有帮助。您仍然不想一次性获取所有数据,但您确实需要所有的密钥和一种方法来提前排序它们。在这种情况下,您需要了解账户数据的布局,并配置数据切片以仅为排序使用所需的数据。 + +例如,您可能有一个这样存储联系信息的账户: + +- `initialized` 作为一个布尔值 +- `phoneNumber` 作为一个无符号的64位整数 +- `firstName` 作为一个字符串 +- `secondName` 作为一个字符串 + +如果您想根据用户的名字按字母顺序对所有账户密钥进行排序,您需要找出名字开始的偏移量。第一个字段`initialized`占用第一个字节,然后`phoneNumber`又占用了8个字节,所以`firstName`字段从偏移量1 + 8 = 9开始。然而,Borsh中动态数据字段使用前4个字节来记录数据的长度,所以我们可以再跳过4个字节,使偏移量为13。 + +然后您需要确定切片长度。由于长度是可变的,在获取数据之前我们无法确切知道。但是您可以选择一个足够大以覆盖大多数情况、又足够小以不至于太麻烦的长度来获取。对于大多数名字来说,15个字节已经足够,但即使对于百万用户来说,这也会导致一个足够小的下载量。 + +一旦您用给定的数据切片获取了账户,您可以在将其映射到一个公钥数组之前使用`sort`方法对数组进行排序。 + +``` +const accounts = await connection.getProgramAccounts( + programId, + { + dataSlice: { offset: 13, length: 15 } + } +) + +accounts.sort( (a, b) => { + const lengthA = a.account.data.readUInt32LE(0) + const lengthB = b.account.data.readUInt32LE(0) + const dataA = a.account.data.slice(4, 4 + lengthA) + const dataB = b.account.data.slice(4, 4 + lengthB) + return dataA.compare(dataB) +}) + +const accountKeys = accounts.map(account => account.pubkey) +``` + +请注意,在上面的代码片段中,我们不是直接比较给定的数据。这是因为对于像字符串这样的动态大小类型,Borsh在开始处放置一个无符号的32位(4字节)整数来指示表示该字段的数据的长度。因此,为了直接比较名字,我们需要获取每个名字的长度,然后创建一个有4字节偏移量和适当长度的数据切片。 + +使用过滤器只检索特定账户 每个账户限制接收的数据量是很好的,但如果您只想返回满足特定条件的账户而不是所有账户怎么办?这就是`filters`配置选项的用武之地。这个选项是一个数组,可以包含以下匹配对象: + +- ``` + memcmp + ``` + + \- 将提供的一系列字节与特定偏移处的程序账户数据进行比较。字段包括: + + - `offset` - 在比较数据之前偏移到程序账户数据中的数字 + - `bytes` - 表示要匹配的数据的 base-58 编码字符串;限制为少于129字节 + +- `dataSize` - 将程序账户数据长度与提供的数据大小进行比较 + +这些让您可以基于匹配数据和/或总数据大小进行过滤。 + +例如,您可以通过包含一个`memcmp`过滤器来搜索联系人列表: + +``` +javascriptCopy code +async function fetchMatchingContactAccounts(connection: web3.Connection, search: string): Promise<(web3.AccountInfo | null)[]> { + const accounts = await connection.getProgramAccounts( + programId, + { + dataSlice: { offset: 0, length: 0 }, + filters: [ + { + memcmp: + { + offset: 13, + bytes: bs58.encode(Buffer.from(search)) + } + } + ] + } + ) +} +``` + +在上面的例子中需要注意两点: + +1. 我们将偏移量设置为13,因为我们之前确定了数据布局中`firstName`的偏移量为9,而我们想要额外跳过表示字符串长度的前4个字节。 +2. 我们使用第三方库`bs58`对搜索项进行 base-58 编码。您可以使用`npm install bs58`安装它。 + +# 实验 + +还记得我们在过去两课中开发的电影评论应用吗?我们将对评论列表进行分页,对评论进行排序,使其不那么随机,并添加一些基本搜索功能,使其更加丰富。如果您还没看过前几课,也没关系 - 只要您具备必要的知识,即使您还没有在这个特定项目中工作,您也应该能够跟上实验室的内容。 + +### 1.下载起始代码 如果您没有完成上一课的实验室,或者只是想确保没有错过任何内容,您可以下载起始代码。 + +该项目是一个相当简单的 Next.js 应用程序。它包括我们在“钱包”课程中创建的`WalletContextProvider`、用于显示电影评论的`Card`组件、显示评论列表的`MovieList`组件、用于提交新评论的`Form`组件,以及包含`Movie`对象类定义的`Movie.ts`文件。 + +### 2.为评论添加分页 + +首先,让我们创建一个封装获取账户数据代码的空间。创建一个新文件`MovieCoordinator.ts`并声明一个`MovieCoordinator`类。然后我们将从`MovieList`中移动`MOVIE_REVIEW_PROGRAM_ID`常量到这个新文件,因为我们将引用它 + +``` +javascriptCopy code +const MOVIE_REVIEW_PROGRAM_ID = 'CenYq6bDRB7p73EjsPEpiYN7uveyPUTdXkDkgUduboaN' + +export class MovieCoordinator { } +``` + +现在我们可以使用`MovieCoordinator`来创建一个分页实现。在我们开始之前的一个快速说明:这将是尽可能简单的分页实现,以便我们可以专注于与 Solana 账户交互的复杂部分。对于生产应用程序,您可以(也应该)做得更好。 + +说完这个,让我们创建一个静态属性`accounts`类型为`web3.PublicKey[]`,一个静态函数`prefetchAccounts(connection: web3.Connection)`,和一个静态函数`fetchPage(connection: web3.Connection, page: number, perPage: number): Promise`。您还需要导入`@solana/web3.js`和`Movie`。 + +``` +javascriptCopy code +import * as web3 from '@solana/web3.js' +import { Movie } from '../models/Movie' + +const MOVIE_REVIEW_PROGRAM_ID = 'CenYq6bDRB7p73EjsPEpiYN7uveyPUTdXkDkgUduboaN' + +export class MovieCoordinator { + static accounts: web3.PublicKey[] = [] + + static async prefetchAccounts(connection: web3.Connection) { + + } + + static async fetchPage(connection: web3.Connection, page: number, perPage: number): Promise { + + } +} +``` + +进行分页的关键是预先获取所有没有数据的账户。让我们填充`prefetchAccounts`的主体来做这件事,并将检索到的公钥设置到静态`accounts`属性。 + +``` +javascriptCopy code +static async prefetchAccounts(connection: web3.Connection) { + const accounts = await connection.getProgramAccounts( + new web3.PublicKey(MOVIE_REVIEW_PROGRAM_ID), + { + dataSlice: { offset: 0, length: 0 }, + } + ) + + this.accounts = accounts.map(account => account.pubkey) +} +``` + +现在,让我们填充`fetchPage`方法。首先,如果账户尚未预先获取,我们需要这样做。然后,我们可以获取对应于请求页面的账户公钥,并调用`connection.getMultipleAccountsInfo`。最后,我们反序列化账户数据并返回相应的`Movie`对象。 + +``` +static async fetchPage(connection: web3.Connection, page: number, perPage: number): Promise { + if (this.accounts.length === 0) { + await this.prefetchAccounts(connection) + } + + const paginatedPublicKeys = this.accounts.slice( + (page - 1) * perPage, + page * perPage, + ) + + if (paginatedPublicKeys.length === 0) { + return [] + } + + const accounts = await connection.getMultipleAccountsInfo(paginatedPublicKeys) + + const movies = accounts.reduce((accum: Movie[], account) => { + const movie = Movie.deserialize(account?.data) + if (!movie) { + return accum + } + + return [...accum, movie] + }, []) + + return movies +} +``` + +现在我们已经完成了上述步骤,我们可以重新配置`MovieList`以使用这些方法。在`MovieList.tsx`中,添加`const [page, setPage] = useState(1)`靠近现有的`useState`调用。然后,更新`useEffect`以调用`MovieCoordinator.fetchPage`而不是内联获取账户。 + +``` +javascriptCopy code +const { connection } = useConnection() +const [movies, setMovies] = useState([]) +const [page, setPage] = useState(1) + +useEffect(() => { + MovieCoordinator.fetchPage( + connection, + page, + 10 + ).then(setMovies) +}, [page]) +``` + +最后,我们需要在列表底部添加按钮来导航到不同的页面: + +``` +javascriptCopy code +return ( +
+ { + movies.map((movie, i) => ) + } +
+ + { + page > 1 && + } + + { + MovieCoordinator.accounts.length > page * 2 && + + } + +
+
+) +``` + +到目前为止,您应该能够运行项目并在页面之间点击! + +### 3.按标题字母顺序排列评论 + +如果您查看评论,可能会注意到它们没有任何特定的顺序。我们可以通过在数据切片中添加足够的数据来帮助我们进行排序来解决这个问题。电影评论数据缓冲区中的各种属性布局如下: + +- `initialized` - 无符号8位整数;1字节 +- `rating` - 无符号8位整数;1字节 +- `title` - 字符串;未知字节数 +- `description` - 字符串;未知字节数 + +基于此,我们需要提供给数据切片的偏移量来访问`title`是2。然而,长度是不确定的,所以我们可以只提供看起来合理的长度。我会使用18,因为这将涵盖大多数标题的长度,而不会每次都获取太多数据。 + +一旦我们修改了`getProgramAccounts`中的数据切片,我们还需要对返回的数组进行实际排序。为此,我们需要比较数据缓冲区实际对应`title`的部分。Borsh中动态字段的前4个字节用于存储该字段的字节长度。所以在任何给定的按我们上述讨论的方式切片的缓冲区数据中,字符串部分是`data.slice(4, 4 + data[0])`。 + +现在我们已经思考过这个问题,让我们修改`MovieCoordinator`中`prefetchAccounts`的实现: + +``` +javascriptCopy code +static async prefetchAccounts(connection: web3.Connection, filters: AccountFilter[]) { + const accounts = await connection.getProgramAccounts( + new web3.PublicKey(MOVIE_REVIEW_PROGRAM_ID), + { + dataSlice: { offset: 2, length: 18 }, + } + ) + + accounts.sort( (a, b) => { + const lengthA = a.account.data.readUInt32LE(0) + const lengthB = b.account.data.readUInt32LE(0) + const dataA = a.account.data.slice(4, 4 + lengthA) + const dataB = b.account.data.slice(4, 4 + lengthB) + return dataA.compare(dataB) + }) + + this.accounts = accounts.map(account => account.pubkey) +} +``` + +就这样,您应该能够运行应用并看到按字母顺序排列的电影评论列表。 + +### 4.添加搜索 + +我们将要对这个应用程序做的最后一件事是添加一些基本的搜索功能。让我们向`prefetchAccounts`添加一个搜索参数,并重新配置函数的主体以使用它。 + +我们可以使用`getProgramAccounts`配置参数的`filters`属性根据特定数据过滤账户。标题字段的偏移量为2,但标题的前4个字节是标题的长度,所以字符串本身的实际偏移量是6。请记住,字节需要进行base 58编码,因此让我们安装并导入`bs58`。 + +``` +import bs58 from 'bs58' + +... + +static async prefetchAccounts(connection: web3.Connection, search: string) { + const accounts = await connection.getProgramAccounts( + new web3.PublicKey(MOVIE_REVIEW_PROGRAM_ID), + { + dataSlice: { offset: 2, length: 18 }, + filters: search === '' ? [] : [ + { + memcmp: + { + offset: 6, + bytes: bs58.encode(Buffer.from(search)) + } + } + ] + } + ) + + accounts.sort( (a, b) => { + const lengthA = a.account.data.readUInt32LE(0) + const lengthB = b.account.data.readUInt32LE(0) + const dataA = a.account.data.slice(4, 4 + lengthA) + const dataB = b.account.data.slice(4, 4 + lengthB) + return dataA.compare(dataB) + }) + + this.accounts = accounts.map(account => account.pubkey) +} +``` + +现在,在`fetchPage`中添加一个搜索参数,并更新其对`prefetchAccounts`的调用以传递它。我们还需要向`fetchPage`添加一个`reload`布尔参数,以便每次搜索值更改时都强制刷新账户预获取。 + +``` +static async fetchPage(connection: web3.Connection, page: number, perPage: number, search: string, reload: boolean = false): Promise { + if (this.accounts.length === 0 || reload) { + await this.prefetchAccounts(connection, search) + } + + const paginatedPublicKeys = this.accounts.slice( + (page - 1) * perPage, + page * perPage, + ) + + if (paginatedPublicKeys.length === 0) { + return [] + } + + const accounts = await connection.getMultipleAccountsInfo(paginatedPublicKeys) + + const movies = accounts.reduce((accum: Movie[], account) => { + const movie = Movie.deserialize(account?.data) + if (!movie) { + return accum + } + + return [...accum, movie] + }, []) + + return movies +} +``` + +有了这些设置,让我们更新`MovieList`中的代码以正确调用它。 + +首先,在其他`useState`调用附近添加`const [search, setSearch] = useState('')`。然后更新`useEffect`中对`MovieCoordinator.fetchPage`的调用,以传递搜索参数,并在`search !== ''`时重新加载。 + +``` +const { connection } = useConnection() +const [movies, setMovies] = useState([]) +const [page, setPage] = useState(1) +const [search, setSearch] = useState('') + +useEffect(() => { + MovieCoordinator.fetchPage( + connection, + page, + 2, + search, + search !== '' + ).then(setMovies) +}, [page, search]) +``` + +最后,添加一个搜索栏,它将设置`search`的值: + +``` +return ( +
+
+ setSearch(event.currentTarget.value)} + placeholder='Search' + w='97%' + mt={2} + mb={2} + /> +
+ + ... + +
+) +``` + +就是这样!应用程序现在具有有序的评论、分页和搜索功能。 + +这里包含了大量内容,但您已经成功完成了。如果您需要更多时间来理解这些概念,请随时重读对您来说最具挑战性的部分,并/或查看解决方案代码。 + +# 挑战 + +现在轮到您自己尝试这些操作。使用上一课中的“学生介绍”应用,添加分页、按名字字母顺序排列和按名字搜索功能。 + +![Screenshot of Student Intros frontend](https://www.soldev.app/assets/student-intros-frontend.png) + +您可以从头开始构建,也可以下载起始代码。 + +通过预先获取没有数据的账户,然后仅在需要时获取每个账户的账户数据,为项目添加分页功能。 + +按名字字母顺序排列应用中显示的账户。 + +添加通过学生名字搜索介绍的功能。 + +这是具有挑战性的。如果您遇到困难,请随时参考解决方案代码。 + +通过这样做,您就完成了模块1!您的体验如何?欢迎分享一些快速反馈,以便我们继续改进课程! + +像往常一样,发挥创造力,将这些挑战变得更有创意! \ No newline at end of file diff --git a/solana-development-course-zh/02-dApp-Development/module2-Client-Interaction-with-Common-Solana-Programs/2-1-Create-tokens-with-the-Token-Program.md b/solana-development-course-zh/02-dApp-Development/module2-Client-Interaction-with-Common-Solana-Programs/2-1-Create-tokens-with-the-Token-Program.md new file mode 100755 index 000000000..f85bd4e0c --- /dev/null +++ b/solana-development-course-zh/02-dApp-Development/module2-Client-Interaction-with-Common-Solana-Programs/2-1-Create-tokens-with-the-Token-Program.md @@ -0,0 +1,510 @@ +# 简介 + +SPL-Token代表了Solana网络上所有非本地(即非SOL)代币。Solana上的可替代和非可替代代币(NFT)都是SPL-Token。 + +Token程序包含了创建和互动SPL-Token的指令。 + +TokenMint是一种账户,它保存有关特定Token的数据,但不持有Token。 + +Token账户用于持有特定TokenMint的Token。 + +创建TokenMint和Token账户需要用SOL支付租金。关闭账户时,可以退还Token账户的租金,但目前无法关闭TokenMint。 + +# 概述 + +Token程序是Solana程序库(SPL)提供的众多程序之一。它包含了创建和互动SPL-Token的指令。这些代币代表了Solana网络上所有非本地代币。 + +这节课将集中在使用Token程序创建和管理新的SPL-Token的基础上: + +- 创建新的TokenMint +- 创建Token账户 +- 铸造 Mint +- 将代币从一个持有者转移到另一个持有者 +- 销毁代币 Burn + +我们将使用Javascript库`@solana/spl-token` 来进行客户端开发过程。 + +## Token Mint 代币铸造 + +要创建一个新的SPL-Token,您首先需要创建一个TokenMint。TokenMint是保存有关特定代币数据的账户。 + +举个例子,让我们看看Solana Explorer上的美元币(USDC)。USDC的TokenMint地址是`EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`。通过Explorer,我们可以看到USDC的TokenMint的特定细节,如代币的当前供应量、铸造方Mint和冻结权限帐户的地址,以及代币的精度: + +![Screenshot of USDC Token Mint](https://www.soldev.app/assets/token-program-usdc-mint.png) + +要创建一个新的TokenMint,您需要向Token程序发送正确的交易指令。为此,我们将使用@solana/spl-token的createMint函数。 + +``` +const tokenMint = await createMint( + connection, + payer, + mintAuthority, + freezeAuthority, + decimals +); +``` + +createMint函数返回新Token铸币厂的publicKey。此函数需要以下参数: + +- connection - 连接到集群的JSON-RPC连接 +- payer - 交易支付方的公钥 +- mintAuthority - 授权从TokenMint实际铸造代币的账户。 +- freezeAuthority - 授权冻结Token账户中代币的账户。如果不需要冻结功能,此参数可以设置为null +- decimals - 指定代币的期望小数精度(小数点后几位) + +如果您创建新Mint的脚本可以访问您的秘钥,您可以简单地使用createMint函数。但是,如果您要构建一个网站,允许用户创建新的TokenMint,您需要用用户的秘钥来做,而不需要让他们将其暴露给浏览器。在这种情况下,您需要构建并提交一个包含正确指令的交易。 + +在底层,createMint函数实际上是创建一个包含两条指令的交易: + +1. 创建一个新账户 +2. 初始化一个新铸币厂 + +就像这样: + +``` +import * as web3 from '@solana/web3' +import * as token from '@solana/spl-token' + +async function buildCreateMintTransaction( + connection: web3.Connection, + payer: web3.PublicKey, + decimals: number +): Promise { + const lamports = await token.getMinimumBalanceForRentExemptMint(connection); + const accountKeypair = web3.Keypair.generate(); + const programId = token.TOKEN_PROGRAM_ID + + const transaction = new web3.Transaction().add( + web3.SystemProgram.createAccount({ + fromPubkey: payer, + newAccountPubkey: accountKeypair.publicKey, + space: token.MINT_SIZE, + lamports, + programId, + }), + token.createInitializeMintInstruction( + accountKeypair.publicKey, + decimals, + payer, + payer, + programId + ) + ); + + return transaction +} +``` + +当手动构建创建新TokenMint的指令时,确保将创建账户和初始化铸币厂的指令添加到同一笔交易中。如果您分别在不同的交易中执行每个步骤,理论上其他人可能会占用您创建的账户并为他们自己的铸币厂初始化它。 + +## 租金和租金豁免 Rent and Rent Exemption + +请注意,在之前代码片段中函数体的第一行调用了`getMinimumBalanceForRentExemptMint`,其结果被传递到`createAccount`函数中。这是账户初始化的一部分,称为租金豁免。 + +直到最近,Solana上的所有账户都需要做以下事情之一,以避免被取消分配: + +- 定期支付租金 +- 在初始化时存入足够的SOL,被视为免租金 + +最近,第一个选项被取消,初始化新账户时存入足够的SOL以免租金成为了要求。 + +在这种情况下,我们正在为Token铸币厂创建一个新账户,所以我们使用来自`@solana/spl-token`库的`getMinimumBalanceForRentExemptMint`。 + +其实,这个概念适用于所有账户,您可以使用`Connection`上的更通用的`getMinimumBalanceForRentExemption`方法来创建您可能需要的其他账户。 + +## Token Account + +在您铸造代币(发行)之前,您需要一个Token账户来持有新发行的代币。 + +Token账户持有由特定“TokenMint”铸造的代币,并指定账户的“所有者”。只有所有者被授权以转账、销毁等方式减少Token账户余额,而任何人都可以向Token账户发送代币以增加其余额。这是显然的,别人可以给你转钱,但只有你有权限花钱。 + +您可以使用`spl-token`库的`createAccount`函数来创建新的Token账户: + +``` +javascriptCopy code +const tokenAccount = await createAccount( + connection, + payer, + mint, + owner, + keypair +); +``` + +`createAccount`函数返回新Token账户的`publicKey`。此函数需要以下参数: + +- connection - 连接到集群的JSON-RPC连接 +- payer - 交易支付方的账户 +- mint - 新Token账户关联的Token铸币厂 +- owner - 新Token账户的所有者账户 +- keypair - 这是一个可选参数,用于指定新Token账户的地址。如果未提供keypair,`createAccount`函数默认从相关的铸币厂和所有者账户中派生。 + +请注意,这里的`createAccount`函数与我们在查看`createMint`函数时显示的`createAccount`函数不同。之前我们使用`SystemProgram`上的`createAccount`函数返回创建所有账户的指令。这里的`createAccount`函数是spl-token库中的一个辅助函数,它提交一个包含两条指令的交易。第一个创建账户,第二个将账户初始化为Token账户。 + +就像创建Token铸币厂一样,如果我们需要手动构建`createAccount`的交易,我们可以复制该函数在底层所做的操作: + +1. 使用`getMint`检索与铸币厂关联的数据。 +2. 使用`getAccountLenForMint`计算Token账户所需的空间。 +3. 使用`getMinimumBalanceForRentExemption`计算免租金所需的lamports。 +4. 使用`SystemProgram.createAccount`和`createInitializeAccountInstruction`创建一个新交易。请注意,这里的`createAccount`来自@solana/web3.js,用于创建一个通用的新账户。`createInitializeAccountInstruction`使用这个新账户来初始化新的Token账户。 + +``` +javascriptCopy code +import * as web3 from '@solana/web3' +import * as token from '@solana/spl-token' + +async function buildCreateTokenAccountTransaction( + connection: web3.Connection, + payer: web3.PublicKey, + mint: web3.PublicKey +): Promise { + const mintState = await token.getMint(connection, mint) + const accountKeypair = await web3.Keypair.generate() + const space = token.getAccountLenForMint(mintState); + const lamports = await connection.getMinimumBalanceForRentExemption(space); + const programId = token.TOKEN_PROGRAM_ID + + const transaction = new web3.Transaction().add( + web3.SystemProgram.createAccount({ + fromPubkey: payer, + newAccountPubkey: accountKeypair.publicKey, + space, + lamports, + programId, + }), + token.createInitializeAccountInstruction( + accountKeypair.publicKey, + mint, + payer, + programId + ) + ); + + return transaction +} +``` + +## 关联 Token 账户 + +关联Token账户是一种Token账户,其地址通过所有者的公钥和TokenMint派生而来。关联Token账户为特定TokenMint的特定公钥所有者提供了一种确定性的方式来找到其Token账户。 + +大多数情况下,当您创建Token账户时,您会希望它是一个关联Token账户。 + +如果没有关联Token账户,一个用户可能拥有属于同一TokenMint的多个Token账户,这会导致混淆,不清楚应该向哪里发送代币。 + +关联Token账户允许用户向另一个用户发送代币,即使接收方还没有该TokenMint的Token账户。 + +![ATAs are PDAs](https://www.soldev.app/assets/atas-are-pdas.svg) + +与上面类似,您可以使用`spl-token`库的`createAssociatedTokenAccount`函数来创建关联Token账户。 + +``` +javascriptCopy code +const associatedTokenAccount = await createAssociatedTokenAccount( + connection, + payer, + mint, + owner, +); +``` + +此函数返回新关联Token账户的publicKey,并需要以下参数: + +- connection - 连接到集群的JSON-RPC连接 +- payer - 交易支付方的账户 +- mint - 新Token账户关联的Token铸币厂 +- owner - 新Token账户的所有者账户 + +您还可以使用`getOrCreateAssociatedTokenAccount`来获取与给定地址关联的Token账户,或在它不存在时创建它。例如,如果您正在编写向给定用户空投代币的代码,您可能会使用此函数以确保在不存在时创建与给定用户关联的Token账户。 + +在底层,`createAssociatedTokenAccount`正在做两件事: + +1. 使用`getAssociatedTokenAddress`从铸币厂和所有者派生关联Token账户地址。 +2. 使用`createAssociatedTokenAccountInstruction`的指令构建交易。 + +``` +javascriptCopy code +import * as web3 from '@solana/web3' +import * as token from '@solana/spl-token' + +async function buildCreateAssociatedTokenAccountTransaction( + payer: web3.PublicKey, + mint: web3.PublicKey +): Promise { + const associatedTokenAddress = await token.getAssociatedTokenAddress(mint, payer, false); + + const transaction = new web3.Transaction().add( + token.createAssociatedTokenAccountInstruction( + payer, + associatedTokenAddress, + payer, + mint + ) + ) + + return transaction +} +``` + +## 铸造代币 + +铸造代币是指将新代币发行到流通中的过程。当您铸造代币时,您增加了Token铸币厂的供应量,并将新铸造的代币存入Token账户。只有Token铸币厂的铸币权限账户被允许铸造新代币。 + +要使用`spl-token`库铸造代币,您可以使用`mintTo`函数。 + +``` +javascriptCopy code +const transactionSignature = await mintTo( + connection, + payer, + mint, + destination, + authority, + amount +); +``` + +`mintTo`函数返回一个`TransactionSignature`,可以复制该签名,在 [Solana Explorer](https://explorer.solana.com/?cluster=custom)对应的网络上进行查看。 + +`mintTo`函数需要以下参数: + +- connection - 连接到集群的JSON-RPC连接 +- payer - 交易支付方的账户 +- mint - 与新Token账户关联的TokenMint +- destination - 将被铸造代币的Token账户 +- authority - 授权铸造代币的账户 +- amount - 铸造的代币数量,不包括小数点,例如,如果Scrooge Coin铸币厂的decimals属性设为2,那么要获得1个完整的Scrooge Coin,您需要将此属性设置为100 + +在代币铸造后,将TokenMint的铸币权限设置为null并不罕见。这将设定最大供应量,并确保未来不会铸造更多代币。相反,铸币权限也可以授予一个程序,这样代币就可以按照固定间隔或可编程条件自动铸造。 + +在底层,`mintTo`函数只是创建了一个包含从`createMintToInstruction`函数获取的指令的交易。 + +``` +javascriptCopy code +import * as web3 from '@solana/web3' +import * as token from '@solana/spl-token' + +async function buildMintToTransaction( + authority: web3.PublicKey, + mint: web3.PublicKey, + amount: number, + destination: web3.PublicKey +): Promise { + const transaction = new web3.Transaction().add( + token.createMintToInstruction( + mint, + destination, + authority, + amount + ) + ) + + return transaction +} +``` + +## 转移代币 + +`SPL-Token`转移要求发送方和接收方都必须有代币铸币厂所发行的代币账户。代币从发送方的Token账户转移到接收方的Token账户。 + +在获取接收方的关联Token账户时,您可以使用`getOrCreateAssociatedTokenAccount`来确保在转移之前他们的Token账户已经存在。请记住,如果账户尚未存在,这个函数将创建它,并且交易的支付方将被扣除用于账户创建的lamports。 + +一旦您知道了接收方的Token账户地址,就可以使用`spl-token`库的`transfer`函数来转移代币。 + +``` +javascriptCopy code +const transactionSignature = await transfer( + connection, + payer, + source, + destination, + owner, + amount +) +``` + +`transfer`函数返回一个可以在Solana Explorer上查看的`TransactionSignature`。`transfer`函数需要以下参数: + +- connection - 连接到集群的JSON-RPC连接 +- payer - 交易支付方的账户 +- source - 发送代币的Token账户 +- destination - 接收代币的Token账户 +- owner - 源Token账户所有者的账户 +- amount - 要转移的代币数量 + +在底层,`transfer`函数只是创建了一个包含从`createTransferInstruction`函数获取的指令的交易。 + +``` +javascriptCopy code +import * as web3 from '@solana/web3' +import * as token from '@solana/spl-token' + +async function buildTransferTransaction( + source: web3.PublicKey, + destination: web3.PublicKey, + owner: web3.PublicKey, + amount: number +): Promise { + const transaction = new web3.Transaction().add( + token.createTransferInstruction( + source, + destination, + owner, + amount, + ) + ) + + return transaction +} +``` + +## 销毁代币 + +销毁代币是指减少特定TokenMint的代币供应量的过程。销毁代币将其从指定的Token账户和更广泛的流通中移除。 + +要使用`spl-token`库销毁代币,您可以使用`burn`函数。 + +``` +javascriptCopy code +const transactionSignature = await burn( + connection, + payer, + account, + mint, + owner, + amount +) +``` + +`burn`函数返回一个可以在Solana Explorer上查看的`TransactionSignature`。`burn`函数需要以下参数: + +- connection - 连接到集群的JSON-RPC连接 +- payer - 交易支付方的账户 +- account - 将要从中销毁代币的Token账户 +- mint - 与Token账户关联的Token铸币厂 +- owner - Token账户所有者的账户 +- amount - 要销毁的代币数量 + +在底层,`burn`函数创建了一个包含从`createBurnInstruction`函数获取的指令的交易。 + +``` +javascriptCopy code +import * as web3 from '@solana/web3' +import * as token from '@solana/spl-token' + +async function buildBurnTransaction( + account: web3.PublicKey, + mint: web3.PublicKey, + owner: web3.PublicKey, + amount: number +): Promise { + const transaction = new web3.Transaction().add( + token.createBurnInstruction( + account, + mint, + owner, + amount + ) + ) + + return transaction +} +``` + +## 批准代理 + +批准代理是指授权另一个账户从Token账户转移或销毁代币的过程。使用代理时,Token账户的控制权仍然在原始所有者手中。代理可以转移或销毁的最大代币量在Token账户的所有者批准代理时指定。请注意,任何时候一个Token账户只能关联一个代理账户。 + +要使用`spl-token`库批准代理,您可以使用`approve`函数。 + +``` +javascriptCopy code +const transactionSignature = await approve( + connection, + payer, + account, + delegate, + owner, + amount +) +``` + +`approve`函数返回一个可以在Solana Explorer上查看的TransactionSignature。`approve`函数需要以下参数: + +- connection - 连接到集群的JSON-RPC连接 +- payer - 交易支付方的账户 +- account - 将要从中委托代币的Token账户 +- delegate - 所有者授权转移或销毁代币的账户 +- owner - Token账户所有者的账户 +- amount - 代理可以转移或销毁的最大代币数量 + +在底层,`approve`函数创建了一个包含从`createApproveInstruction`函数获取的指令的交易。 + +``` +javascriptCopy code +import * as web3 from '@solana/web3' +import * as token from '@solana/spl-token' + +async function buildApproveTransaction( + account: web3.PublicKey, + delegate: web3.PublicKey, + owner: web3.PublicKey, + amount: number +): Promise { + const transaction = new web3.Transaction().add( + token.createApproveInstruction( + account, + delegate, + owner, + amount + ) + ) + + return transaction +} +``` + +## 撤销代理 + +之前批准的Token账户代理可以被撤销。一旦代理被撤销,代理将无法再从所有者的Token账户转移代币。之前批准的剩余未转移的金额将无法再由代理转移。 + +要使用`spl-token`库撤销代理,您可以使用`revoke`函数。 + +``` +javascriptCopy code +const transactionSignature = await revoke( + connection, + payer, + account, + owner, +) +``` + +`revoke`函数返回一个可以在Solana Explorer上查看的`TransactionSignature`。`revoke`函数需要以下参数: + +- connection - 连接到集群的JSON-RPC连接 +- payer - 交易支付方的账户 +- account - 将要撤销代理权限的Token账户 +- owner - Token账户所有者的账户 + +在底层,`revoke`函数创建了一个包含从`createRevokeInstruction`函数获取的指令的交易。 + +``` +javascriptCopy code +import * as web3 from '@solana/web3' +import * as token from '@solana/spl-token' + +async function buildRevokeTransaction( + account: web3.PublicKey, + owner: web3.PublicKey, +): Promise { + const transaction = new web3.Transaction().add( + token.createRevokeInstruction( + account, + owner, + ) + ) + + return transaction +} +``` \ No newline at end of file diff --git a/solana-development-course-zh/02-dApp-Development/module2-Client-Interaction-with-Common-Solana-Programs/2-2-Create-Solana-NFTs-With-Metaplex.md b/solana-development-course-zh/02-dApp-Development/module2-Client-Interaction-with-Common-Solana-Programs/2-2-Create-Solana-NFTs-With-Metaplex.md new file mode 100755 index 000000000..ac45e01c4 --- /dev/null +++ b/solana-development-course-zh/02-dApp-Development/module2-Client-Interaction-with-Common-Solana-Programs/2-2-Create-Solana-NFTs-With-Metaplex.md @@ -0,0 +1,567 @@ +# 导言:使用Metaplex在Solana上创建和分发NFTs + +1. **SPL代币与元数据账户**:Solana上的NFTs表现为SPL代币,每个代币都有一个关联的元数据账户,0小数位,最大供应量为1。 +2. **Metaplex工具集**:Metaplex提供了一系列工具,简化了在Solana区块链上创建和分发NFTs的过程。 +3. **代币元数据程序**:代币元数据程序标准化了将元数据附加到SPL代币的过程。 +4. **Metaplex SDK**:Metaplex SDK是一个工具,提供了用户友好的API,帮助开发者利用Metaplex提供的链上工具。 +5. **Candy Machine程序**:Candy Machine是一个NFT分发工具,用于从一个集合中创建和铸造NFTs。 +6. **Sugar CLI工具**:Sugar CLI是一个工具,简化了上传媒体/元数据文件和为一个集合创建Candy Machine的过程。 + +**Solana上的非同质化代币(NFTs)简介** + +Solana上的非同质化代币(NFTs)是使用Token程序创建的SPL代币。但这些代币还有一个额外的元数据账户与每个代币铸造账户相关联。这允许代币有多种用途,比如可以代表游戏库存或艺术品。 + +在本课程中,我们将介绍Solana上NFTs的基础知识,如何使用Metaplex SDK创建和更新它们,并简要介绍一些可以帮助你在Solana上大规模创建和分发NFTs的工具。 + +**Solana上的NFTs** + +Solana上的NFT是一个带有关联元数据的不可分割代币。此外,该代币的铸造最大供应量为1。 + +换句话说,NFT是来自Token程序的标准代币,但与你可能认为的“标准代币”不同,它: + +- 有0个小数位,因此不能被分割成部分。 +- 来自供应量为1的代币铸造,这意味着只存在这样一个代币。 +- 来自一个铸币权限被设置为null的代币铸造(以确保供应量永远不会改变)。 +- 拥有一个存储元数据的关联账户。 + +虽然前三点是可以通过SPL代币程序实现的特性,但关联的元数据需要一些额外的功能。 + +通常,NFT的元数据包含链上和链下两部分。请看下面的图表: + +![Screenshot of Metadata](https://www.soldev.app/assets/solana-nft-metaplex-metadata.png) + +- 链上元数据存储在与代币铸造账户关联的账户中。链上元数据包含一个指向链下.json文件的URI字段。 +- 链下元数据的JSON文件存储了指向NFT媒体(图像、视频、3D文件)的链接、NFT可能具有的特征以及额外的元数据(请参见此示例JSON文件)。像Arweave这样的永久数据存储系统通常用于存储NFT元数据的链下组件。 + +**Metaplex简介** + +Metaplex是一个提供一系列工具的组织,如Metaplex SDK,这些工具简化了在Solana区块链上创建和分发NFTs的过程。这些工具适用于广泛的使用场景,使您可以轻松管理创建和铸造NFT集合的整个过程。 + +更具体地说,Metaplex SDK旨在帮助开发者利用Metaplex提供的链上工具。它提供了一个用户友好的API,专注于流行的使用场景,并允许轻松与第三方插件集成。要了解更多关于Metaplex SDK的功能,您可以参考其README文件。 + +Metaplex提供的一个基本程序是代币元数据程序。代币元数据程序标准化了将元数据附加到SPL代币的过程。使用Metaplex创建NFT时,代币元数据程序会使用代币铸造作为种子,通过程序派生地址(PDA)创建一个元数据账户。这使得任何NFT的元数据账户都可以使用代币铸造的地址确定性地定位。要了解更多关于代币元数据程序的信息,您可以参考Metaplex文档。 + +在接下来的章节中,我们将介绍使用Metaplex SDK准备资产、创建NFTs、更新NFTs以及将NFT与更广泛的集合关联的基础知识。 + +**Metaplex实例** + +Metaplex实例作为访问Metaplex SDK API的入口点。这个实例接受一个用于与集群通信的连接。此外,开发者可以通过指定“身份驱动”和“存储驱动”来定制SDK的交互。 + +身份驱动实质上是一个可以用来签署交易的密钥对,创建NFT时需要此操作。存储驱动用于指定您希望用于上传资产的存储服务。默认选项是bundlrStorage驱动,它将资产上传到Arweave,这是一个永久且去中心化的存储服务。 + +下面是一个如何为devnet设置Metaplex实例的示例。 + +``` +import { + Metaplex, + keypairIdentity, + bundlrStorage, +} from "@metaplex-foundation/js"; +import { Connection, clusterApiUrl, Keypair } from "@solana/web3.js"; + +const connection = new Connection(clusterApiUrl("devnet")); +const wallet = Keypair.generate(); + +const metaplex = Metaplex.make(connection) + .use(keypairIdentity(wallet)) + .use( + bundlrStorage({ + address: "https://devnet.bundlr.network", + providerUrl: "https://api.devnet.solana.com", + timeout: 60000, + }), + ); +``` + +**上传资产** + +在创建NFT之前,您需要准备并上传计划与NFT关联的任何资产。虽然这不必是一张图片,但大多数NFT都有一张关联的图片。 + +准备和上传图片包括将图片转换为缓冲区,使用toMetaplexFile函数将其转换为Metaplex格式,最后将其上传到指定的存储驱动。 + +Metaplex SDK支持从您本地计算机上存在的文件或通过浏览器上传的用户文件创建新的Metaplex文件。您可以通过使用fs.readFileSync读取图片文件,然后使用toMetaplexFile将其转换为Metaplex文件来完成前者。最后,使用您的Metaplex实例调用storage().upload(file)来上传文件。该函数的返回值将是存储图像的URI。 + +``` +const buffer = fs.readFileSync("/path/to/image.png"); +const file = toMetaplexFile(buffer, "image.png"); + +const imageUri = await metaplex.storage().upload(file); +``` + +**上传元数据** + +上传图片后,是时候使用nfts().uploadMetadata函数上传链下JSON元数据了。这将返回一个存储JSON元数据的URI。 + +记住,元数据的链下部分包括诸如图像URI以及额外信息,如NFT的名称和描述。虽然您在理论上可以在此JSON对象中包含您想要的任何内容,但在大多数情况下,您应该遵循NFT标准,以确保与钱包、程序和应用程序的兼容性。 + +要创建元数据,请使用SDK提供的uploadMetadata方法。此方法接受一个元数据对象,并返回指向上传元数据的URI。 + +``` +const { uri } = await metaplex.nfts().uploadMetadata({ + name: "My NFT", + description: "My description", + image: imageUri, +}); +``` + +**创建NFT** + +上传了NFT的元数据后,您终于可以在网络上创建NFT了。Metaplex SDK的create方法允许您使用最少的配置创建一个新的NFT。此方法将为您处理铸币账户、代币账户、元数据账户和主版账户的创建。提供给此方法的数据将代表NFT元数据的链上部分。您可以探索SDK,查看可以选择性地提供给此方法的所有其他输入。 + +``` +const { nft } = await metaplex.nfts().create( + { + uri: uri, + name: "My NFT", + sellerFeeBasisPoints: 0, + }, + { commitment: "finalized" }, +); +``` + +此方法返回一个包含新创建的NFT信息的对象。默认情况下,SDK将isMutable属性设置为true,允许对NFT的元数据进行更新。然而,您可以选择将isMutable设置为false,使NFT的元数据不可变。 + +**更新NFT** + +如果您将isMutable保留为true,您可能最终会有理由更新NFT的元数据。SDK的update方法允许您更新NFT元数据的链上和链下部分。要更新链下元数据,您需要重复前面步骤中概述的上传新图像和元数据URI的步骤,然后将新的元数据URI提供给此方法。这将改变链上元数据指向的URI,有效地更新链下元数据。 + +``` +const nft = await metaplex.nfts().findByMint({ mintAddress }); + +const { response } = await metaplex.nfts().update( + { + nftOrSft: nft, + name: "Updated Name", + uri: uri, + sellerFeeBasisPoints: 100, + }, + { commitment: "finalized" }, +); +``` + +请注意,您在调用update时未包含的任何字段都将保持不变,这是设计如此。 + +**将NFT添加到集合** + +认证集合是个别NFT可以属于的NFT。想想像Solana Monkey Business这样的大型NFT集合。如果您查看个别NFT的元数据,您会看到一个collection字段,其中的键指向认证集合NFT。简单地说,属于集合的NFT与代表该集合本身的另一个NFT相关联。 + +为了将NFT添加到集合,首先必须创建集合NFT。过程与之前相同,除了您将在NFT元数据中包含一个额外字段:isCollection。这个字段告诉代币程序,这个NFT是一个集合NFT。 + +``` +const { collectionNft } = await metaplex.nfts().create( + { + uri: uri, + name: "My NFT Collection", + sellerFeeBasisPoints: 0, + isCollection: true + }, + { commitment: "finalized" }, +); +``` + +然后,您将集合的铸币地址列为新Nft的collection字段中的引用。 + +``` +const { nft } = await metaplex.nfts().create( + { + uri: uri, + name: "My NFT", + sellerFeeBasisPoints: 0, + collection: collectionNft.mintAddress + }, + { commitment: "finalized" }, +); +``` + +当您查看新创建的NFT的元数据时,您现在应该看到一个collection字段,如下所示: + +``` +"collection":{ + "verified": false, + "key": "SMBH3wF6baUj6JWtzYvqcKuj2XCKWDqQxzspY12xPND" +} +``` + +您需要做的最后一件事是验证NFT。这实际上只是将上面的verified字段切换为true,但这非常重要。这是让消费程序和应用知道您的NFT实际上是集合的一部分的方法。您可以使用verifyCollection函数来做到这一点: + +``` +await metaplex.nfts().verifyCollection({ + mintAddress: nft.address, + collectionMintAddress: collectionNft.address, + isSizedCollection: true, +}) +``` + +**Candy Machine** + +当创建和分发大量NFT时,Metaplex通过他们的Candy Machine程序和Sugar CLI使其变得容易。 + +Candy Machine实际上是一个铸造和分发程序,帮助推出NFT集合。Sugar是一个命令行界面,帮助您创建candy machine、准备资产,并大规模创建NFTs。上面为创建一个NFT所覆盖的步骤,如果要一次性执行数千个NFT,将会非常繁琐。Candy Machine和Sugar通过提供许多保障措施,解决了这个问题,并帮助确保公平启动。 + +我们不会深入介绍这些工具,但一定要查看Metaplex文档中Candy Machine和Sugar是如何协同工作的。 + +要探索Metaplex提供的全部工具范围,您可以在GitHub上查看Metaplex仓库。 + +**实验** + +在这个实验室中,我们将通过使用Metaplex SDK创建一个NFT的步骤,之后更新NFT的元数据,然后将NFT与一个集合关联。到最后,您将基本了解如何使用Metaplex SDK与Solana上的NFT进行交互。 + +**1.开始** + +首先,从这个仓库的starter分支下载起始代码。 + +该项目包含src目录中的两张图片,我们将用它们来创建NFTs。 + +此外,在index.ts文件中,您会找到以下代码片段,其中包含了我们将要创建和更新的NFT的样本数据。 + +``` +interface NftData { + name: string; + symbol: string; + description: string; + sellerFeeBasisPoints: number; + imageFile: string; +} + +interface CollectionNftData { + name: string + symbol: string + description: string + sellerFeeBasisPoints: number + imageFile: string + isCollection: boolean + collectionAuthority: Signer +} + +// example data for a new NFT +const nftData = { + name: "Name", + symbol: "SYMBOL", + description: "Description", + sellerFeeBasisPoints: 0, + imageFile: "solana.png", +} + +// example data for updating an existing NFT +const updateNftData = { + name: "Update", + symbol: "UPDATE", + description: "Update Description", + sellerFeeBasisPoints: 100, + imageFile: "success.png", +} + +async function main() { + // create a new connection to the cluster's API + const connection = new Connection(clusterApiUrl("devnet")); + + // initialize a keypair for the user + const user = await initializeKeypair(connection); + + console.log("PublicKey:", user.publicKey.toBase58()); +} +``` + +要安装必要的依赖项,请在命令行中运行npm install。 + +接下来,通过运行npm start来执行代码。这将创建一个新的密钥对,将其写入.env文件,并向密钥对空投devnet SOL。 + +``` +Current balance is 0 +Airdropping 1 SOL... +New balance is 1 +PublicKey: GdLEz23xEonLtbmXdoWGStMst6C9o3kBhb7nf7A1Fp6F +Finished successfully +``` + +**2.设置Metaplex** + +在我们开始创建和更新NFT之前,我们需要设置Metaplex实例。用以下内容更新main()函数: + +``` +async function main() { + // create a new connection to the cluster's API + const connection = new Connection(clusterApiUrl("devnet")); + + // initialize a keypair for the user + const user = await initializeKeypair(connection); + + console.log("PublicKey:", user.publicKey.toBase58()); + + // metaplex set up + const metaplex = Metaplex.make(connection) + .use(keypairIdentity(user)) + .use( + bundlrStorage({ + address: "https://devnet.bundlr.network", + providerUrl: "https://api.devnet.solana.com", + timeout: 60000, + }), + ); +} +``` + +**3.uploadMetadata助手函数** + +接下来,让我们创建一个助手函数来处理上传图像和元数据的过程,并返回元数据URI。该函数将以Metaplex实例和NFT数据作为输入,并返回元数据URI作为输出。 + +``` +// helper function to upload image and metadata +async function uploadMetadata( + metaplex: Metaplex, + nftData: NftData, +): Promise { + // file to buffer + const buffer = fs.readFileSync("src/" + nftData.imageFile); + + // buffer to metaplex file + const file = toMetaplexFile(buffer, nftData.imageFile); + + // upload image and get image uri + const imageUri = await metaplex.storage().upload(file); + console.log("image uri:", imageUri); + + // upload metadata and get metadata uri (off chain metadata) + const { uri } = await metaplex.nfts().uploadMetadata({ + name: nftData.name, + symbol: nftData.symbol, + description: nftData.description, + image: imageUri, + }); + + console.log("metadata uri:", uri); + return uri; +} +``` + +该函数将读取图像文件,将其转换为缓冲区,然后上传以获取图像URI。然后它将上传包括名称、符号、描述和图像URI在内的NFT元数据,并获取元数据URI。这个URI是链下元数据。该函数还会记录图像URI和元数据URI以供参考。 + +**4.createNft助手函数** + +接下来,让我们创建一个助手函数来处理创建NFT。该函数接受Metaplex实例、元数据URI和NFT数据作为输入。它使用SDK的create方法创建NFT,传入元数据URI、名称、卖方费用和符号作为参数。 + +``` +// helper function create NFT +async function createNft( + metaplex: Metaplex, + uri: string, + nftData: NftData, +): Promise { + const { nft } = await metaplex.nfts().create( + { + uri: uri, // metadata URI + name: nftData.name, + sellerFeeBasisPoints: nftData.sellerFeeBasisPoints, + symbol: nftData.symbol, + }, + { commitment: "finalized" }, + ); + + console.log( + `Token Mint: https://explorer.solana.com/address/${nft.address.toString()}?cluster=devnet`, + ); + + return nft; +} +``` + +createNft函数记录代币铸造URL并返回一个包含新创建的NFT信息的nft对象。NFT将铸造到用作设置Metaplex实例时身份驱动的用户对应的公钥。 + +**5. 创建NFT** + +现在我们已经设置了Metaplex实例,并创建了上传元数据和创建NFT的助手函数,我们可以通过创建一个NFT来测试这些函数。在main()函数中,调用uploadMetadata函数上传NFT数据并获取元数据的URI。然后,使用createNft函数和元数据URI创建一个NFT。 + +``` +async function main() { + ... + + // upload the NFT data and get the URI for the metadata + const uri = await uploadMetadata(metaplex, nftData) + + // create an NFT using the helper function and the URI from the metadata + const nft = await createNft(metaplex, uri, nftData) +} +``` + +在命令行中运行npm start来执行main函数。您应该看到类似以下的输出: + +``` +Current balance is 1.770520342 +PublicKey: GdLEz23xEonLtbmXdoWGStMst6C9o3kBhb7nf7A1Fp6F +image uri: https://arweave.net/j5HcSX8qttSgJ_ZDLmbuKA7VGUo7ZLX-xODFU4LFYew +metadata uri: https://arweave.net/ac5fwNfRckuVMXiQW_EAHc-xKFCv_9zXJ-1caY08GFE +Token Mint: https://explorer.solana.com/address/QdK4oCUZ1zMroCd4vqndnTH7aPAsr8ApFkVeGYbvsFj?cluster=devnet +Finished successfully +``` + +请随意检查图像和元数据生成的URI,以及通过访问输出中提供的URL在Solana浏览器上查看NFT。 + +**6. updateNftUri 助手函数** 接下来,让我们创建一个助手函数来处理更新现有NFT的URI。该函数将接受Metaplex实例、元数据URI和NFT的铸币地址。它使用SDK的findByMint方法使用铸币地址获取现有的NFT数据,然后使用update方法更新新URI的元数据。最后,它将记录代币铸造URL和交易签名以供参考。 + +``` +// helper function update NFT +async function updateNftUri( + metaplex: Metaplex, + uri: string, + mintAddress: PublicKey, +) { + // fetch NFT data using mint address + const nft = await metaplex.nfts().findByMint({ mintAddress }); + + // update the NFT metadata + const { response } = await metaplex.nfts().update( + { + nftOrSft: nft, + uri: uri, + }, + { commitment: "finalized" }, + ); + + console.log( + `Token Mint: https://explorer.solana.com/address/${nft.address.toString()}?cluster=devnet`, + ); + + console.log( + `Transaction: https://explorer.solana.com/tx/${response.signature}?cluster=devnet`, + ); +} +``` + +**7. 更新NFT** + +要更新现有的NFT,我们首先需要为NFT上传新的元数据并获取新的URI。在main()函数中,再次调用uploadMetadata函数上传更新后的NFT数据并获取元数据的新URI。然后,我们可以使用updateNftUri助手函数,传入Metaplex实例、元数据的新URI和NFT的铸币地址。nft.address来自createNft函数的输出。 + +``` +async function main() { + ... + + // upload updated NFT data and get the new URI for the metadata + const updatedUri = await uploadMetadata(metaplex, updateNftData) + + // update the NFT using the helper function and the new URI from the metadata + await updateNftUri(metaplex, updatedUri, nft.address) +} +``` + + + +在命令行中运行`npm start`来执行`main`函数。您应该看到类似以下的附加输出: + +您还可以通过从`.env`文件导入`PRIVATE_KEY`,在Phantom钱包中查看NFTs。 + +**8. 创建NFT集合** + +太棒了,现在您知道如何在Solana区块链上创建和更新单个NFT!但是,如何将其添加到集合中呢? + +首先,让我们创建一个名为`createCollectionNft`的助手函数。请注意,它与`createNft`非常相似,但确保`isCollection`设置为true,并且数据符合集合的要求。 + +``` +async function createCollectionNft( + metaplex: Metaplex, + uri: string, + data: CollectionNftData +): Promise { + const { nft } = await metaplex.nfts().create( + { + uri: uri, + name: data.name, + sellerFeeBasisPoints: data.sellerFeeBasisPoints, + symbol: data.symbol, + isCollection: true, + }, + { commitment: "finalized" } + ) + + console.log( + `Collection Mint: https://explorer.solana.com/address/${nft.address.toString()}?cluster=devnet` + ) + + return nft +} +``` + +接下来,我们需要为集合创建链下数据。在现有的`createNft`调用之前,在`main`中添加以下`collectionNftData`: + +``` +const collectionNftData = { + name: "TestCollectionNFT", + symbol: "TEST", + description: "Test Description Collection", + sellerFeeBasisPoints: 100, + imageFile: "success.png", + isCollection: true, + collectionAuthority: user, +} +``` + +现在,让我们调用uploadMetadata与collectionNftData,然后调用createCollectionNft。同样,在创建NFT的代码之前执行此操作。 + +``` +async function main() { + ... + + // upload data for the collection NFT and get the URI for the metadata + const collectionUri = await uploadMetadata(metaplex, collectionNftData) + + // create a collection NFT using the helper function and the URI from the metadata + const collectionNft = await createCollectionNft( + metaplex, + collectionUri, + collectionNftData + ) +} +``` + +这将返回我们集合的铸币地址,以便我们使用它将NFT分配到集合中。 + +**9. 将NFT分配到集合** + +现在我们有了一个集合,让我们更改现有的代码,以便新创建的NFT被添加到集合中。首先,让我们修改createNft函数,以便nfts().create调用包括collection字段。然后,添加调用verifyCollection的代码,使链上元数据中的verified字段设置为true。这是消费程序和应用程序可以确信NFT实际上属于该集合的方式。 + +``` +async function createNft( + metaplex: Metaplex, + uri: string, + nftData: NftData +): Promise { + const { nft } = await metaplex.nfts().create( + { + uri: uri, // metadata URI + name: nftData.name, + sellerFeeBasisPoints: nftData.sellerFeeBasisPoints, + symbol: nftData.symbol, + }, + { commitment: "finalized" } + ) + + console.log( + `Token Mint: https://explorer.solana.com/address/${nft.address.toString()}? cluster=devnet` + ) + + //this is what verifies our collection as a Certified Collection + await metaplex.nfts().verifyCollection({ + mintAddress: nft.mint.address, + collectionMintAddress: collectionMint, + isSizedCollection: true, + }) + + return nft +} +``` + +现在,运行`npm start`,瞧!如果您按照新的nft链接并查看元数据选项卡,您将看到一个带有您集合铸币地址的`collection`字段。 + +祝贺您!您已经成功学会了如何使用Metaplex SDK创建、更新和验证作为集合一部分的NFT。这是您需要的一切,以便为几乎任何用例构建自己的集合。您可以构建一个TicketMaster竞争者,重塑Costco的会员计划,甚至数字化您学校的学生ID系统。可能性是无限的! + +如果您想查看最终解决方案代码,可以在同一仓库的[解决方案](https://github.com/Unboxed-Software/solana-metaplex/tree/solution)分支上找到。 + +# 挑战 + +为了加深您对 Metaplex 工具的理解,请深入研究 Metaplex 文档并熟悉 Metaplex 提供的各种程序和工具。例如,您可以深入了解 Candy Machine 程序以了解其功能。 + +一旦您了解了糖果机程序的工作原理,就可以使用 Sugar CLI 来测试您的知识,为您自己的收藏创建糖果机。这种实践经验不仅会增强您对这些工具的理解,还会增强您对未来有效使用它们的能力的信心。 + +玩得开心!这将是您第一个独立创作的NFT收藏!至此,您将完成第 2 单元。希望您能感受到这个过程!请随时分享一些即时反馈,以便我们继续改进课程! + + + From d92192c327213bce953d21b87bfa1f195385afb2 Mon Sep 17 00:00:00 2001 From: Yanbo Wang Date: Mon, 13 May 2024 09:39:23 +0800 Subject: [PATCH 3/5] Update README.md --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 57e07cd39..4a16a2fa7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,14 @@ Rust 速览、Solana Local Node、Solana CLI Tools、Network Wallet 交互 - **环境安装** - **Cargo 使用配置** - **Rust 语法基础** - - 变量(可变量、类型、复合类型、引用、集合、结构体、枚举) + - 变量 + - 可变量 + - 类型 + - 复合类型 + - 引用 + - 集合 + - 结构体 + - 枚举 - 函数、闭包 - 泛型 - 控制流 From 0d225037586c4c9ed69a67e6372efbee92c99b24 Mon Sep 17 00:00:00 2001 From: Yanbo Wang Date: Mon, 13 May 2024 09:41:20 +0800 Subject: [PATCH 4/5] Update README.md --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4a16a2fa7..dc308fdb3 100644 --- a/README.md +++ b/README.md @@ -126,13 +126,15 @@ Rust 速览、Solana Local Node、Solana CLI Tools、Network Wallet 交互 ### 4. NFT -[https://creatorsdao.github.io/solana-co-learn/Solana-Co-Learn/module2/nfts-and-minting-with-metaplex/](https://creatorsdao.github.io/solana-co-learn/Solana-Co-Learn/module2/nfts-and-minting-with-metaplex/) +- Metaplex SDK 发布 NFT -[https://creatorsdao.github.io/solana-co-learn/Solana-Co-Learn/module2/displayings-nfts-in-a-ui/displaying-nfts/](https://creatorsdao.github.io/solana-co-learn/Solana-Co-Learn/module2/displayings-nfts-in-a-ui/displaying-nfts/) +[NFTs & Minting with Metaplex](https://creatorsdao.github.io/solana-co-learn/Solana-Co-Learn/module2/nfts-and-minting-with-metaplex/) -- Metaplex SDK 发布 NFT - 获取不同的NFT +[displaying-nfts](https://creatorsdao.github.io/solana-co-learn/Solana-Co-Learn/module2/displayings-nfts-in-a-ui/displaying-nfts/) + + # Links * [往期黑客松项目参考](https://hyperdrive.solana.com/projects/explore) From ed4ff6279dbac0b4d846edafb553b91540d81273 Mon Sep 17 00:00:00 2001 From: Yanbo Wang Date: Mon, 13 May 2024 10:35:01 +0800 Subject: [PATCH 5/5] Create SECURITY.md --- SECURITY.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..034e84803 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Supported Versions + +Use this section to tell people about which versions of your project are +currently being supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| 5.1.x | :white_check_mark: | +| 5.0.x | :x: | +| 4.0.x | :white_check_mark: | +| < 4.0 | :x: | + +## Reporting a Vulnerability + +Use this section to tell people how to report a vulnerability. + +Tell them where to go, how often they can expect to get an update on a +reported vulnerability, what to expect if the vulnerability is accepted or +declined, etc.