链下计算的 Mina 智能合约 zkApps 快速入门

  • 线结边
  • 更新于 2024-09-05 10:28
  • 阅读 978

本篇尝试从 Token 合约的角度学习 Mina 的 zkApps 的基本编程方法,从中了解了 Mina 合约不需要部署到链上,只需要生成链下证明,然后更新链上的账号,这种方式解锁了智能合约新的可能性。

一般的区块链项目,例如以太坊,在区块生成后会同步到所有的节点,节点接收区块后需要验证区块和执行区块里的智能合约调用,每一个节点都需要重复这个工作,虽然浪费计算资源,但这可能去中心化需要付出的代价。

不能说这样不对,但这制约了区块链的性能,因为并不是每个节点都会配置高性能计算机,节点可能是一台老旧的家用电脑,所以要运行这样的区块链在设计时就必须限制区块的大小和计算时间,限制区块链的能力。

但 zk 的出现改变了这种情况,zk 允许智能合约在链下运行,仅把结果和证明发送到链上验证和记录,这意味着不论智能合约有多复杂,计算量有多大,对于节点来说都是相同的验证时间。

而 Mina 就解决了这样一个问题,它是一个纯 zk 实现的项目,不仅仅是智能合约的实现用 zk,公链的验证也用 zk 实现。

Mina 的智能合约叫 zkApps,它和以太坊智能合约对比如下:

image.png

为什么使用 zkApps(早期曾用Snapps)这个名称替代智能合约呢?因为呀,它是在链下执行的,所以其实它是和前端 UI 或者客户端是融合编程的,思路和智能合约差别很大,用 zkApps 这个名称可能更符合它的特征。

本篇不想讨论 zkApps 能做到什么以太坊智能合约不能实现的东西,而是想探讨 zkApps 如何做出以太坊智能合约能做的东西。

基本语法

Mina 的 zkApps 使用 TypeScript 语言编写,对于做前端的开发者来说要入门那是极容易,不过要写出安全的智能合约需要注意很多细节。

首先我们来了解一下它的基本语法,一些新手开发者可能会有疑问:TypeScript 我都会了,还学什么语法?因为呀,如果你用 TypeScript 原生的那些方法来写,是没有带约束的,是不能用 zk 证明计算结果的,是不安全的。

这里需要用到一个库:o1js,它是官方开发的一个框架,也可以说是一门 zk 专用语言,但我觉得可以不用去强调这个是什么,只需要知道开发时需要引入它。

import { Field, Poseidon, ... } from 'o1js';

完整的组件可以参考:https://docs.minaprotocol.com/zkapps/o1js-reference

  1. 常见数据类型
new Bool(x);   // accepts true or false
new Field(x);  // accepts an integer, or a numeric string if you want to represent a number greater than JavaScript can represent but within the max value that a field can store.
new UInt64(x); // accepts a Field - useful for constraining numbers to 64 bits
new UInt32(x); // accepts a Field - useful for constraining numbers to 32 bits

PrivateKey, PublicKey, Signature; // useful for accounts and signing
new Group(x, y); // a point on our elliptic curve, accepts two Fields/numbers/strings
Scalar; // the corresponding scalar field (different than Field)

CircuitString.from('some string'); // string of max length 128
  1. 常见方法
let x = new Field(4); // x = 4
x = x.add(3); // x = 7
x = x.sub(1); // x = 6
x = x.mul(3); // x = 18
x = x.div(2); // x = 9
x = x.square(); // x = 81
x = x.sqrt(); // x = -9

let hash = Poseidon.hash([x]); // takes array of Fields, returns Field

let privKey = PrivateKey.random(); // create a private key
let pubKey = PublicKey.fromPrivateKey(privKey); // derive public key
let msg = [hash];
let sig = Signature.create(privKey, msg); // sign a message
sig.verify(pubKey, msg); // Bool(true)
  1. 常用判断语句
let b = x.equals(8); // b = Bool(false)
b = x.greaterThan(8); // b = Bool(true)
b = b.not().or(b).and(b); // b = Bool(true)
b.toBoolean(); // true
  1. 条件语句

使用Circuit.if()方法,它是一个三元运算符:

const x = Circuit.if(new Bool(foo), a, b); // behaves like `foo ? a : b`

注意,不支持传统的条件语句:

// this will NOT work
if (foo) {
  x.assertEquals(y);
}

这是因为这些语句最终都是要转化成电路(方程)来执行,对电路来说是不存在跳转的。

Mina 账号

