o1js
Version:
TypeScript framework for zk-SNARKs and zkApps
260 lines (222 loc) • 8.77 kB
text/typescript
/**
* Example implementation of a MINA airdrop that allows concurrent withdrawals.
*/
import {
AccountUpdate,
Bool,
Experimental,
Field,
method,
Poseidon,
Provable,
PublicKey,
SmartContract,
State,
state,
UInt64,
assert,
} from '../../../index.js';
import {
TestInstruction,
expectBalance,
testLocal,
transaction,
} from '../test/test-contract.js';
const { IndexedMerkleMap, BatchReducer } = Experimental;
const MINA = 1_000_000_000n;
const AMOUNT = 10n * MINA;
class MerkleMap extends IndexedMerkleMap(10) {}
// set up reducer
let batchReducer = new BatchReducer({
actionType: PublicKey,
// artificially low batch size to test batch splitting more easily
batchSize: 3,
// artificially low max pending action lists we process per proof, to test recursive proofs
// the default is 100 in the final (zkApp) proof, and 300 per recursive proof
// these could be set even higher (at the cost of larger proof times in the case of few actions)
maxUpdatesFinalProof: 4,
maxUpdatesPerProof: 4,
});
class Batch extends batchReducer.Batch {}
class BatchProof extends batchReducer.BatchProof {}
/**
* Contract that manages airdrop claims.
*
* WARNING: This airdrop design is UNSAFE against attacks by users that set their permissions such that sending them MINA is impossible.
* A single such user which claims the airdrop will cause the reducer to be deadlocked forever.
* Workarounds exist but they require too much code which this example is not about.
*
* THIS IS JUST FOR TESTING. BE CAREFUL ABOUT REDUCER DEADLOCKS IN PRODUCTION CODE!
*/
class UnsafeAirdrop extends SmartContract {
// Batch reducer related
(Field)
actionState = State(BatchReducer.initialActionState);
(Field)
actionStack = State(BatchReducer.initialActionStack);
// Merkle map related
(Field)
eligibleRoot = State(eligible.root);
(Field)
eligibleLength = State(eligible.length);
/**
* Claim an airdrop.
*/
async claim() {
let address = this.sender.getUnconstrained();
// ensure that the MINA account already exists and that the sender knows its private key
let au = AccountUpdate.createSigned(address);
au.body.useFullCommitment = Bool(true); // ensures the signature attests to the entire transaction
batchReducer.dispatch(address);
}
/**
* Go through pending claims and pay them out.
*
* Note: This two-step process is necessary so that multiple users can claim concurrently.
*/
.returns(MerkleMap.provable)
async settleClaims(batch: Batch, proof: BatchProof) {
// witness merkle map and require that it matches the onchain root
let eligibleMap = Provable.witness(MerkleMap.provable, () =>
eligible.clone()
);
this.eligibleRoot.requireEquals(eligibleMap.root);
this.eligibleLength.requireEquals(eligibleMap.length);
// process claims by reducing actions
batchReducer.processBatch({ batch, proof }, (address, isDummy) => {
// check whether the claim is valid = exactly contained in the map
let addressKey = key(address);
let isValidField = eligibleMap.getOption(addressKey).orElse(0n);
isValidField = Provable.if(isDummy, Field(0), isValidField);
let isValid = Bool.Unsafe.fromField(isValidField); // not unsafe, because only bools can be put in the map
// if the claim is valid, zero out the account in the map
eligibleMap.setIf(isValid, addressKey, 0n);
// if the claim is valid, send 100 MINA to the account
let amount = Provable.if(isValid, UInt64.from(AMOUNT), UInt64.zero);
let update = AccountUpdate.createIf(isValid, address);
update.balance.addInPlace(amount);
this.balance.subInPlace(amount);
});
// update the onchain root and action state pointer
this.eligibleRoot.set(eligibleMap.root);
this.eligibleLength.set(eligibleMap.length);
// return the updated eligible map
return eligibleMap;
}
}
/**
* How to map an address to a map key.
*/
function key(address: PublicKey) {
return Poseidon.hash(address.toFields());
}
// TEST BELOW
const eligible = new MerkleMap();
await testLocal(
UnsafeAirdrop,
{ proofsEnabled: 'both', batchReducer },
({
contract,
accounts: { sender },
newAccounts: { alice, bob, charlie, danny, eve },
Local,
}): TestInstruction[] => {
// create a new map of accounts that are eligible for the airdrop
eligible.overwrite(new MerkleMap());
// for every eligible account, we store 1 in the map, representing TRUE
// eve is not eligible, the others are
[alice, bob, charlie, danny].forEach((address) =>
eligible.insert(key(address), 1n)
);
let newEligible = eligible; // for tracking updates to the eligible map
return [
// preparation: sender funds the contract with 100 MINA
transaction('fund contract', async () => {
AccountUpdate.createSigned(sender).send({
to: contract.address,
amount: 100n * MINA,
});
}),
// preparation: create user accounts
transaction('create accounts', async () => {
for (let address of [alice, bob, charlie, danny, eve]) {
AccountUpdate.create(address); // empty account update causes account creation
}
AccountUpdate.fundNewAccount(sender, 5);
}),
// first 4 accounts claim
// (we skip proofs here because they're not interesting for this test)
() => Local.setProofsEnabled(false),
transaction.from(alice)('alice claims', () => contract.claim()),
transaction.from(bob)('bob claims', () => contract.claim()),
transaction.from(eve)('eve claims', () => contract.claim()),
transaction.from(charlie)('charlie claims', () => contract.claim()),
() => Local.resetProofsEnabled(),
// settle claims, 1
async () => {
let batches = await batchReducer.prepareBatches();
// should cause 2 batches because we have 4 claims and batchSize is 3
assert(batches.length === 2, 'two batches');
// should not cause a recursive proof because onchain action processing was set to handle 4 actions
assert(
batches[0].batch.isRecursive.toBoolean() === false,
'not recursive'
);
return batches.flatMap(({ batch, proof }, i) => [
// we create one transaction for each batch
transaction(`settle claims 1-${i}`, async () => {
newEligible = await contract.settleClaims(batch, proof);
}),
// after each transaction, we update our local merkle map
() => eligible.overwrite(newEligible),
]);
},
expectBalance(alice, AMOUNT),
expectBalance(bob, AMOUNT),
expectBalance(eve, 0n), // eve was not eligible
expectBalance(charlie, AMOUNT),
expectBalance(danny, 0n), // danny didn't claim yet
// more claims + final settling
// we submit the same claim 9 times to cause 2 recursive proofs
// and to check that double claims are rejected
() => Local.setProofsEnabled(false),
...Array.from({ length: 9 }, () =>
transaction.from(danny)('danny claims 9x', () => contract.claim())
),
() => Local.resetProofsEnabled(),
// settle claims, 2
async () => {
console.time('recursive batch proof 2x');
let batches = await batchReducer.prepareBatches();
console.timeEnd('recursive batch proof 2x');
// should cause 9/3 === 3 batches
assert(batches.length === 3, 'three batches');
// should have caused a recursive proof (2 actually) because ceil(9/4) = 3 proofs are needed (one of them done as part of the zkApp)
assert(
batches[0].batch.isRecursive.toBoolean() === true,
'is recursive'
);
return batches.flatMap(({ batch, proof }, i) => [
// we create one transaction for each batch
transaction(`settle claims 2-${i}`, async () => {
newEligible = await contract.settleClaims(batch, proof);
}),
// after each transaction, we update our local merkle map
() => eligible.overwrite(newEligible),
]);
},
expectBalance(alice, AMOUNT),
expectBalance(bob, AMOUNT),
expectBalance(eve, 0n),
expectBalance(charlie, AMOUNT),
expectBalance(danny, AMOUNT), // only danny's first claim was fulfilled
// no more claims to settle
async () => {
let batches = await batchReducer.prepareBatches();
// sanity check that batchReducer doesn't create transactions if there is nothing to reduce
assert(batches.length === 0, 'no more claims to settle');
},
];
}
);