Methods
Besides state, runtime modules can also contain methods. In general there are two kinds
of methods available: runtime methods and non-runtime methods. Non-runtime methods
are just regular typescript methods, however the runtime methods are in addition decorated by the
@runtimeMethod()
decorator, making them callable by users via transactions.
Non-runtime methods
Non-runtime methods are especially useful for internal logic that should not be exposed to the user. In order to maintain good testability of the runtime module, it is recommended to keep the general method implementation without side-effects as well. Methods may freely interact with the state API or other methods, and can include a return value too.
import {
RuntimeModule,
runtimeModule,
state,
} from "@proto-kit/module";
import { State, StateMap, Option } from "@proto-kit/protocol";
import { PublicKey } from "o1js";
import { UInt64 } from "@proto-kit/library";
interface BalancesConfig {
totalSupply: UInt64;
}
@runtimeModule()
export class Balances extends RuntimeModule<BalancesConfig> {
@state() public balances = StateMap.from(PublicKey, UInt64);
public getBalance(address: PublicKey): UInt64 {
const balance = this.balances.get(address);
return UInt64.from(balance.value);
}
}
Non-runtime methods that read the state are only executable by the sequencer. You won't be able to call these methods on the client side (e.g. in the browser or tests), since they would not have access to the required state behind the scenes.
Method visibility
You can make use of public
, private
, protected
keywords to control the visibility of your methods (or state properties). This becomes
useful when you want to control access to methods in the runtime module inheritance chain.
Runtime methods
To elevate a method into a runtime method, we must decorate it with the @runtimeMethod()
decorator. Runtime methods
are callable by users via transactions. Where as non-runtime methods are only callable by other methods in the runtime.
Keep in mind that runtime methods will be only as secure, as you implement them. The only guarantees that exist within
runtime methods are the ones you implement yourself. Method visibility has no effect on the @runtimeMethod()
decorator,
these methods will be callable via transactions regardless of visibility.
The example below allows anyone to set a balance for anyone. This is obviously not a good idea, do not do this.
import {
RuntimeModule,
runtimeModule,
state,
runtimeMethod,
} from "@proto-kit/module";
import { State, StateMap, Option } from "@proto-kit/protocol";
import { PublicKey } from "o1js";
import { UInt64 } from "@proto-kit/library"
interface BalancesConfig {
totalSupply: UInt64;
}
@runtimeModule()
export class Balances extends RuntimeModule<BalancesConfig> {
@state() public balances = StateMap.from(PublicKey, UInt64);
@runtimeMethod()
public mint(address: PublicKey, amount: UInt64) {
this.balances.set(address, amount);
}
}
Valid argument types
Runtime methods can only accept arguments of a valid type.
✅ You can use the following data types as runtime method arguments:
- UInt64, UInt32
- Field
- Signature
- Merkle witness
- PublicKey and other o1js primitives
- Structs
- 🤯 Proofs (0-2 proof arguments allowed)
❌ The following types are not supported as runtime method arguments:
- number
- string
- object
- array
- and other non-o1js/native primitives
Proofs as arguments
Using proofs as arguments is a powerful feature of the runtime, which results in the provided proof being recursively included in the proof of the runtime method execution. You can generate client-side proofs using o1js's zk-program, and pass them as arguments to your runtime methods.
The zk program below is a dummy program, it always returns true, effectively proving nothing. For a real use case, make sure to implement a secure and sound zk program that correctly verifies eligibility of the user to mint.
Here's how an example proof as argument would look like:
import {
RuntimeModule,
runtimeModule,
state,
runtimeMethod,
} from "@proto-kit/module";
import { State, StateMap, Option } from "@proto-kit/protocol";
import { PublicKey, Bool, Experimental } from "o1js";
import { dummyBase64Proof } from "o1js/dist/node/lib/proof_system";
import { Pickles } from "o1js/dist/node/snarky";
import { UInt64 } from "@proto-kit/library";
// your zk-circuit proving the user is eligible to mint
const canMint = () => {
return Bool(true);
};
// your zk program goes here
const canMintProgram = Experimental.ZkProgram({
publicOutput: Field,
publicInput: Bool,
methods: {
canMint: {
privateInputs: [],
// eslint-disable-next-line putout/putout
method: canMint,
},
},
});
// define the type of the proof
class CanMintProof extends Experimental.ZkProgram.Proof(canMintProgram) {}
// generate a dummy proof, to be used when testing the runtime method
const [, dummy] = Pickles.proofOfBase64(await dummyBase64Proof(), 2);
const publicInput = Field(0);
const proof = new CanMintProof({
proof: dummy,
publicOutput: canMint(publicInput),
publicInput,
maxProofsVerified: 2,
});
@runtimeModule()
export class Balances extends RuntimeModule<Record<string, never>> {
@state() public balances = StateMap.from(PublicKey, UInt64);
@runtimeMethod()
public mint(canMintProof: CanMintProof, amount: UInt64) {
canMintProof.verify();
this.balances.set(address, amount);
}
}
Assertions
Both regular and runtime methods within the runtime cannot forcefully fail. This means that to handle various
logical cases in our runtime logic, we must use soft-failing assertions. The framework exposes an assert(...)
method
to address this issue.
We can easily check for various conditions which result in Bool
values. And then assert these conditions to be truthy,
and record an error for the transaction if they're not.
You can use as many assertions within your runtime method as you'd like, the overall execution status of the runtime is a subject to all the assert calls within the method. If one assertion fails, the entire transaction will fail.
You cannot use the o1js built in assertion methods, such as assertEquals
within your runtime methods. This unfortunately
rules out usage of primitives that include range checks, or call the built in assertions in any way - such as UInt64 or UInt32.
You can use our built in math primitives instead - they are shipped
as @proto-kit/library
.
import {
RuntimeModule,
runtimeModule,
state,
runtimeMethod,
} from "@proto-kit/module";
import { State, StateMap, Option, assert } from "@proto-kit/protocol";
import { PublicKey } from "o1js";
import { UInt64 } from "@proto-kit/library";
interface BalancesConfig {
totalSupply: UInt64;
}
@runtimeModule()
export class Balances extends RuntimeModule<BalancesConfig> {
@state() public balances = StateMap.from(PublicKey, UInt64);
@runtimeMethod()
public transfer(from: PublicKey, to: PublicKey, amount: UInt64) {
const fromBalance = this.balances.get(from);
const isFromBalanceSufficient = fromBalance.moreThanOrEqual(amount);
assert(isFromBalanceSufficient, "From balance insufficient");
// ... additional transfer logic
}
}