import { Field, ProvablePure } from '../snarky.js'; import { circuitArray, Circuit } from './circuit_value.js'; import { AccountUpdate, TokenId } from './account_update.js'; import { PublicKey } from './signature.js'; import * as Mina from './mina.js'; import { Account, fetchAccount } from './fetch.js'; import { inCheckedComputation, inProver } from './proof_system.js'; import { SmartContract } from './zkapp.js'; // external API export { State, state, declareState }; // internal API export { assertStatePrecondition, cleanStatePrecondition }; /** * Gettable and settable state that can be checked for equality. */ type State = { get(): A; set(a: A): void; fetch(): Promise; assertEquals(a: A): void; assertNothing(): void; }; function State(): State { return createState(); } /** * A decorator to use within a zkapp to indicate what will be stored on-chain. * For example, if you want to store a field element `some_state` in a zkapp, * you can use the following in the declaration of your zkapp: * * ``` * @state(Field) some_state = State(); * ``` * */ function state(stateType: ProvablePure) { return function ( target: SmartContract & { constructor: any }, key: string, _descriptor?: PropertyDescriptor ) { const ZkappClass = target.constructor; if (reservedPropNames.has(key)) { throw Error(`Property name ${key} is reserved.`); } let sc = smartContracts.get(ZkappClass); if (sc === undefined) { sc = { states: [], layout: undefined }; smartContracts.set(ZkappClass, sc); } sc.states.push([key, stateType]); Object.defineProperty(target, key, { get(this) { return this._?.[key]; }, set(this, v: InternalStateType) { if (v._contract !== undefined) throw Error( 'A State should only be assigned once to a SmartContract' ); if (this._?.[key]) throw Error('A @state should only be assigned once'); v._contract = { key, stateType: stateType, instance: this, class: ZkappClass, wasConstrained: false, wasRead: false, cachedVariable: undefined, }; (this._ ??= {})[key] = v; }, }); }; } /** * `declareState` can be used in place of the `@state` decorator to declare on-chain state on a SmartContract. * It should be placed _after_ the class declaration. * Here is an example of declaring a state property `x` of type `Field`. * ```ts * class MyContract extends SmartContract { * x = State(); * // ... * } * declareState(MyContract, { x: Field }); * ``` * * If you're using pure JS, it's _not_ possible to use the built-in class field syntax, * i.e. the following will _not_ work: * * ```js * // THIS IS WRONG IN JS! * class MyContract extends SmartContract { * x = State(); * } * declareState(MyContract, { x: Field }); * ``` * * Instead, add a constructor where you assign the property: * ```js * class MyContract extends SmartContract { * constructor(x) { * super(); * this.x = State(); * } * } * declareState(MyContract, { x: Field }); * ``` */ function declareState( SmartContract: T, states: Record> ) { for (let key in states) { let CircuitValue = states[key]; state(CircuitValue)(SmartContract.prototype, key); } } // metadata defined by @state, which link state to a particular SmartContract type StateAttachedContract = { key: string; stateType: ProvablePure; instance: SmartContract; class: typeof SmartContract; wasRead: boolean; wasConstrained: boolean; cachedVariable?: A; }; type InternalStateType = State & { _contract?: StateAttachedContract }; function createState(): InternalStateType { return { _contract: undefined as StateAttachedContract | undefined, set(state: T) { if (this._contract === undefined) throw Error( 'set can only be called when the State is assigned to a SmartContract @state.' ); let layout = getLayoutPosition(this._contract); let stateAsFields = this._contract.stateType.toFields(state); let accountUpdate = this._contract.instance.self; stateAsFields.forEach((x, i) => { AccountUpdate.setValue( accountUpdate.body.update.appState[layout.offset + i], x ); }); }, assertEquals(state: T) { if (this._contract === undefined) throw Error( 'assertEquals can only be called when the State is assigned to a SmartContract @state.' ); let layout = getLayoutPosition(this._contract); let stateAsFields = this._contract.stateType.toFields(state); let accountUpdate = this._contract.instance.self; stateAsFields.forEach((x, i) => { AccountUpdate.assertEquals( accountUpdate.body.preconditions.account.state[layout.offset + i], x ); }); this._contract.wasConstrained = true; }, assertNothing() { if (this._contract === undefined) throw Error( 'assertNothing can only be called when the State is assigned to a SmartContract @state.' ); this._contract.wasConstrained = true; }, get() { if (this._contract === undefined) throw Error( 'get can only be called when the State is assigned to a SmartContract @state.' ); // inside the circuit, we have to cache variables, so there's only one unique variable per on-chain state. // if we'd return a fresh variable everytime, developers could easily end up linking just *one* of them to the precondition, // while using an unconstrained variable elsewhere, which would create a loophole in the proof. if ( this._contract.cachedVariable !== undefined && // `inCheckedComputation() === true` here always implies being inside a wrapped smart contract method, // which will ensure that the cache is cleaned up before & after each method run. inCheckedComputation() ) { this._contract.wasRead = true; return this._contract.cachedVariable; } let layout = getLayoutPosition(this._contract); let contract = this._contract; let inProver_ = inProver(); let stateFieldsType = circuitArray(Field, layout.length); let stateAsFields = Circuit.witness(stateFieldsType, () => { let account: Account; try { account = Mina.getAccount( contract.instance.address, contract.instance.self.body.tokenId ); } catch (err) { // TODO: there should also be a reasonable error here if (inProver_) { throw err; } throw Error( `${contract.key}.get() failed, either:\n` + `1. We can't find this zkapp account in the ledger\n` + `2. Because the zkapp account was not found in the cache. ` + `Try calling \`await fetchAccount(zkappAddress)\` first.\n` + `If none of these are the case, then please reach out on Discord at #zkapp-developers and/or open an issue to tell us!` ); } if (account.appState === undefined) { // if the account is not a zkapp account, let the default state be all zeroes return Array(layout.length).fill(Field(0)); } else { let stateAsFields: Field[] = []; for (let i = 0; i < layout.length; ++i) { stateAsFields.push(account.appState[layout.offset + i]); } return stateAsFields; } }); let state = this._contract.stateType.fromFields(stateAsFields); this._contract.stateType.check?.(state); this._contract.wasRead = true; this._contract.cachedVariable = state; return state; }, async fetch() { if (this._contract === undefined) throw Error( 'fetch can only be called when the State is assigned to a SmartContract @state.' ); if (Mina.currentTransaction.has()) throw Error( 'fetch is not intended to be called inside a transaction block.' ); let layout = getLayoutPosition(this._contract); let address: PublicKey = this._contract.instance.address; let { account } = await fetchAccount({ publicKey: address, tokenId: TokenId.toBase58(TokenId.default), }); if (account === undefined) return undefined; let stateAsFields: Field[]; if (account.appState === undefined) { stateAsFields = Array(layout.length).fill(Field(0)); } else { stateAsFields = []; for (let i = 0; i < layout.length; i++) { stateAsFields.push(account.appState[layout.offset + i]); } } return this._contract.stateType.fromFields(stateAsFields); }, }; } function getLayoutPosition({ key, class: contractClass, }: StateAttachedContract) { let layout = getLayout(contractClass); let stateLayout = layout.get(key); if (stateLayout === undefined) { throw new Error(`state ${key} not found`); } return stateLayout; } function getLayout(scClass: typeof SmartContract) { let sc = smartContracts.get(scClass); if (sc === undefined) throw Error('bug'); if (sc.layout === undefined) { let layout = new Map(); sc.layout = layout; let offset = 0; sc.states.forEach(([key, stateType]) => { let length = stateType.sizeInFields(); layout.set(key, { offset, length }); offset += length; }); } return sc.layout; } // per-smart contract class context for keeping track of state layout const smartContracts = new WeakMap< typeof SmartContract, { states: [string, ProvablePure][]; layout: Map | undefined; } >(); const reservedPropNames = new Set(['_methods', '_']); function assertStatePrecondition(sc: SmartContract) { try { for (let [key, context] of getStateContexts(sc)) { // check if every state that was read was also contrained if (!context?.wasRead || context.wasConstrained) continue; // we accessed a precondition field but not constrained it explicitly - throw an error let errorMessage = `You used \`this.${key}.get()\` without adding a precondition that links it to the actual on-chain state. Consider adding this line to your code: this.${key}.assertEquals(this.${key}.get());`; throw Error(errorMessage); } } finally { cleanStatePrecondition(sc); } } function cleanStatePrecondition(sc: SmartContract) { for (let [, context] of getStateContexts(sc)) { if (context === undefined) continue; context.wasRead = false; context.wasConstrained = false; context.cachedVariable = undefined; } } function getStateContexts( sc: SmartContract ): [string, StateAttachedContract | undefined][] { let scClass = sc.constructor as typeof SmartContract; let scInfo = smartContracts.get(scClass); if (scInfo === undefined) return []; return scInfo.states.map(([key]) => [key, (sc as any)[key]?._contract]); }