-
Notifications
You must be signed in to change notification settings - Fork 118
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Refactor offchain state #1834
Refactor offchain state #1834
Conversation
// We also can't rely on different instances of the contract having different offchain state instances, because this specific instance of | ||
// offchain state is referenced by the class definition of the smart contract. | ||
// By using a closure and exporting the offchain state and the contract together, we can always refer to the correct instance of the offchain state | ||
offchainState = offchainStateInstance; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This line is the source of a lot of frustration. Here are some alternatives attempted:
offchainState = offchainState.init()
- Re-initialized the state every time the prover is invoked. The smart contract is not capable of staying up to date with the contract state.
OffchainState#memoizedInstances
- Attempted to store previously initialized instances in a map in global state to get around the re-init problem, but ran into issues trying to read variables in provable code. E.g. if we use the contract address as a key, the prover blows up.
offchainState: typeof offchainStateInstance
- The contract can't compile because provable methods rely on offchainState being defined at compile time
That leaves the implementation as implemented here. The instance offchainStateInstance
is tied to the class definition of the smart contract. That means 2 or more smart contract instances will all have the same offchain state instance. Even if we reset the instance state after initialization, the prover will re-run this line and set the offchain state back to the orifinally-defined value.
I found the best/only way to manage this is to turn the whole smart contract class into a closure so that the definition of each smart contract can be paired with its offchain state instance. This doesn't feel amazing, but it does unblock the use case.
ExampleContract: ExampleContractB, | ||
} = ExampleContractWithState(); | ||
|
||
const Local = await Mina.LocalBlockchain({ proofsEnabled: true }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
testLocal
accepts a single contract instance only, so I re-implemented the behavior here for the specific case I needed.
|
||
console.time('compile contract'); | ||
await ExampleContractA.compile(); | ||
await ExampleContractB.compile(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Contracts have the same _verificationKey.hash
, but _provers
need to be recompiled, or else all the contracts will point to the same instance of offchain state again.
Love it |
Given that these modifications affect both the public API and developer usage patterns, we should evaluate options for maintaining backwards compatibility. If backwards compatibility isn't feasible, we may have to consider targeting V2 for these changes |
@Trivo25 this is in the experimental namespace, so do we need to be worried about compatibility? I do think an accompanying PR in the docs is required to match this change before release. |
That's a good point, then probably not |
@45930 sounds like that could be solved with |
Ok, I think I have a working branch locally that works as expected with the memoization happening "behind the scenes". I don't love that this is hidden from the developer, but I like it better than forcing them to use a closure. WDYT?
OR
|
@mitschabaude , bf0e09e is pretty clean! I think this API is a lot less error-prone and requires less deep knowledge to use. But it does add more secret global state, which tends to cause its own issues down the line. |
init(): void { | ||
super.init(); | ||
this.offchainState.setContractInstance(this); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
in general, this will be the wrong place for connecting the offchain state. init()
is only called on initial deployment. can't we move this into offchainState.init()
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cc @45930
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can't we move this into offchainState.init()?
No, we can't.
We can just remove the init method here to make it more clear that offchainState.setContractInstance
needs to be called outside of the circuit. The way it works in my example is:
// first time deploying the contract
const c = Contract(address);
Mina.transaction({ c.deploy }); // contract instance is set automatically
// contract is already deployed
const c = Contract(address);
c.offchainState.setActiveInstance(c); // set contract instance manually
Mina.transaction({ c.method(...params) });
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Again, this is a unit test, so we can't stop a developer from using it like I have here. I'm open to the idea that it's confusing and we should focus on demonstrating one canonical way to set the contract instance.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fair enough, just keep in mind that most devs will use our unit tests as example
instance.setContractClass( | ||
contractInstance.constructor as OffchainStateContractClass<Config> | ||
); | ||
memoizedInstances.set(key, instance); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
any reason not to do instance.setContractInstance(contractInstance)
here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That was my first instinct, but it continues to cause provable errors:
Error: variables only exist inside checked computations
This shouldn't have happened and indicates an internal bug.
at Bug (file:///Users/coby/Projects/o1js/dist/node/lib/util/errors.js:226:12)
at assert (file:///Users/coby/Projects/o1js/dist/node/lib/util/errors.js:233:15)
at toConstantField (file:///Users/coby/Projects/o1js/dist/node/lib/provable/field.js:1083:5)
at toConstant (file:///Users/coby/Projects/o1js/dist/node/lib/provable/field.js:1076:12)
at Field.toConstant (file:///Users/coby/Projects/o1js/dist/node/lib/provable/field.js:118:16)
at Object.fromPublicKey (file:///Users/coby/Projects/o1js/dist/node/lib/ml/conversion.js:64:24)
at Object.getAccount (file:///Users/coby/Projects/o1js/dist/node/lib/mina/local-blockchain.js:77:52)
at Module.getAccount (file:///Users/coby/Projects/o1js/dist/node/lib/mina/mina-instance.js:43:27)
at Object.fetch (file:///Users/coby/Projects/o1js/dist/node/lib/mina/state.js:223:32)
at onchainActionState (file:///Users/coby/Projects/o1js/dist/node/lib/mina/actions/offchain-state.js:77:81)
at merkleMaps (file:///Users/coby/Projects/o1js/dist/node/lib/mina/actions/offchain-state.js:86:37)
at Object.createSettlementProof (file:///Users/coby/Projects/o1js/dist/node/lib/mina/actions/offchain-state.js:222:43)
at file:///Users/coby/Projects/o1js/dist/node/lib/mina/actions/offchain-contract.unit-test.js:114:50
at runInstruction (file:///Users/coby/Projects/o1js/dist/node/lib/mina/test/test-contract.js:101:27)
at execute (file:///Users/coby/Projects/o1js/dist/node/lib/mina/test/test-contract.js:77:19)
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
at async testLocal (file:///Users/coby/Projects/o1js/dist/node/lib/mina/test/test-contract.js:93:9)
at async file:///Users/coby/Projects/o1js/dist/node/lib/mina/actions/offchain-contract.unit-test.js:104:1
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This error throws after compiling and deploying successfully, when I try to call createAccount
in the first transaction to the zkapp after deploying.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I landed on the init method because it's the only place within the zkapp itself that will only be executed when I want it to be executed.
Setting the contract instance outside of the zkapp is still possible, e.g.
// already-deployed contract
const zkapp = Contract(deployedAddress);
zkapp.offchainState.setContractInstance(zkapp);
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you please export IndexedMerkleMapBase as part of this refactoring? It is needed for the serialization of IndexedMerkleMap of any height. Now, only the function IndexedMerkleMap is exported, and I need to get this type using syntax like
export function serializeIndexedMap(
map: InstanceType<ReturnType<typeof IndexedMerkleMap>>
)
https://github.com/zkcloudworker/zkcloudworker-tests/blob/main/src/indexed-map.ts
@dfstio do you want to open a separate PR to expose that API? This PR may take some more time to fully review, but something simpler may get through faster on its own. |
return getContract(); | ||
} catch { | ||
return internal.contract; | ||
type InitializedInternalState = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This type seems to be unused. Do you plan to use it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, it can be removed, I'll do that in my next commit
@Trivo25 just to add some visual context and reasoning behind this change: This diagram shows how the feature works today. The instance of the offchain state, and the definition of the smart contract are directly linked. They need to be linked somehow because offchain state described a zk circuit which is a dependency of the smart contract. Unfortunately, this way doesn't work because a 2nd contract initiialized in the same js session will reference the same offchain state, and when the states differ, you won't be able to generate a valid proof for at least one of them. My changes instantiate a separate instance of the offchain state for each smart contract, but they each reference the same one zk circuit described by the offchain state definition. This leaves 2 "fake classes": |
Trying to find the right API that a) works and b) is simple to use has been the challenging piece, and the last thing Gregor and I were haggling over. What makes it tricky is that when a prover generates a proof, it re-runs the smart contract definition, so if the definition of the smart contract includes an empty state to start with, that's what will be used at proof time. If the definition of the contract doesn't reference offchain state at all to avoid this problem, then we can't use the data in provable methods. 2 plausible patterns are:
function MyContract() {
const offchainState = OffchainState.init();
class MyContract {
// ... reference offchainState here
}
return {
state: offchainState,
contract: MyContract
}
// in offchain state definition
// manages a memoization map behind the scenes so that a new instance is only initialized the first time
init(contractInstance: OffchainStateContract<Config>) {
let key = 'COMPILE_TIME';
let contractAddress = contractInstance.address;
if (contractAddress.isConstant()) {
key = contractAddress.toBase58();
} else {
Provable.asProver(() => {
key = contractAddress.toBase58();
});
}
let instance = memoizedInstances.get(key);
if (instance === undefined) {
instance = OffchainStateInstance();
instance.setContractClass(
contractInstance.constructor as OffchainStateContractClass<Config>
);
memoizedInstances.set(key, instance);
}
return instance;
} // in contract file
class MyContract {
// ... reference offchainState here
offchainState = OffchainState.init(this);
} Currently, option 2 is implemented, which I think is ok. All things equal, I don't like the magic global state, but I don't have an alternative that I like better. |
assert((await contractA.getBalance(sender)).toBigInt() == 900n); | ||
assert((await contractB.getBalance(sender)).toBigInt() == 1300n); | ||
assert((await contractA.getBalance(receiver)).toBigInt() == 100n); | ||
assert((await contractB.getBalance(receiver)).toBigInt() == 200n); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
imo it would be good to add a test where the state of both contracts actually diverge more aggressively (eg contract A doesn't settle for 10 actions, whereas contract B settles every action)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, I will add this kind of test, rm the extra type I added, noted in Boray's comment, and add to the changelog. Anything else you want updated?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I need to get a bit familiar with the changes first, will do another pass
wen release 👀 |
It will be included in the next release after it's merged but you can use before that with |
offchainState.emptyCommitments(); | ||
|
||
// o1js memoizes the offchain state by contract address so that this pattern works | ||
offchainState: any = offchainState.init(this); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
any
is required here because the type of offchainState
is actually
OffchainStateInstance<{
readonly accounts: { kind: "offchain-map"; keyType: typeof PublicKey; valueType: typeof UInt64; };
readonly totalSupply: { kind: "offchain-field"; type: typeof UInt64; };
}>
But that type is not exported, resulting in this error: Public property 'offchainState' of exported class has or is using name 'OffchainStateInstance' from external module "/Users/coby/Projects/o1js/src/lib/mina/actions/offchain-state" but cannot be named.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems fine to me to handle in a different PR eventually, as this API remains experimental.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
as a rule of thumb: If we can do it now, might as well do it now :D
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
woah doesn't omitting this export completely break the type-safety of interacting with offchain state then?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Turns out to be a pretty simple fix
import { ExampleContract } from './ExampleContract.js'; | ||
import { settle, transfer } from './utils.js'; | ||
|
||
const Local = await Mina.LocalBlockchain({ proofsEnabled: false }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
proofs not enabled on the multi-contract test since it takes much longer. This also exercises both the proofs-enabled and proofs-disabled paths.
} from './ExampleContract.js'; | ||
import { settle, transfer } from './utils.js'; | ||
|
||
const Local = await Mina.LocalBlockchain({ proofsEnabled: true }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Proofs enabled on the shorter test to make sure the artifacts compile correctly and proofs can generate.
offchainState.emptyCommitments(); | ||
|
||
// o1js memoizes the offchain state by contract address so that this pattern works | ||
offchainState: any = offchainState.init(this); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
as a rule of thumb: If we can do it now, might as well do it now :D
@@ -0,0 +1,89 @@ | |||
import { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am okay with leaving it here for now but next time we add larger tests we should add them to the example folder and run them from there - makes it a bit easier to keep the internal code clean yet test it and provide a good example to the community
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd be happy to follow convention, but it doesn't seem like there is one. This unit-test file was already here and I added onto it. It seemed like it was getting too big so I broke it out into different files. I'll follow up with you about how you see o1js being organized and we can work towards that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd be happy to follow convention, but it doesn't seem like there is one. This unit-test file was already here and I added onto it. It seemed like it was getting too big so I broke it out into different files. I'll follow up with you about how you see o1js being organized and we can work towards that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree, definitely not your fault - just something I noticed with the larger test
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should bring back the testLocal()
test IMO, it was less verbose and had higher coverage of the implementation than the current proofsEnabled
test
Summary
The experimental offchain state API currently uses a singleton class to manage storage on behalf of a smart contract. This blocks the use case where many instances of the same smart contract need to be instantiated at the same time, because they will all use the same offchain state under the hood, leading to data mismatches.
This PR splits the offchain state API into
OffchainState
andOffchainStateInstance
such that theOffchainState
can be compiled into a circuit definition, and theOffchainStateInstance
stores the internal data like which contract instance it is associated with and the merkle trees of data.Note
I called it out in line, but please note that there is still an ongoing DevX problem where the smart contract class definition has to reference a specific offchain state instance in order to compile. That leaves us in the same problem, where unless handled by the developer, multiple contracts will point to the same storage. There is now a viable workaround, but it doesn't work as nicely as we'd like. I'd appreciate any input to overcome this issue.
Closes: #1832