在 Mina 区块链上,每个 zkApp 账户提供 8 个字段,每个字段约 32 字节,用于存储链上状态。

Mina 提供了一系列的账号操作方法,详细参考:https://docs.minaprotocol.com/zkapps/writing-a-zkapp/feature-overview/on-chain-values

我们需要知道的是,Mina 的所有状态数据都是保存在账号里,例如:token 的 symbol,balance等。

代币发行

Token 和 DeFi 是每个智能合约平台首先需要实现的。

Mina 在技术栈的底层支持自定义代币功能。Mina 对待自定义代币的方式与原生 MINA 代币几乎相同。这种方法具有以下好处:

  • 作为开发人员,你不必管理那么多合约模板。
  • 开发人员不需要自己跟踪账户和余额。
  • 它更安全,因为由不正确的配置和部署导致的漏洞更少。

Mina 上的每个帐户都可以拥有与之关联的代币。使用 zkApps,您可以构建与代币交互的智能合约,例如将一个代币换成另一个代币或存入 MINA 代币。

官方已发布同质化 token 标准:https://minaprotocol.com/blog/fungible-token-standard

简单做个测试:

$ git clone https://github.com/MinaFoundation/mina-fungible-token.git
$ nvm use 22 #低版本可能会报错
$ npm i
$ npm run task examples/concurrent-transfer.eg.ts
alexa B62qo1nHUrAGvB6KKkFBj7npiBX2qV92QNA2SQgeYjpm5F8ReoTNNRp EKFTBoMqy7jxkee3bgGiSqXxS2HT6pZpT8CiyRJ3aCGPeYZHyXAZ
billy B62qnCVsRSLTXNpkxipj5VUpTLySyj8qBxgTRzAyyAze5JbQjAYdKHs EKEWLmkjEzfxhehbJVySwuDD21B8ESTdmQwtmp614GaD1NvXqgDE
jackie B62qizRbCYJxzgpvrz48XP4aF4Cyr1H2qdzVKqRs1bkpcP6RFf1d5vn EKFJF39BJTY8LdBbRSMNy3YUMkRJfvMA3ZCwZUZSFjX7XNb8wAE4
contract B62qoZvgRATeVuobfmT6ZasWomqiJw1HNzmMcTF97a9SJJW576P6VFA EKDu7L7w9jNgGiQ8RM118JoswNQ6a8HTQwoLxbwwbnjptJRFgDXM
admin B62qpEZUtNoXf2WwYALZHvZsiCHHicRgBuXwL4MJc56QYogeiwdVwgT EKF16FCQif1yCsPdVA8xPbyNNXXt77jh869Ps3T7xnRia8Bi7vcB
feepayer B62qmVz7pPiLXPvz2nPkuK3K5akjrePAVtdBVMfeyixrccgqKTQte8K EKE5nJtRFYVWqrCfdpqJqKKdt2Sskf5Co2q8CWJKEGSg71ZXzES7
Deploying token contract.
Deploy tx: 5JuWxx2yFHQescGqgkzHDBGAzEMYDyHDS9qQjh34V1RQc6FiNiRX
Minting new tokens to Alexa.
Mint tx: 5JtsfACmD3zt3awvKarKjQ9MomjKsLQYoph14YfGUQ8gtnroCSsf
[1] Transferring tokens from Alexa to Billy
Transfer 1 tx: 5Jv9C4iHXb3Lftocojb6bGmT4Auca7PX9pRUfP4d1EwFVKMSyYuQ
Transferring from Alexa and Billy to Jackie (concurrently)
feepayer nonce: 324
Transfer tx: 5Juy5QLRGv6jM7GC8kzsGKsXpa2LXuDzdjgKJH4FuBBtqqKv9qUW
feepayer nonce: 325
Transfer tx: 5JtZrovH2bPBCpXCqeVMeToY8M7i6xA7G9BPj91dFnuFjVLLzgsj
feepayer nonce: 326
Transfer tx: 5JupKSj6eg4DTx7Psb679mMpoZyxzLeXsZCJpp4wbQyV7RBP6wfP
feepayer nonce: 327
Transfer tx: 5JthGtLy3pivxG2jj5fee9d5NbzWMc4V8qCck94vgoAwo6j6NMPf
feepayer nonce: 328
Transfer tx: 5JuRQeS4uwGTCYKpQ3X87tBrVz2HG1XS57fwwV4XBsu4xv7p8TPF
feepayer nonce: 329
Transfer tx: 5JuVCysbHnLiw75L61Z2MEkXnqtUoAs3FA3LBVvqKfVBmn1qH5Tn

