本文简单介绍了 Mina 只能合约, 组合zkapp, 更新zkapp, 自定义数据类型(Provable type).
你可以通过扩展基类SmartContract
来编写智能合约, 示例如下:
let zkAppKey = PrivateKey.random();
let zkAppAddress = PublicKey.fromPrivateKey(zkAppKey);
let zkApp = new HelloWorld(zkAppAddress);
在 Mina 上, 普通用户账户和 zkApp 账户之间并没有很大的区别. zkApp 账户具有以下特点:
存储在 zkApp 账户上的验证密钥能够验证由智能合约生成的零知识证明. 对于给定的 zkApp 账户, 该验证密钥存在于链上, 并且 Mina 网络会使用它来验证零知识证明是否满足证明者中定义的所有约束条件. 详见证明者函数和验证密钥.
与智能合约的交互是通过调用其一个或多个“方法”来实现的. 你可以使用@method
装饰器来声明方法, 示例如下:
class HelloWorld extends SmartContract {
@method async myMethod(x: Field) {
x.mul(2).assertEquals(5);
}
}
在方法内部, 你可以使用 o1js 数据类型和方法来自定义逻辑.
从内部来看, 每个@method
都会定义一个 zk-SNARK 电路. 从密码学的角度来说, 一个智能合约就是一组电路的集合, 所有这些电路都会被编译成一个证明者和一个验证密钥. 只有当证明在账户中的验证密钥验证通过时, 网络才会接受该证明. 这种验证要求确保了相同的 zkApp 代码也在终端用户设备上运行, 并且账户更新符合智能合约的规则.
在@method
内部, 有时候情况会稍有不同.
为了构建一个可以被证明的电路, o1js 会调用 SnarkyML, 这是一种用于构建电路以及连接变量和约束的语言. 作为 zkApp 开发者, 你必须使用 o1js 提供的方法, 函数和类型. 普通的 JavaScript 代码不会调用 SnarkyML, 因此无法构建电路.
当SmartContract
被编译成证明者和验证密钥时(Setup), 方法处于这样一种环境中: 方法输入并没有附带任何具体的值(类似于占位符). 相反, 它们就像是数学变量x
、y
、z
一样, 通过运行方法代码来构建诸如x^2 + y^2
这样的抽象计算.
相比之下, 在生成证明时, 所有变量都“附带”了实际的值(密码学家称之为“见证值”).
要记录这些值用于调试, 可以在方法内部使用一个特殊的函数来进行记录:
Provable.log(x);
该API与console.log
类似, 但它会自动以可读的格式处理o1js数据类型的打印. 不过, 在SmartContract
被编译时, Provable.log(x)
函数并不会产生任何效果.
智能合约可以包含链上状态. 使用@state
装饰器将其声明为类的一个属性, 示例如下:
class HelloWorld extends SmartContract {
@state(Field) x = State<Field>();
// ...
}
这里, x
的类型是 Field
. 与方法输入一样, 只有 o1js 结构体才能用作状态变量. 状态最多可以由 8 个 32 字节的字段组成. 这些状态存储在 zkApp 账户上.
有些结构体占用不止一个 Field
. 例如, 一个PublicKey
需要占用这8个字段中的两个.
状态通过State()
函数进行初始化.
方法可以通过使用this.<state>.set()
来修改链上状态, 示例如下:
class HelloWorld extends SmartContract {
@state(Field) x = State<Field>();
@method async setX(x: Field) {
this.x.set(x);
}
}
作为zkApp开发者, 如果你将这个方法添加到你的智能合约中, 就意味着任何人都可以调用这个方法, 将账户上的x
设置为他们想要的任何值.
可以通过this.state_name.getAndRequireEquals()
获取当前的链上状态x
. 之后, 它使用this.state_name.set()
将新状态设置为x + 1
.
当你使用链上值时, 你必须“证明”这个值就是链上的值. 如果它是一个不同的值, 验证就必须失败. 否则, 恶意用户可能会修改 o1js, 使其使用不同于当前链上状态的值--这会破坏 zkApp.
你必须将证明时的x
与验证时的x
关联起来, 使其保持一致. 这是一个“前置条件”, 也就是当验证者(Mina节点)在交易中收到证明时要检查的一个条件
因此, 如果预计会有许多用户同时读取和更新状态, 你在读取链上值时就必须小心谨慎. 在某些情况下这是可行的, 但在某些情况下可能会导致竞态条件或需要采取变通方法. 一种变通方法是使用动作. 详见动作与化简器(Actions and Reducer).
虽然zkApp的状态是“公共的”, 但方法参数是“私有”的.
当调用智能合约方法时, 它所生成的证明会利用零知识来隐藏输入以及计算的细节.
要初始化链上状态, 可以使用init()
方法.
和构造函数一样, init()
是在基类SmartContract
中预先定义好的.
你可以重写这个方法来添加对链上状态的初始化.
buy ticket 期间不更新 state(因为更新 state 可能会造成并发读写问题), 而是 dispatch action:
@method async buyTicket(ticket: Ticket) {
...
// Take ticket price from user
let senderUpdate = AccountUpdate.createSigned(
this.sender.getAndRequireSignatureV2()
);
senderUpdate.send({ to: this, amount: TICKET_PRICE.mul(ticket.amount) });
// Dispatch action and emit event
this.reducer.dispatch(
new LotteryAction({
ticket,
})
);
...
彩票投注结束后再进行 reduce ticket:
async reduceTickets(
roundId: number,
): Promise<TicketReduceProof> {
...
for (let action of actionList) {
processedTicketData.ticketId++;
if (cached) {
console.log(`Cached ticket: <${processedTicketData.ticketId}>`);
ticketMap.set(
Field.from(processedTicketData.ticketId),
action.ticket.hash(),
);
continue;
} else {
console.log(`Process ticket: <${processedTicketData.ticketId}>`);
}
input = new TicketReduceProofPublicInput({
action: action,
ticketWitness: ticketMap.getWitness(
Field(processedTicketData.ticketId),
),
});
curProof = await TicketReduceProgram.addTicket(input, curProof);
ticketMap.set(
Field.from(processedTicketData.ticketId),
action.ticket.hash(),
);
lastReducedTicket++;
break;
}
...
}
zkApp 的一个强大特性是它们可以像以太坊智能合约一样进行组合. 你可以简单地从其他智能合约方法中调用智能合约方法, 示例如下:
class HelloWorld extends SmartContract {
@method async myMethod(otherAddress: PublicKey) {
const calledContract = new OtherContract(otherAddress);
calledContract.otherMethod();
}
}
class OtherContract extends SmartContract {
@method async otherMethod() {}
}
当 zkApp 用户调用HelloWorld.myMethod()
时, o1js 会创建两个独立的证明:
myMethod()
执行情况的证明. OtherContract.otherMethod()
执行情况的独立证明. myMethod()
的证明:
otherMethod()
的函数签名以及该函数调用的任何参数和返回值的适当哈希值. otherMethod()
产生的账户更新中的callData
字段相匹配, 而该账户更新是myMethod()
公共输入的一部分. 因此, 当你调用另一个 zkApp 方法时, 你实际上证明了: “我在这个 zkApp 账户上, 使用这些特定的参数和返回值调用了这个名称的方法. ”
要从方法中返回一个值, 你必须使用method.returns
装饰器明确声明返回类型.
以下是一个返回名为isSuccess
的Bool
类型值的示例:
@method.returns(Bool) async otherMethod(): Promise<Bool> { // annotated return type
// ...
return isSuccess;
}
Mina 协议允许在链上升级验证密钥(更新合约账户里的 Verification Key).
智能合约开发的另一个重要部分是可升级性. 通过使用权限, 你可以使智能合约可升级或不可升级. 在 Mina 上, 当你部署一个智能合约时, 你从合约源代码生成一个验证密钥. 验证密钥和智能合约存储在链上, 并用于验证属于该智能合约的证明. 可以通过修改setVerificationKey
权限来设置智能合约的可升级性.
在其他区块链上的可升级性可能意味着用户认为他们正在使用一个程序, 但由于该程序已被升级, 他们实际上正在使用另一个程序. 具有相同函数签名的两个程序可能具有非常不同的行为, 并导致糟糕或不安全的用户体验. Mina 的执行模型不同. 在 Mina 上, 用户运行自己的程序并将证明上传到区块链仅用于验证. 因此, 升级一个 ZkApp 意味着仅在链上更改验证密钥. 使用较旧的证明函数生成的证明将无效, 用户将需要下载新的证明函数以生成有效的证明.
import { Field, SmartContract, state, State, method } from 'o1js';
export class Add extends SmartContract { @state(Field) num = State<Field>();
init() { super.init(); this.num.set(Field(1)); }
@method async update() { const currentState = this.num.getAndRequireEquals(); const newState = currentState.add(2); this.num.set(newState); } }
2. Upgraded zkapp:
```ts
import { Field, SmartContract, state, State, method } from 'o1js';
export class AddV2 extends SmartContract {
@state(Field) num = State<Field>();
init() {
super.init();
this.num.set(Field(1));
}
@method async update() {
const currentState = this.num.getAndRequireEquals();
const newState = currentState.add(4);
this.num.set(newState);
}
}
const verificationKey = (await AddV2.compile()).verificationKey;
const contractAddress = zkAppKey.toPublicKey();
const upgradeTx = await Mina.transaction({ sender, fee }, async () => { const update = AccountUpdate.createSigned(contractAddress); update.account.verificationKey.set(verificationKey); } ); await upgradeTx.sign([senderKey, zkAppKey]).prove(); await upgradeTx.send();
> 请记住, 升级时状态不会被重置. init也不会再次被调用. 除了验证密钥之外, 已部署合约当前状态中的任何原始值都不会因升级而被编辑.
> 确保避免像下面这个不安全的例子那样重新排序状态变量.
```ts
import { Field, SmartContract, state, State, method } from 'o1js';
export class AddV3Unsafe extends SmartContract {
@state(Field) callCount = State<Field>(); // `num` used to be first!
@state(Field) num = State<Field>();
...
}
智能合约方法的参数可以是任何内置的o1js类型.
你可以使用o1js提供的Struct
函数为你的智能合约创建自定义数据类型:
Struct({ })
的类. { }
对象内部, 定义你想在自定义数据类型中使用的字段. 例如, 你可以创建一个名为Point
的自定义数据类型来表示网格上的二维点. Point
结构体没有实例方法, 仅用于保存关于x
和y
坐标点的信息.
要创建Point
类, 扩展Struct
类, 示例如下:
class Point extends Struct({
x: Field,
y: Field,
}) {}
现在Struct
已经定义好了, 你可以在智能合约中针对任何o1js内置类型使用它.
例如, 以下智能合约将前面定义的Point
结构体用作状态以及方法参数:
export class Grid extends SmartContract {
@state(Point) p = State<Point>();
@method async init() {
this.p.set(new Point({ x: Field(1), y: Field(2) }));
}
@method async move(newPoint: Point) {
const point = this.p.get();
this.p.requireEquals(point);
const newX = point.x.add(newPoint.x);
const newY = point.y.add(newPoint.y);
this.p.set(new Point({ x: newX, y: newY }));
}
}
请注意, 你的Struct
类可以包含o1js内置类型, 如Field
、Bool
、UInt64
等等, 甚至还可以包含你基于Struct
类定义的其他自定义类型.
这种灵活性使得结构体具有很强的可组合性和可复用性.
合约函数内须使用 Provable Array. o1js 提供了可证明数组Provable.Array(type, size)
. 这些数组的大小是固定的, 不能使用像 push 或 pop 这样的方法. 你需要为空元素使用虚拟值, 并处理每个元素以有条件地更新数组.
以下是一个如何更新数组元素的示例:
for (let i = 0; i < ARRAY_SIZE; i++) {
myArray[i] = Provable.if(confition(myArray[i]), newValue, myArray[i])
}
若要为合约函数传递 Array 参数, 可参考下面代码:
import { Field, SmartContract, state, State, method, Provable, Struct } from 'o1js';
const N = 8;
export class ProvableArray extends Struct({
data: Provable.Array(Field, N),
}) {
constructor(data: Field[]) {
if (data.length != N) {
throw new Error(
`Wrong amount of array datas. Got: ${data.length}, expect: ${N}`
);
}
super({ data });
}
}
export class Add extends SmartContract {
@state(Field) num = State<Field>();
init() {
super.init();
this.num.set(Field(1));
}
@method async update3(arr: ProvableArray) {
arr.data[1].assertEquals(Field(0));
}
}
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!