diff --git a/account-compression/sdk/package.json b/account-compression/sdk/package.json index c6d32d01c9e..b5e496f5b8d 100644 --- a/account-compression/sdk/package.json +++ b/account-compression/sdk/package.json @@ -37,19 +37,21 @@ "fmt": "prettier --write '{*,**/*}.{ts,tsx,js,jsx,json}'", "pretty": "prettier --check '{,{src,test}/**/}*.{j,t}s'", "pretty:fix": "prettier --write '{,{src,test}/**/}*.{j,t}s'", - "lint": "set -ex; npm run pretty; eslint . --ext .js,.ts", - "lint:fix": "npm run pretty:fix && eslint . --fix --ext .js,.ts", + "lint": "set -ex; pnpm run pretty; eslint . --ext .js,.ts", + "lint:fix": "pnpm run pretty:fix && eslint . --fix --ext .js,.ts", "docs": "rm -rf docs/ && typedoc --out docs", - "deploy:docs": "yarn docs && gh-pages --dest account-compression/sdk --dist docs --dotfiles", + "deploy:docs": "pnpm docs && gh-pages --dest account-compression/sdk --dist docs --dotfiles", "start-validator": "solana-test-validator --reset --quiet --bpf-program cmtDvXumGCrqC1Age74AVPhSRVXJMd8PJS91L8KbNCK ../target/deploy/spl_account_compression.so --bpf-program noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV ../target/deploy/spl_noop.so", "run-tests": "jest tests --detectOpenHandles", "run-tests:events": "jest tests/events --detectOpenHandles", "run-tests:accounts": "jest tests/accounts --detectOpenHandles", + "run-tests:instructions": "jest tests/instructions --detectOpenHandles", "run-tests:e2e": "jest accountCompression.test.ts --detectOpenHandles", "test:events": "start-server-and-test start-validator http://127.0.0.1:8899/health run-tests:events", "test:accounts": "start-server-and-test start-validator http://127.0.0.1:8899/health run-tests:accounts", "test:e2e": "start-server-and-test start-validator http://127.0.0.1:8899/health run-tests:e2e", "test:merkle-tree": "jest tests/merkleTree.test.ts --detectOpenHandles", + "test:instructions": "start-server-and-test start-validator http://127.0.0.1:8899/health run-tests:instructions", "test": "start-server-and-test start-validator http://127.0.0.1:8899/health run-tests" }, "dependencies": { @@ -71,7 +73,8 @@ "@solana/prettier-config-solana": "^0.0.2", "@types/bn.js": "^5.1.1", "@types/chai": "^4.3.0", - "@types/jest": "^29.0.0", + "@types/jest": "^29.5.8", + "@types/mocha": "^10.0.4", "@types/node-fetch": "^2.6.2", "@typescript-eslint/eslint-plugin": "^5.40.1", "@typescript-eslint/parser": "^5.40.1", diff --git a/account-compression/sdk/tests/accountCompression.test.ts b/account-compression/sdk/tests/accountCompression.test.ts index 86d741e63a4..78b6a136ba1 100644 --- a/account-compression/sdk/tests/accountCompression.test.ts +++ b/account-compression/sdk/tests/accountCompression.test.ts @@ -1,23 +1,19 @@ import { AnchorProvider } from '@project-serum/anchor'; import NodeWallet from '@project-serum/anchor/dist/cjs/nodewallet'; -import { Connection, Keypair, PublicKey, TransactionInstruction } from '@solana/web3.js'; -import { BN } from 'bn.js'; +import { Connection, Keypair, PublicKey } from '@solana/web3.js'; import { assert } from 'chai'; import * as crypto from 'crypto'; import { ConcurrentMerkleTreeAccount, createAppendIx, - createCloseEmptyTreeInstruction, createReplaceIx, - createTransferAuthorityIx, createVerifyLeafIx, ValidDepthSizePair, } from '../src'; -import { hash, MerkleTree } from '../src/merkle-tree'; +import { MerkleTree } from '../src/merkle-tree'; import { createTreeOnChain, execute } from './utils'; -// eslint-disable-next-line no-empty describe('Account Compression', () => { // Configure the client to use the local cluster. let offChainTree: MerkleTree; @@ -127,7 +123,7 @@ describe('Account Compression', () => { const newLeaf = crypto.randomBytes(32); const index = 0; - const replaceLeafIx = createReplaceIx(cmt, payer, newLeaf, offChainTree.getProof(index, false, -1)); + const replaceLeafIx = createReplaceIx(cmt, payer, newLeaf, offChainTree.getProof(index)); assert(replaceLeafIx.keys.length == 3 + MAX_DEPTH, `Failed to create proof for ${MAX_DEPTH}`); await execute(provider, [replaceLeafIx], [payerKeypair]); @@ -143,12 +139,22 @@ describe('Account Compression', () => { ); }); - it('Replace that leaf with a minimal proof', async () => { + it('Replace that leaf with while autocompleting a proof', async () => { + /* + In this test, we only pass 1 node of the proof to the instruction, and the rest is autocompleted. + This test should pass because we just previously appended the leaf, so the proof can be autocompleted + from what is in the active buffer. + There probably isn't a use case for this, but it is still expected behavior, hence this test. + */ + const newLeaf = crypto.randomBytes(32); const index = 0; - const replaceLeafIx = createReplaceIx(cmt, payer, newLeaf, offChainTree.getProof(index, true, 1)); - assert(replaceLeafIx.keys.length == 3 + 1, 'Failed to minimize proof to expected size of 1'); + // Truncate proof to only top most node + const proof = offChainTree.getProof(index, true, 1); + assert(proof.proof.length === 1, 'Failed to minimize proof to expected size of 1'); + + const replaceLeafIx = createReplaceIx(cmt, payer, newLeaf, proof); await execute(provider, [replaceLeafIx], [payerKeypair]); offChainTree.updateLeaf(index, newLeaf); @@ -163,144 +169,6 @@ describe('Account Compression', () => { }); }); - describe('Examples transferring authority', () => { - const authorityKeypair = Keypair.generate(); - const authority = authorityKeypair.publicKey; - const randomSignerKeypair = Keypair.generate(); - const randomSigner = randomSignerKeypair.publicKey; - - beforeEach(async () => { - await provider.connection.confirmTransaction( - await (connection as Connection).requestAirdrop(authority, 1e10) - ); - [cmtKeypair, offChainTree] = await createTreeOnChain(provider, authorityKeypair, 1, DEPTH_SIZE_PAIR); - cmt = cmtKeypair.publicKey; - }); - it('Attempting to replace with random authority fails', async () => { - const newLeaf = crypto.randomBytes(32); - const replaceIndex = 0; - const proof = offChainTree.getProof(replaceIndex); - const replaceIx = createReplaceIx(cmt, randomSigner, newLeaf, proof); - - try { - await execute(provider, [replaceIx], [randomSignerKeypair]); - assert(false, 'Transaction should have failed since incorrect authority cannot execute replaces'); - } catch {} - }); - it('Can transfer authority', async () => { - const transferAuthorityIx = createTransferAuthorityIx(cmt, authority, randomSigner); - await execute(provider, [transferAuthorityIx], [authorityKeypair]); - - const splCMT = await ConcurrentMerkleTreeAccount.fromAccountAddress(connection, cmt); - - assert( - splCMT.getAuthority().equals(randomSigner), - `Upon transfering authority, authority should be ${randomSigner.toString()}, but was instead updated to ${splCMT.getAuthority()}` - ); - - // Attempting to replace with new authority now works - const newLeaf = crypto.randomBytes(32); - const replaceIndex = 0; - const proof = offChainTree.getProof(replaceIndex); - const replaceIx = createReplaceIx(cmt, randomSigner, newLeaf, proof); - - await execute(provider, [replaceIx], [randomSignerKeypair]); - }); - }); - - describe(`Having created a tree with ${MAX_SIZE} leaves`, () => { - beforeEach(async () => { - [cmtKeypair, offChainTree] = await createTreeOnChain(provider, payerKeypair, MAX_SIZE, DEPTH_SIZE_PAIR); - cmt = cmtKeypair.publicKey; - }); - it(`Replace all of them in a block`, async () => { - // Replace 64 leaves before syncing off-chain tree with on-chain tree - const ixArray: TransactionInstruction[] = []; - const txList: Promise[] = []; - - const leavesToUpdate: Buffer[] = []; - for (let i = 0; i < MAX_SIZE; i++) { - const index = i; - const newLeaf = hash(payer.toBuffer(), Buffer.from(new BN(i).toArray())); - leavesToUpdate.push(newLeaf); - const proof = offChainTree.getProof(index); - const replaceIx = createReplaceIx(cmt, payer, newLeaf, proof); - ixArray.push(replaceIx); - } - - // Execute all replaces - ixArray.map(ix => { - txList.push(execute(provider, [ix], [payerKeypair])); - }); - await Promise.all(txList); - - leavesToUpdate.map((leaf, index) => { - offChainTree.updateLeaf(index, leaf); - }); - - // Compare on-chain & off-chain roots - const splCMT = await ConcurrentMerkleTreeAccount.fromAccountAddress(connection, cmt); - const onChainRoot = splCMT.getCurrentRoot(); - - assert( - Buffer.from(onChainRoot).equals(offChainTree.root), - 'Updated on chain root does not match root of updated off chain tree' - ); - }); - it('Empty all of the leaves and close the tree', async () => { - const ixArray: TransactionInstruction[] = []; - const txList: Promise[] = []; - const leavesToUpdate: Buffer[] = []; - for (let i = 0; i < MAX_SIZE; i++) { - const index = i; - const newLeaf = hash(payer.toBuffer(), Buffer.from(new BN(i).toArray())); - leavesToUpdate.push(newLeaf); - const proof = offChainTree.getProof(index); - const replaceIx = createReplaceIx(cmt, payer, Buffer.alloc(32), proof); - ixArray.push(replaceIx); - } - // Execute all replaces - ixArray.map(ix => { - txList.push(execute(provider, [ix], [payerKeypair])); - }); - await Promise.all(txList); - - let payerInfo = await provider.connection.getAccountInfo(payer, 'confirmed')!; - let treeInfo = await provider.connection.getAccountInfo(cmt, 'confirmed')!; - - const payerLamports = payerInfo!.lamports; - const treeLamports = treeInfo!.lamports; - - const ix = createCloseEmptyTreeInstruction({ - authority: payer, - merkleTree: cmt, - recipient: payer, - }); - await execute(provider, [ix], [payerKeypair]); - - payerInfo = await provider.connection.getAccountInfo(payer, 'confirmed')!; - const finalLamports = payerInfo!.lamports; - assert( - finalLamports === payerLamports + treeLamports - 5000, - 'Expected payer to have received the lamports from the closed tree account' - ); - - treeInfo = await provider.connection.getAccountInfo(cmt, 'confirmed'); - assert(treeInfo === null, 'Expected the merkle tree account info to be null'); - }); - it('It cannot be closed until empty', async () => { - const ix = createCloseEmptyTreeInstruction({ - authority: payer, - merkleTree: cmt, - recipient: payer, - }); - try { - await execute(provider, [ix], [payerKeypair]); - assert(false, 'Closing a tree account before it is empty should ALWAYS error'); - } catch (e) {} - }); - }); - describe(`Having created a tree with depth 3`, () => { const DEPTH = 3; beforeEach(async () => { @@ -352,157 +220,4 @@ describe('Account Compression', () => { ); }); }); - describe(`Canopy test`, () => { - const DEPTH = 5; - it(`Testing canopy for verify leaf instructions`, async () => { - [cmtKeypair, offChainTree] = await createTreeOnChain( - provider, - payerKeypair, - 2 ** DEPTH, - { maxBufferSize: 8, maxDepth: DEPTH }, - DEPTH // Store full tree on chain - ); - cmt = cmtKeypair.publicKey; - - const splCMT = await ConcurrentMerkleTreeAccount.fromAccountAddress(connection, cmt, 'confirmed'); - let i = 0; - const stepSize = 4; - while (i < 2 ** DEPTH) { - const ixs: TransactionInstruction[] = []; - for (let j = 0; j < stepSize; j += 1) { - const leafIndex = i + j; - const leaf = offChainTree.leaves[leafIndex].node; - const verifyIx = createVerifyLeafIx(cmt, { - leaf, - leafIndex, - proof: [], - root: splCMT.getCurrentRoot(), - }); - ixs.push(verifyIx); - } - i += stepSize; - await execute(provider, ixs, [payerKeypair]); - } - }); - it('Testing canopy for appends and replaces on a full on chain tree', async () => { - [cmtKeypair, offChainTree] = await createTreeOnChain( - provider, - payerKeypair, - 0, - { maxBufferSize: 8, maxDepth: DEPTH }, - DEPTH // Store full tree on chain - ); - cmt = cmtKeypair.publicKey; - - // Test that the canopy updates properly throughout multiple modifying instructions - // in the same transaction - const leaves: Array[] = []; - let i = 0; - const stepSize = 4; - while (i < 2 ** DEPTH) { - const ixs: TransactionInstruction[] = []; - for (let j = 0; j < stepSize; ++j) { - const newLeaf = Array.from(Buffer.alloc(32, i + 1)); - leaves.push(newLeaf); - const appendIx = createAppendIx(cmt, payer, newLeaf); - ixs.push(appendIx); - } - await execute(provider, ixs, [payerKeypair]); - i += stepSize; - console.log('Appended', i, 'leaves'); - } - - // Compare on-chain & off-chain roots - let ixs: TransactionInstruction[] = []; - const splCMT = await ConcurrentMerkleTreeAccount.fromAccountAddress(connection, cmt); - const root = splCMT.getCurrentRoot(); - - // Test that the entire state of the tree is stored properly - // by using the canopy to infer proofs to all of the leaves in the tree. - // We test that the canopy is updating properly by replacing all the leaves - // in the tree - const leafList = Array.from(leaves.entries()); - leafList.sort(() => Math.random() - 0.5); - let replaces = 0; - const newLeaves: Record = {}; - for (const [i, leaf] of leafList) { - const newLeaf = crypto.randomBytes(32); - newLeaves[i] = newLeaf; - const replaceIx = createReplaceIx(cmt, payer, newLeaf, { - leaf: Buffer.from(Uint8Array.from(leaf)), - leafIndex: i, - proof: [], - root, // No proof necessary - }); - ixs.push(replaceIx); - if (ixs.length == stepSize) { - replaces++; - await execute(provider, ixs, [payerKeypair]); - console.log('Replaced', replaces * stepSize, 'leaves'); - ixs = []; - } - } - - const newLeafList: Buffer[] = []; - for (let i = 0; i < 32; ++i) { - newLeafList.push(newLeaves[i]); - } - - const tree = new MerkleTree(newLeafList); - - for (let proofSize = 1; proofSize <= 5; ++proofSize) { - const newLeaf = crypto.randomBytes(32); - const i = Math.floor(Math.random() * 32); - const leaf = newLeaves[i]; - - let proof = tree.getProof(i); - const partialProof = proof.proof.slice(0, proofSize); - - // Create an instruction to replace the leaf - const replaceIx = createReplaceIx(cmt, payer, newLeaf, { - ...proof, - proof: partialProof, - }); - tree.updateLeaf(i, newLeaf); - - // Create an instruction to undo the previous replace, but using the now-outdated partialProof - proof = tree.getProof(i); - const replaceBackIx = createReplaceIx(cmt, payer, leaf, { - ...proof, - proof: partialProof, - }); - tree.updateLeaf(i, leaf); - await execute(provider, [replaceIx, replaceBackIx], [payerKeypair], true, true); - } - }); - }); - describe(`Having created a tree with 8 leaves`, () => { - beforeEach(async () => { - [cmtKeypair, offChainTree] = await createTreeOnChain(provider, payerKeypair, 1 << 3, { - maxBufferSize: 8, - maxDepth: 3, - }); - cmt = cmtKeypair.publicKey; - }); - it(`Attempt to replace a leaf beyond the tree's capacity`, async () => { - // Ensure that this fails - const outOfBoundsIndex = 8; - const index = outOfBoundsIndex; - const newLeaf = hash(payer.toBuffer(), Buffer.from(new BN(outOfBoundsIndex).toArray())); - const node = offChainTree.leaves[outOfBoundsIndex - 1].node; - const proof = offChainTree.getProof(index - 1).proof; - - const replaceIx = createReplaceIx(cmt, payer, newLeaf, { - leaf: node, - leafIndex: index, - proof, - root: offChainTree.root, - }); - - try { - await execute(provider, [replaceIx], [payerKeypair]); - throw Error('This replace instruction should have failed because the leaf index is OOB'); - } catch (_e) {} - }); - }); }); diff --git a/account-compression/sdk/tests/accounts/canopy.test.ts b/account-compression/sdk/tests/accounts/canopy.test.ts new file mode 100644 index 00000000000..dae8364eb71 --- /dev/null +++ b/account-compression/sdk/tests/accounts/canopy.test.ts @@ -0,0 +1,204 @@ +import { AnchorProvider } from '@project-serum/anchor'; +import NodeWallet from '@project-serum/anchor/dist/cjs/nodewallet'; +import { Connection, Keypair, PublicKey, TransactionInstruction } from '@solana/web3.js'; +import { assert } from 'chai'; +import * as crypto from 'crypto'; + +import { + ConcurrentMerkleTreeAccount, + createAppendIx, + createReplaceIx, + createVerifyLeafIx, + ValidDepthSizePair, +} from '../../src'; +import { MerkleTree } from '../../src/merkle-tree'; +import { createTreeOnChain, execute } from '../utils'; + +describe(`Canopy test`, () => { + let offChainTree: MerkleTree; + let cmtKeypair: Keypair; + let cmt: PublicKey; + let payerKeypair: Keypair; + let payer: PublicKey; + let connection: Connection; + let provider: AnchorProvider; + + const MAX_BUFFER_SIZE = 8; + const MAX_DEPTH = 5; + const DEPTH_SIZE_PAIR: ValidDepthSizePair = { + maxBufferSize: MAX_BUFFER_SIZE, + maxDepth: MAX_DEPTH, + }; + + beforeEach(async () => { + payerKeypair = Keypair.generate(); + payer = payerKeypair.publicKey; + connection = new Connection('http://127.0.0.1:8899', { + commitment: 'confirmed', + }); + const wallet = new NodeWallet(payerKeypair); + provider = new AnchorProvider(connection, wallet, { + commitment: connection.commitment, + skipPreflight: true, + }); + + await provider.connection.confirmTransaction( + await provider.connection.requestAirdrop(payer, 1e10), + 'confirmed' + ); + }); + + describe(`Unit test proof instructions`, () => { + beforeEach(async () => { + [cmtKeypair, offChainTree] = await createTreeOnChain( + provider, + payerKeypair, + 2 ** MAX_DEPTH, // Fill up the tree + DEPTH_SIZE_PAIR, + MAX_DEPTH // Store full tree on chain + ); + cmt = cmtKeypair.publicKey; + }); + it(`VerifyLeaf works with no proof accounts`, async () => { + const splCMT = await ConcurrentMerkleTreeAccount.fromAccountAddress(connection, cmt, 'confirmed'); + + // Test that the entire state of the tree is stored properly + // by verifying every leaf in the tree. + // We use batches of 4 verify ixs / tx to speed up the test + let i = 0; + const stepSize = 4; + while (i < 2 ** MAX_DEPTH) { + const ixs: TransactionInstruction[] = []; + for (let j = 0; j < stepSize; j += 1) { + const leafIndex = i + j; + const leaf = offChainTree.leaves[leafIndex].node; + const verifyIx = createVerifyLeafIx(cmt, { + leaf, + leafIndex, + proof: [], + root: splCMT.getCurrentRoot(), + }); + ixs.push(verifyIx); + } + i += stepSize; + await execute(provider, ixs, [payerKeypair], true); + } + }); + it('ReplaceLeaf works with no proof accounts', async () => { + for (let i = 0; i < 2 ** MAX_DEPTH; i += 1) { + const proof = offChainTree.getProof(i); + + // Replace the current leaf to random bytes, without any additional proof accounts + const newLeaf = crypto.randomBytes(32); + const replaceIx = createReplaceIx(cmt, payer, newLeaf, { + ...proof, + proof: [], + }); + offChainTree.updateLeaf(i, newLeaf); + await execute(provider, [replaceIx], [payerKeypair], true, false); + + // Check that replaced leaf actually exists in new tree root + const splCMT = await ConcurrentMerkleTreeAccount.fromAccountAddress(connection, cmt, { + commitment: 'confirmed', + }); + assert(splCMT.getCurrentRoot().equals(Buffer.from(offChainTree.root)), 'Roots do not match'); + } + }); + }); + + describe('Test integrated appends & replaces', () => { + beforeEach(async () => { + [cmtKeypair, offChainTree] = await createTreeOnChain( + provider, + payerKeypair, + 0, // Start off with 0 leaves + DEPTH_SIZE_PAIR, + MAX_DEPTH // Store full tree on chain + ); + cmt = cmtKeypair.publicKey; + }); + + it('Testing canopy for appends and replaces on a full on chain tree', async () => { + // Test that the canopy updates properly throughout multiple modifying instructions + // in the same transaction + const leaves: Array[] = []; + let i = 0; + const stepSize = 4; + while (i < 2 ** MAX_DEPTH) { + const ixs: TransactionInstruction[] = []; + for (let j = 0; j < stepSize; ++j) { + const newLeaf = Array.from(Buffer.alloc(32, i + 1)); + leaves.push(newLeaf); + const appendIx = createAppendIx(cmt, payer, newLeaf); + ixs.push(appendIx); + } + await execute(provider, ixs, [payerKeypair]); + i += stepSize; + console.log('Appended', i, 'leaves'); + } + + // Compare on-chain & off-chain roots + let ixs: TransactionInstruction[] = []; + const splCMT = await ConcurrentMerkleTreeAccount.fromAccountAddress(connection, cmt); + const root = splCMT.getCurrentRoot(); + + // Test that the entire state of the tree is stored properly + // by using the canopy to infer proofs to all of the leaves in the tree. + // We test that the canopy is updating properly by replacing all the leaves + // in the tree + const leafList = Array.from(leaves.entries()); + leafList.sort(() => Math.random() - 0.5); + let replaces = 0; + const newLeaves: Record = {}; + for (const [i, leaf] of leafList) { + const newLeaf = crypto.randomBytes(32); + newLeaves[i] = newLeaf; + const replaceIx = createReplaceIx(cmt, payer, newLeaf, { + leaf: Buffer.from(Uint8Array.from(leaf)), + leafIndex: i, + proof: [], + root, // No proof necessary + }); + ixs.push(replaceIx); + if (ixs.length == stepSize) { + replaces++; + await execute(provider, ixs, [payerKeypair], true); + console.log('Replaced', replaces * stepSize, 'leaves'); + ixs = []; + } + } + + const newLeafList: Buffer[] = []; + for (let i = 0; i < 32; ++i) { + newLeafList.push(newLeaves[i]); + } + + const tree = new MerkleTree(newLeafList); + + for (let proofSize = 1; proofSize <= 5; ++proofSize) { + const newLeaf = crypto.randomBytes(32); + const i = Math.floor(Math.random() * 32); + const leaf = newLeaves[i]; + + let proof = tree.getProof(i); + const partialProof = proof.proof.slice(0, proofSize); + + // Create an instruction to replace the leaf + const replaceIx = createReplaceIx(cmt, payer, newLeaf, { + ...proof, + proof: partialProof, + }); + tree.updateLeaf(i, newLeaf); + + // Create an instruction to undo the previous replace, but using the now-outdated partialProof + proof = tree.getProof(i); + const replaceBackIx = createReplaceIx(cmt, payer, leaf, { + ...proof, + proof: partialProof, + }); + tree.updateLeaf(i, leaf); + await execute(provider, [replaceIx, replaceBackIx], [payerKeypair], true, false); + } + }); + }); +}); diff --git a/account-compression/sdk/tests/accounts/concurrentMerkleTreeAccount.test.ts b/account-compression/sdk/tests/accounts/concurrentMerkleTreeAccount.test.ts index f7f9799b223..18b1b8dc778 100644 --- a/account-compression/sdk/tests/accounts/concurrentMerkleTreeAccount.test.ts +++ b/account-compression/sdk/tests/accounts/concurrentMerkleTreeAccount.test.ts @@ -116,7 +116,7 @@ describe('ConcurrentMerkleTreeAccount tests', () => { const maxDepth = 30; const maxBufferSize = 2048; - for (let canopyDepth = 1; canopyDepth <= 14; canopyDepth++) { + for (let canopyDepth = 1; canopyDepth <= 17; canopyDepth++) { // Airdrop enough SOL to cover tree creation const size = getConcurrentMerkleTreeAccountSize(maxDepth, maxBufferSize, canopyDepth); const rent = await connection.getMinimumBalanceForRentExemption(size, 'confirmed'); diff --git a/account-compression/sdk/tests/instructions/appendLeaf.test.ts b/account-compression/sdk/tests/instructions/appendLeaf.test.ts new file mode 100644 index 00000000000..9551543a4b8 --- /dev/null +++ b/account-compression/sdk/tests/instructions/appendLeaf.test.ts @@ -0,0 +1,87 @@ +import { AnchorProvider } from '@project-serum/anchor'; +import NodeWallet from '@project-serum/anchor/dist/cjs/nodewallet'; +import { Connection, Keypair, PublicKey } from '@solana/web3.js'; +import { assert } from 'chai'; +import * as crypto from 'crypto'; + +import { ConcurrentMerkleTreeAccount, createAppendIx, ValidDepthSizePair } from '../../src'; +import { MerkleTree } from '../../src/merkle-tree'; +import { createTreeOnChain, execute } from '../utils'; + +describe(`CloseEmptyTree instruction`, () => { + let offChainTree: MerkleTree; + let cmtKeypair: Keypair; + let cmt: PublicKey; + let payerKeypair: Keypair; + let payer: PublicKey; + let connection: Connection; + let provider: AnchorProvider; + + const MAX_SIZE = 64; + const MAX_DEPTH = 14; + const DEPTH_SIZE_PAIR: ValidDepthSizePair = { + maxBufferSize: MAX_SIZE, + maxDepth: MAX_DEPTH, + }; + beforeEach(async () => { + payerKeypair = Keypair.generate(); + payer = payerKeypair.publicKey; + connection = new Connection('http://127.0.0.1:8899', { + commitment: 'confirmed', + }); + const wallet = new NodeWallet(payerKeypair); + provider = new AnchorProvider(connection, wallet, { + commitment: connection.commitment, + skipPreflight: true, + }); + + await provider.connection.confirmTransaction( + await provider.connection.requestAirdrop(payer, 1e10), + 'confirmed' + ); + }); + describe('Having created a tree with a single leaf', () => { + beforeEach(async () => { + [cmtKeypair, offChainTree] = await createTreeOnChain(provider, payerKeypair, 1, DEPTH_SIZE_PAIR); + cmt = cmtKeypair.publicKey; + }); + it('Append single leaf', async () => { + const newLeaf = crypto.randomBytes(32); + const appendIx = createAppendIx(cmt, payer, newLeaf); + + await execute(provider, [appendIx], [payerKeypair]); + offChainTree.updateLeaf(1, newLeaf); + + const splCMT = await ConcurrentMerkleTreeAccount.fromAccountAddress(connection, cmt); + const onChainRoot = splCMT.getCurrentRoot(); + + assert( + Buffer.from(onChainRoot).equals(offChainTree.root), + 'Updated on chain root matches root of updated off chain tree' + ); + }); + }); + describe(`Having created a tree with 8 leaves`, () => { + beforeEach(async () => { + [cmtKeypair, offChainTree] = await createTreeOnChain(provider, payerKeypair, 2 ** 3, { + maxBufferSize: 8, + maxDepth: 3, + }); + cmt = cmtKeypair.publicKey; + }); + it(`Attempt to append a leaf to a full tree`, async () => { + // Ensure that this fails + const newLeaf = crypto.randomBytes(32); + const appendIx = createAppendIx(cmt, payer, newLeaf); + + try { + await execute(provider, [appendIx], [payerKeypair]); + throw Error( + 'This append instruction should have failed because there is no more space to append leaves' + ); + } catch (_e) { + assert(true); + } + }); + }); +}); diff --git a/account-compression/sdk/tests/instructions/closeEmptyTree.test.ts b/account-compression/sdk/tests/instructions/closeEmptyTree.test.ts new file mode 100644 index 00000000000..253387cc4c1 --- /dev/null +++ b/account-compression/sdk/tests/instructions/closeEmptyTree.test.ts @@ -0,0 +1,125 @@ +import { AnchorProvider } from '@project-serum/anchor'; +import NodeWallet from '@project-serum/anchor/dist/cjs/nodewallet'; +import { Connection, Keypair, PublicKey, TransactionInstruction } from '@solana/web3.js'; +import { assert } from 'chai'; + +import { createCloseEmptyTreeInstruction, createReplaceIx, ValidDepthSizePair } from '../../src'; +import { MerkleTree } from '../../src/merkle-tree'; +import { createTreeOnChain, execute } from '../utils'; + +describe(`CloseEmptyTree instruction`, () => { + let offChainTree: MerkleTree; + let cmtKeypair: Keypair; + let cmt: PublicKey; + let payerKeypair: Keypair; + let payer: PublicKey; + let connection: Connection; + let provider: AnchorProvider; + + const MAX_SIZE = 64; + const MAX_DEPTH = 14; + const DEPTH_SIZE_PAIR: ValidDepthSizePair = { + maxBufferSize: MAX_SIZE, + maxDepth: MAX_DEPTH, + }; + beforeEach(async () => { + payerKeypair = Keypair.generate(); + payer = payerKeypair.publicKey; + connection = new Connection('http://127.0.0.1:8899', { + commitment: 'confirmed', + }); + const wallet = new NodeWallet(payerKeypair); + provider = new AnchorProvider(connection, wallet, { + commitment: connection.commitment, + skipPreflight: true, + }); + + await provider.connection.confirmTransaction( + await provider.connection.requestAirdrop(payer, 1e10), + 'confirmed' + ); + }); + + describe('Testing execution', () => { + const NUM_LEAVES_TO_EMPTY = 10; + + beforeEach(async () => { + [cmtKeypair, offChainTree] = await createTreeOnChain( + provider, + payerKeypair, + NUM_LEAVES_TO_EMPTY, + DEPTH_SIZE_PAIR + ); + cmt = cmtKeypair.publicKey; + }); + + it('Empty all of the leaves and close the tree', async () => { + // Empty all the leaves in the tree + const ixArray: TransactionInstruction[] = []; + const txList: Promise[] = []; + const leavesToUpdate: Buffer[] = []; + for (let i = 0; i < NUM_LEAVES_TO_EMPTY; i++) { + const index = i; + const newLeaf = Buffer.alloc(32); + leavesToUpdate.push(newLeaf); + + const proof = offChainTree.getProof(index); + const replaceIx = createReplaceIx(cmt, payer, Buffer.alloc(32), proof); + ixArray.push(replaceIx); + } + ixArray.map(ix => { + txList.push(execute(provider, [ix], [payerKeypair])); + }); + await Promise.all(txList); + + // Check that the user is able to close the tree and receive all passports + let payerInfo = await connection.getAccountInfo(payer, 'confirmed'); + let treeInfo = await connection.getAccountInfo(cmt, 'confirmed'); + if (payerInfo === null) { + assert(false, 'Expected payer to exist'); + return; + } + if (treeInfo === null) { + assert(false, 'Expected tree to exist'); + return; + } + const payerLamports = payerInfo.lamports; + const treeLamports = treeInfo.lamports; + + const ix = createCloseEmptyTreeInstruction({ + authority: payer, + merkleTree: cmt, + recipient: payer, + }); + await execute(provider, [ix], [payerKeypair]); + + payerInfo = await provider.connection.getAccountInfo(payer, 'confirmed'); + if (payerInfo === null) { + assert(false, 'Expected payer to exist'); + return; + } + + const finalLamports = payerInfo.lamports; + assert( + finalLamports === payerLamports + treeLamports - 5000, + 'Expected payer to have received the lamports from the closed tree account' + ); + + treeInfo = await provider.connection.getAccountInfo(cmt, 'confirmed'); + assert(treeInfo === null, 'Expected the merkle tree account info to be null'); + }); + it('It cannot be closed until empty', async () => { + const ix = createCloseEmptyTreeInstruction({ + authority: payer, + merkleTree: cmt, + recipient: payer, + }); + try { + await execute(provider, [ix], [payerKeypair]); + assert(false, 'Closing a tree account before it is empty should ALWAYS error'); + } catch (e) { + assert(true); + } + }); + }); +}); diff --git a/account-compression/sdk/tests/instructions/replaceLeaf.test.ts b/account-compression/sdk/tests/instructions/replaceLeaf.test.ts new file mode 100644 index 00000000000..1c89d084b4a --- /dev/null +++ b/account-compression/sdk/tests/instructions/replaceLeaf.test.ts @@ -0,0 +1,126 @@ +import { AnchorProvider } from '@project-serum/anchor'; +import NodeWallet from '@project-serum/anchor/dist/cjs/nodewallet'; +import { Connection, Keypair, PublicKey, TransactionInstruction } from '@solana/web3.js'; +import { assert } from 'chai'; +import * as crypto from 'crypto'; + +import { ConcurrentMerkleTreeAccount, createReplaceIx, ValidDepthSizePair } from '../../src'; +import { MerkleTree } from '../../src/merkle-tree'; +import { createTreeOnChain, execute } from '../utils'; + +describe(`ReplaceLeaf instruction`, () => { + let offChainTree: MerkleTree; + let cmtKeypair: Keypair; + let cmt: PublicKey; + let payerKeypair: Keypair; + let payer: PublicKey; + let connection: Connection; + let provider: AnchorProvider; + + const MAX_BUFFER_SIZE = 8; + const MAX_DEPTH = 5; + const DEPTH_SIZE_PAIR: ValidDepthSizePair = { + maxBufferSize: MAX_BUFFER_SIZE, + maxDepth: MAX_DEPTH, + }; + beforeEach(async () => { + payerKeypair = Keypair.generate(); + payer = payerKeypair.publicKey; + connection = new Connection('http://127.0.0.1:8899', { + commitment: 'confirmed', + }); + const wallet = new NodeWallet(payerKeypair); + provider = new AnchorProvider(connection, wallet, { + commitment: connection.commitment, + skipPreflight: true, + }); + + await provider.connection.confirmTransaction( + await provider.connection.requestAirdrop(payer, 1e10), + 'confirmed' + ); + }); + describe(`Having created a tree with ${MAX_BUFFER_SIZE} leaves`, () => { + const NUM_LEAVES_TO_REPLACE = MAX_BUFFER_SIZE; + + beforeEach(async () => { + [cmtKeypair, offChainTree] = await createTreeOnChain( + provider, + payerKeypair, + NUM_LEAVES_TO_REPLACE, + DEPTH_SIZE_PAIR + ); + cmt = cmtKeypair.publicKey; + }); + + it(`Replace all of them with same proof information`, async () => { + // Replace MAX_BUFFER_SIZE leaves before syncing off-chain tree with on-chain tree + // This is meant to simulate the the case where the Proof Server has fallen behind by MAX_BUFFER_SIZE updates + const ixArray: TransactionInstruction[] = []; + const txList: Promise[] = []; + + const leavesToUpdate: Buffer[] = []; + for (let i = 0; i < NUM_LEAVES_TO_REPLACE; i++) { + const index = i; + const newLeaf = crypto.randomBytes(32); + leavesToUpdate.push(newLeaf); + + const proof = offChainTree.getProof(index); + const replaceIx = createReplaceIx(cmt, payer, newLeaf, proof); + ixArray.push(replaceIx); + } + + // Execute all replaces + ixArray.map(ix => { + txList.push(execute(provider, [ix], [payerKeypair])); + }); + await Promise.all(txList); + + leavesToUpdate.map((leaf, index) => { + offChainTree.updateLeaf(index, leaf); + }); + + // Compare on-chain & off-chain roots + const splCMT = await ConcurrentMerkleTreeAccount.fromAccountAddress(connection, cmt); + const onChainRoot = splCMT.getCurrentRoot(); + + assert( + Buffer.from(onChainRoot).equals(offChainTree.root), + 'Updated on chain root does not match root of updated off chain tree' + ); + }); + }); + + describe(`Having created a tree with 8 leaves`, () => { + beforeEach(async () => { + [cmtKeypair, offChainTree] = await createTreeOnChain(provider, payerKeypair, 2 ** 3, { + maxBufferSize: 8, + maxDepth: 3, + }); + cmt = cmtKeypair.publicKey; + }); + it(`Attempt to replace a leaf beyond the tree's capacity`, async () => { + // Ensure that this fails + const outOfBoundsIndex = 8; + const index = outOfBoundsIndex; + const newLeaf = crypto.randomBytes(32); + + const node = offChainTree.leaves[outOfBoundsIndex - 1].node; + const proof = offChainTree.getProof(index - 1).proof; + + const replaceIx = createReplaceIx(cmt, payer, newLeaf, { + leaf: node, + leafIndex: index, + proof, + root: offChainTree.root, + }); + + try { + await execute(provider, [replaceIx], [payerKeypair]); + throw Error('This replace instruction should have failed because the leaf index is out of bounds'); + } catch (_e) { + assert(true); + } + }); + }); +}); diff --git a/account-compression/sdk/tests/instructions/transferAuthority.test.ts b/account-compression/sdk/tests/instructions/transferAuthority.test.ts new file mode 100644 index 00000000000..9b91c6e606d --- /dev/null +++ b/account-compression/sdk/tests/instructions/transferAuthority.test.ts @@ -0,0 +1,90 @@ +import { AnchorProvider } from '@project-serum/anchor'; +import NodeWallet from '@project-serum/anchor/dist/cjs/nodewallet'; +import { Connection, Keypair, PublicKey } from '@solana/web3.js'; +import { assert } from 'chai'; +import * as crypto from 'crypto'; + +import { ConcurrentMerkleTreeAccount, createReplaceIx, createTransferAuthorityIx, ValidDepthSizePair } from '../../src'; +import { MerkleTree } from '../../src/merkle-tree'; +import { createTreeOnChain, execute } from '../utils'; + +describe(`TransferAuthority instruction`, () => { + let offChainTree: MerkleTree; + let cmtKeypair: Keypair; + let cmt: PublicKey; + let payerKeypair: Keypair; + let payer: PublicKey; + let connection: Connection; + let provider: AnchorProvider; + + const MAX_SIZE = 64; + const MAX_DEPTH = 14; + const DEPTH_SIZE_PAIR: ValidDepthSizePair = { + maxBufferSize: MAX_SIZE, + maxDepth: MAX_DEPTH, + }; + beforeEach(async () => { + payerKeypair = Keypair.generate(); + payer = payerKeypair.publicKey; + connection = new Connection('http://127.0.0.1:8899', { + commitment: 'confirmed', + }); + const wallet = new NodeWallet(payerKeypair); + provider = new AnchorProvider(connection, wallet, { + commitment: connection.commitment, + skipPreflight: true, + }); + + await provider.connection.confirmTransaction( + await provider.connection.requestAirdrop(payer, 1e10), + 'confirmed' + ); + }); + + describe('Unit test transferAuthority', () => { + const authorityKeypair = Keypair.generate(); + const authority = authorityKeypair.publicKey; + const randomSignerKeypair = Keypair.generate(); + const randomSigner = randomSignerKeypair.publicKey; + + beforeEach(async () => { + await provider.connection.confirmTransaction( + await (connection as Connection).requestAirdrop(authority, 1e10) + ); + [cmtKeypair, offChainTree] = await createTreeOnChain(provider, authorityKeypair, 1, DEPTH_SIZE_PAIR); + cmt = cmtKeypair.publicKey; + }); + it('Attempting to replace with random authority fails', async () => { + const newLeaf = crypto.randomBytes(32); + const replaceIndex = 0; + const proof = offChainTree.getProof(replaceIndex); + const replaceIx = createReplaceIx(cmt, randomSigner, newLeaf, proof); + + try { + await execute(provider, [replaceIx], [randomSignerKeypair]); + assert(false, 'Transaction should have failed since incorrect authority cannot execute replaces'); + } catch { + assert(true); + } + }); + it('Can transfer authority', async () => { + const transferAuthorityIx = createTransferAuthorityIx(cmt, authority, randomSigner); + await execute(provider, [transferAuthorityIx], [authorityKeypair]); + + const splCMT = await ConcurrentMerkleTreeAccount.fromAccountAddress(connection, cmt); + + assert( + splCMT.getAuthority().equals(randomSigner), + `Upon transfering authority, authority should be ${randomSigner.toString()}, but was instead updated to ${splCMT.getAuthority()}` + ); + + // Attempting to replace with new authority now works + const newLeaf = crypto.randomBytes(32); + const replaceIndex = 0; + const proof = offChainTree.getProof(replaceIndex); + const replaceIx = createReplaceIx(cmt, randomSigner, newLeaf, proof); + + await execute(provider, [replaceIx], [randomSignerKeypair]); + }); + }); +}); diff --git a/account-compression/sdk/tests/merkleTree.test.ts b/account-compression/sdk/tests/merkleTree.test.ts index 68f177066f1..ff38c6f8ed6 100644 --- a/account-compression/sdk/tests/merkleTree.test.ts +++ b/account-compression/sdk/tests/merkleTree.test.ts @@ -1,34 +1,32 @@ -import { assert } from "chai"; -import * as crypto from "crypto"; +import { assert } from 'chai'; +import * as crypto from 'crypto'; -import { emptyNode, MerkleTree } from "../src"; +import { emptyNode, MerkleTree } from '../src'; -describe("MerkleTree tests", () => { - it("Check constructor equivalence for depth 2 tree", () => { - const leaves = [ - crypto.randomBytes(32), - crypto.randomBytes(32), - crypto.randomBytes(32), - ]; - const rawLeaves = leaves.concat(emptyNode(0)); - const merkleTreeRaw = new MerkleTree(rawLeaves); - const merkleTreeSparse = MerkleTree.sparseMerkleTreeFromLeaves(leaves, 2); +// This is used to test the MerkleTree helper class that we +// export to help with the creation and debugging of proofs +describe('MerkleTree tests', () => { + it('Check constructor equivalence for depth 2 tree', () => { + const leaves = [crypto.randomBytes(32), crypto.randomBytes(32), crypto.randomBytes(32)]; + const rawLeaves = leaves.concat(emptyNode(0)); + const merkleTreeRaw = new MerkleTree(rawLeaves); + const merkleTreeSparse = MerkleTree.sparseMerkleTreeFromLeaves(leaves, 2); - assert(merkleTreeRaw.root.equals(merkleTreeSparse.root)); - }); + assert(merkleTreeRaw.root.equals(merkleTreeSparse.root)); + }); - const TEST_DEPTH = 14; - it(`Check proofs for 2^${TEST_DEPTH} tree`, () => { - const leaves: Buffer[] = []; - for (let i = 0; i < 2 ** TEST_DEPTH; i++) { - leaves.push(crypto.randomBytes(32)); - } - const merkleTree = new MerkleTree(leaves); + const TEST_DEPTH = 14; + it(`Check proofs for 2^${TEST_DEPTH} tree`, () => { + const leaves: Buffer[] = []; + for (let i = 0; i < 2 ** TEST_DEPTH; i++) { + leaves.push(crypto.randomBytes(32)); + } + const merkleTree = new MerkleTree(leaves); - // Check proofs - for (let i = 0; i < leaves.length; i++) { - const proof = merkleTree.getProof(i); - assert(MerkleTree.verify(merkleTree.getRoot(), proof)); - } - }); + // Check proofs + for (let i = 0; i < leaves.length; i++) { + const proof = merkleTree.getProof(i); + assert(MerkleTree.verify(merkleTree.getRoot(), proof)); + } + }); }); diff --git a/account-compression/sdk/tsconfig.json b/account-compression/sdk/tsconfig.json index 986f3ac403b..326c3d629d6 100644 --- a/account-compression/sdk/tsconfig.json +++ b/account-compression/sdk/tsconfig.json @@ -8,6 +8,7 @@ "declaration": true, "declarationMap": true, "target": "ES2016", - "module": "CommonJS" + "module": "CommonJS", + "types": ["jest", "node"] } } diff --git a/account-compression/sdk/yarn.lock b/account-compression/sdk/yarn.lock index bd8c6d8c4d4..6f98078898a 100644 --- a/account-compression/sdk/yarn.lock +++ b/account-compression/sdk/yarn.lock @@ -1042,10 +1042,10 @@ dependencies: "@types/istanbul-lib-report" "*" -"@types/jest@^29.0.0": - version "29.0.0" - resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.0.0.tgz#bc66835bf6b09d6a47e22c21d7f5b82692e60e72" - integrity sha512-X6Zjz3WO4cT39Gkl0lZ2baFRaEMqJl5NC1OjElkwtNzAlbkr2K/WJXkBkH5VP0zx4Hgsd2TZYdOEfvp2Dxia+Q== +"@types/jest@^29.5.8": + version "29.5.8" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.8.tgz#ed5c256fe2bc7c38b1915ee5ef1ff24a3427e120" + integrity sha512-fXEFTxMV2Co8ZF5aYFJv+YeA08RTYJfhtN5c9JSv/mFEMe+xxjufCb+PHL+bJcMs/ebPUsBu+UNTEz+ydXrR6g== dependencies: expect "^29.0.0" pretty-format "^29.0.0" @@ -1060,6 +1060,11 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== +"@types/mocha@^10.0.4": + version "10.0.4" + resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.4.tgz#b5331955ebca216604691fd4fcd2dbdc2bd559a4" + integrity sha512-xKU7bUjiFTIttpWaIZ9qvgg+22O1nmbA+HRxdlR+u6TWsGfmFdXrheJoK4fFxrHNVIOBDvDNKZG+LYBpMHpX3w== + "@types/node-fetch@^2.6.2": version "2.6.2" resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.2.tgz#d1a9c5fd049d9415dce61571557104dec3ec81da"