这个示例里在 devnet 测试网 deploy 了一个 token 合约(注意:Mina 的合约代码是不需要部署到链上的,这里只是调用了合约一个 deploy 函数,设置 token 的信息),给 alexa mint 了 100 个代币,然后就是一系列 transfer 转账。如果要在主网发行代币,只需要修改源代码里面的 RPC 和私钥信息,十分简单顺畅,不足点是主网的打包时间实在太长了,这点希望在未来的升级中能进行优化。

网络配置可以参考:

Chain Endpoint
Mainnet GraphQL RPC https://api.minascan.io/node/mainnet/v1/graphql
Mainnet Archive GraphQL API https://api.minascan.io/archive/mainnet/v1/graphql
Devnet GraphQL RPC https://api.minascan.io/node/devnet/v1/graphql
Devnet Archive GraphQL API https://api.minascan.io/archive/devnet/v1/graphql

代币合约解读

代码仓库:https://github.com/MinaFoundation/mina-fungible-token

import {
  AccountUpdate,
  AccountUpdateForest,
  assert,
  Bool,
  DeployArgs,
  Field,
  Int64,
  method,
  Permissions,
  Provable,
  PublicKey,
  State,
  state,
  Struct,
  TokenContractV2,
  Types,
  UInt64,
  UInt8,
  VerificationKey,
} from "o1js"
import { FungibleTokenAdmin, FungibleTokenAdminBase } from "./FungibleTokenAdmin.js"

interface FungibleTokenDeployProps extends Exclude<DeployArgs, undefined> {
  /** The token symbol. */
  symbol: string
  /** A source code reference, which is placed within the `zkappUri` of the contract account.
   * Typically a link to a file on github. */
  src: string
}

export const FungibleTokenErrors = {
  noAdminKey: "could not fetch admin contract key",
  noPermissionToChangeAdmin: "Not allowed to change admin contract",
  tokenPaused: "Token is currently paused",
  noPermissionToMint: "Not allowed to mint tokens",
  noPermissionToPause: "Not allowed to pause token",
  noPermissionToResume: "Not allowed to resume token",
  noTransferFromCirculation: "Can't transfer to/from the circulation account",
  noPermissionChangeAllowed: "Can't change permissions for access or receive on token accounts",
  flashMinting:
    "Flash-minting or unbalanced transaction detected. Please make sure that your transaction is balanced, and that your `AccountUpdate`s are ordered properly, so that tokens are not received before they are sent.",
  unbalancedTransaction: "Transaction is unbalanced",
}

export class FungibleToken extends TokenContractV2 {
  @state(UInt8)
  decimals = State<UInt8>()
  @state(PublicKey)
  admin = State<PublicKey>()
  @state(Bool)
  paused = State<Bool>()

  // This defines the type of the contract that is used to control access to administrative actions.
  // If you want to have a custom contract, overwrite this by setting FungibleToken.AdminContract to
  // your own implementation of FungibleTokenAdminBase.
  static AdminContract: new(...args: any) => FungibleTokenAdminBase = FungibleTokenAdmin

  readonly events = {
    SetAdmin: SetAdminEvent,
    Pause: PauseEvent,
    Mint: MintEvent,
    Burn: BurnEvent,
    BalanceChange: BalanceChangeEvent,
  }

  async deploy(props: FungibleTokenDeployProps) {
    await super.deploy(props)
    this.paused.set(Bool(true))
    this.account.zkappUri.set(props.src)
    this.account.tokenSymbol.set(props.symbol)

    this.account.permissions.set({
      ...Permissions.default(),
      setVerificationKey: Permissions.VerificationKey.impossibleDuringCurrentVersion(),
      setPermissions: Permissions.impossible(),
      access: Permissions.proof(),
    })
  }

  /** Update the verification key.
   * Note that because we have set the permissions for setting the verification key to `impossibleDuringCurrentVersion()`, this will only be possible in case of a protocol update that requires an update.
   */
  @method
  async updateVerificationKey(vk: VerificationKey) {
    this.account.verificationKey.set(vk)
  }

  /** Initializes the account for tracking total circulation.
   * @argument {PublicKey} admin - public key where the admin contract is deployed
   * @argument {UInt8} decimals - number of decimals for the token
   * @argument {Bool} startPaused - if set to `Bool(true), the contract will start in a mode where token minting and transfers are paused. This should be used for non-atomic deployments
   */
  @method
  async initialize(
    admin: PublicKey,
    decimals: UInt8,
    startPaused: Bool,
  ) {
    this.account.provedState.requireEquals(Bool(false))

    this.admin.set(admin)
    this.decimals.set(decimals)
    this.paused.set(Bool(false))

    this.paused.set(startPaused)

    const accountUpdate = AccountUpdate.createSigned(this.address, this.deriveTokenId())
    let permissions = Permissions.default()
    // This is necessary in order to allow token holders to burn.
    permissions.send = Permissions.none()
    permissions.setPermissions = Permissions.impossible()
    accountUpdate.account.permissions.set(permissions)
  }

  public async getAdminContract(): Promise<FungibleTokenAdminBase> {
    const admin = await Provable.witnessAsync(PublicKey, async () => {
      let pk = await this.admin.fetch()
      assert(pk !== undefined, FungibleTokenErrors.noAdminKey)
      return pk
    })
    this.admin.requireEquals(admin)
    return (new FungibleToken.AdminContract(admin))
  }

  @method
  async setAdmin(admin: PublicKey) {
    const adminContract = await this.getAdminContract()
    const canChangeAdmin = await adminContract.canChangeAdmin(admin)
    canChangeAdmin.assertTrue(FungibleTokenErrors.noPermissionToChangeAdmin)
    this.admin.set(admin)
    this.emitEvent("SetAdmin", new SetAdminEvent({ adminKey: admin }))
  }

  @method.returns(AccountUpdate)
  async mint(recipient: PublicKey, amount: UInt64): Promise<AccountUpdate> {
    this.paused.getAndRequireEquals().assertFalse(FungibleTokenErrors.tokenPaused)
    const accountUpdate = this.internal.mint({ address: recipient, amount })
    const adminContract = await this.getAdminContract()
    const canMint = await adminContract.canMint(accountUpdate)
    canMint.assertTrue(FungibleTokenErrors.noPermissionToMint)
    recipient.equals(this.address).assertFalse(
      FungibleTokenErrors.noTransferFromCirculation,
    )
    this.approve(accountUpdate)
    this.emitEvent("Mint", new MintEvent({ recipient, amount }))
    const circulationUpdate = AccountUpdate.create(this.address, this.deriveTokenId())
    circulationUpdate.balanceChange = Int64.fromUnsigned(amount)
    return accountUpdate
  }

  @method.returns(AccountUpdate)
  async burn(from: PublicKey, amount: UInt64): Promise<AccountUpdate> {
    this.paused.getAndRequireEquals().assertFalse(FungibleTokenErrors.tokenPaused)
    const accountUpdate = this.internal.burn({ address: from, amount })
    const circulationUpdate = AccountUpdate.create(this.address, this.deriveTokenId())
    from.equals(this.address).assertFalse(
      FungibleTokenErrors.noTransferFromCirculation,
    )
    circulationUpdate.balanceChange = Int64.fromUnsigned(amount).negV2()
    this.emitEvent("Burn", new BurnEvent({ from, amount }))
    return accountUpdate
  }

  @method
  async pause() {
    const adminContract = await this.getAdminContract()
    const canPause = await adminContract.canPause()
    canPause.assertTrue(FungibleTokenErrors.noPermissionToPause)
    this.paused.set(Bool(true))
    this.emitEvent("Pause", new PauseEvent({ isPaused: Bool(true) }))
  }

  @method
  async resume() {
    const adminContract = await this.getAdminContract()
    const canResume = await adminContract.canResume()
    canResume.assertTrue(FungibleTokenErrors.noPermissionToResume)
    this.paused.set(Bool(false))
    this.emitEvent("Pause", new PauseEvent({ isPaused: Bool(false) }))
  }

  @method
  async transfer(from: PublicKey, to: PublicKey, amount: UInt64) {
    this.paused.getAndRequireEquals().assertFalse(FungibleTokenErrors.tokenPaused)
    from.equals(this.address).assertFalse(
      FungibleTokenErrors.noTransferFromCirculation,
    )
    to.equals(this.address).assertFalse(
      FungibleTokenErrors.noTransferFromCirculation,
    )
    this.internal.send({ from, to, amount })
  }

  private checkPermissionsUpdate(update: AccountUpdate) {
    let permissions = update.update.permissions

    let { access, receive } = permissions.value
    let accessIsNone = Provable.equal(Types.AuthRequired, access, Permissions.none())
    let receiveIsNone = Provable.equal(Types.AuthRequired, receive, Permissions.none())
    let updateAllowed = accessIsNone.and(receiveIsNone)

    assert(
      updateAllowed.or(permissions.isSome.not()),
      FungibleTokenErrors.noPermissionChangeAllowed,
    )
  }

  /** Approve `AccountUpdate`s that have been created outside of the token contract.
   *
   * @argument {AccountUpdateForest} updates - The `AccountUpdate`s to approve. Note that the forest size is limited by the base token contract, @see TokenContractV2.MAX_ACCOUNT_UPDATES The current limit is 9.
   */
  @method
  async approveBase(updates: AccountUpdateForest): Promise<void> {
    this.paused.getAndRequireEquals().assertFalse(FungibleTokenErrors.tokenPaused)
    let totalBalance = Int64.from(0)
    this.forEachUpdate(updates, (update, usesToken) => {
      // Make sure that the account permissions are not changed
      this.checkPermissionsUpdate(update)
      this.emitEventIf(
        usesToken,
        "BalanceChange",
        new BalanceChangeEvent({ address: update.publicKey, amount: update.balanceChange }),
      )
      // Don't allow transfers to/from the account that's tracking circulation
      update.publicKey.equals(this.address).and(usesToken).assertFalse(
        FungibleTokenErrors.noTransferFromCirculation,
      )
      totalBalance = Provable.if(usesToken, totalBalance.add(update.balanceChange), totalBalance)
      totalBalance.isPositiveV2().assertFalse(
        FungibleTokenErrors.flashMinting,
      )
    })
    totalBalance.assertEquals(Int64.zero, FungibleTokenErrors.unbalancedTransaction)
  }

  @method.returns(UInt64)
  async getBalanceOf(address: PublicKey): Promise<UInt64> {
    const account = AccountUpdate.create(address, this.deriveTokenId()).account
    const balance = account.balance.get()
    account.balance.requireEquals(balance)
    return balance
  }

  /** Reports the current circulating supply
   * This does take into account currently unreduced actions.
   */
  async getCirculating(): Promise<UInt64> {
    let circulating = await this.getBalanceOf(this.address)
    return circulating
  }

  @method.returns(UInt8)
  async getDecimals(): Promise<UInt8> {
    return this.decimals.getAndRequireEquals()
  }
}

export class SetAdminEvent extends Struct({
  adminKey: PublicKey,
}) {}

export class PauseEvent extends Struct({
  isPaused: Bool,
}) {}

export class MintEvent extends Struct({
  recipient: PublicKey,
  amount: UInt64,
}) {}

export class BurnEvent extends Struct({
  from: PublicKey,
  amount: UInt64,
}) {}

export class BalanceChangeEvent extends Struct({
  address: PublicKey,
  amount: Int64,
}) {}

这段代码定义了一个名为 FungibleToken 的可替代代币合约类,它继承自 TokenContractV2。以下是主要功能和组件的解释:

  1. 导入:代码开始导入了必要的模块和类,包括 o1js 库中的各种类型和工具。
  2. 接口和错误定义:
    • FungibleTokenDeployProps 接口定义了部署代币时需要的属性。
    • FungibleTokenErrors 对象定义了各种错误消息。
  3. FungibleToken 类:
    • 状态变量:包括小数位数、管理员公钥和暂停状态。
    • 事件定义:如设置管理员、暂停、铸造、销毁和余额变更等事件。
  4. 主要方法:
    • deploy:部署合约,设置初始状态和权限。
    • initialize:初始化代币,设置管理员、小数位数和初始暂停状态。
    • setAdmin:更改管理员。
    • mint:铸造新代币。
    • burn:销毁代币。
    • pause 和 resume:暂停和恢复代币交易。
    • transfer:在账户间转移代币。
    • approveBase:批准外部创建的账户更新。
    • getBalanceOf:查询账户余额。
    • getCirculating:获取当前流通量。
  5. 辅助方法和检查:
    • getAdminContract:获取管理员合约。
    • checkPermissionsUpdate:检查权限更新。
  6. 事件类:定义了各种事件结构,如 SetAdminEventPauseEvent 等。

这个合约实现了一个功能完善的可替代代币,包括铸造、销毁、转账、暂停等功能,并通过管理员合约来控制关键操作的权限。

总结

本篇尝试从 Token 合约的角度学习 Mina 的 zkApps 的基本编程方法,从中了解了 Mina 合约不需要部署到链上,只需要生成链下证明,然后更新链上的账号,这种方式解锁了智能合约新的可能性。

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

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