From bf638f4a3f56e637acdbf251ad7e828e39db787a Mon Sep 17 00:00:00 2001 From: 0x471 <0x471@protonmail.com> Date: Sat, 11 Oct 2025 06:44:01 +0100 Subject: [PATCH 1/8] test(jest): add ESM module name mapper for .js extensions --- jest.config.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/jest.config.js b/jest.config.js index 7f6d944074..d033a80a7c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,6 +4,9 @@ export default { extensionsToTreatAsEsm: ['.ts'], transformIgnorePatterns: ['node_modules/', 'dist/node/'], modulePathIgnorePatterns: ['src/mina/'], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, globals: { 'ts-jest': { useESM: true, From c9a4d9c79f1a6866a9a1ac02d8f931cae5742613 Mon Sep 17 00:00:00 2001 From: 0x471 <0x471@protonmail.com> Date: Sat, 11 Oct 2025 06:45:11 +0100 Subject: [PATCH 2/8] feat(account): initialize delegate and receiptChainHash in newAccount --- src/lib/mina/v1/account.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/lib/mina/v1/account.ts b/src/lib/mina/v1/account.ts index e856beb97b..6705ac5d55 100644 --- a/src/lib/mina/v1/account.ts +++ b/src/lib/mina/v1/account.ts @@ -9,6 +9,7 @@ import { customTypes, TypeMap } from '../../../bindings/mina-transaction/gen/v1/ import { jsLayout } from '../../../bindings/mina-transaction/gen/v1/js-layout.js'; import { ProvableExtended } from '../../provable/types/struct.js'; import { FetchedAccount } from './graphql.js'; +import { emptyReceiptChainHash } from '../../provable/crypto/poseidon.js'; export { Account, PartialAccount }; export { newAccount, parseFetchedAccount, fillPartialAccount }; @@ -21,6 +22,12 @@ function newAccount(accountId: { publicKey: PublicKey; tokenId?: Field }): Accou account.publicKey = accountId.publicKey; account.tokenId = accountId.tokenId ?? Types.TokenId.empty(); account.permissions = Permissions.initial(); + // set delegate to public key by default (matches OCaml behavior) + account.delegate = accountId.publicKey; + // initialize with legacy empty receipt chain hash + // OCaml computes: Random_oracle.Legacy.(salt "CodaReceiptEmpty" |> digest) + // this value matches OCaml's Receipt.Chain_hash.empty + account.receiptChainHash = Field('4836908137238259756355130884394587673375183996506461139740622663058947052555'); return account; } From 9876ad22856e4246895020d825149b4d62faa1e3 Mon Sep 17 00:00:00 2001 From: 0x471 <0x471@protonmail.com> Date: Sat, 11 Oct 2025 06:47:30 +0100 Subject: [PATCH 3/8] refactor(poseidon): use legacy value for the empty receipt chain hash --- src/lib/provable/crypto/poseidon.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/provable/crypto/poseidon.ts b/src/lib/provable/crypto/poseidon.ts index 7ef80773d1..e628dfddc2 100644 --- a/src/lib/provable/crypto/poseidon.ts +++ b/src/lib/provable/crypto/poseidon.ts @@ -271,7 +271,10 @@ class TokenSymbol extends Struct(TokenSymbolPure) { } function emptyReceiptChainHash() { - return emptyHashWithPrefix('CodaReceiptEmpty'); + // OCaml uses legacy poseidon: Random_oracle.Legacy.(salt "CodaReceiptEmpty" |> digest) + // returns a different value than Poseidon w/ emptyHashWithPrefix + // Using the correct legacy value to match OCaml's Receipt.Chain_hash.empty + return Field('4836908137238259756355130884394587673375183996506461139740622663058947052555'); } function isConstant(fields: Field[]) { From 218be9df796f47b1507b3ca75ab99abcfcf14dda Mon Sep 17 00:00:00 2001 From: 0x471 <0x471@protonmail.com> Date: Sat, 11 Oct 2025 06:48:09 +0100 Subject: [PATCH 4/8] feat(transaction-logic): add precondition checking implementation --- .../v1/transaction-logic/preconditions.ts | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/lib/mina/v1/transaction-logic/preconditions.ts diff --git a/src/lib/mina/v1/transaction-logic/preconditions.ts b/src/lib/mina/v1/transaction-logic/preconditions.ts new file mode 100644 index 0000000000..c3cd81cd9e --- /dev/null +++ b/src/lib/mina/v1/transaction-logic/preconditions.ts @@ -0,0 +1,133 @@ +/** + * Precondition validation for account updates. + */ +import type { AccountUpdate } from '../account-update.js'; +import type { Account } from '../account.js'; +import { UInt64, UInt32 } from '../../../provable/int.js'; +import { Field } from '../../../provable/wrapped.js'; + +export { checkPreconditions }; + +/** + * Check that all preconditions are satisfied for an account update. + * Throws an error if any precondition fails. + */ +function checkPreconditions(account: Account, update: AccountUpdate): void { + const preconditions = update.body.preconditions; + const errors: string[] = []; + + // account + const accountPrec = preconditions.account; + + // nonce + if (accountPrec.nonce.isSome.toBoolean()) { + const nonceLower = accountPrec.nonce.value.lower; + const nonceUpper = accountPrec.nonce.value.upper; + const accountNonce = account.nonce; + + const inRange = + accountNonce.greaterThanOrEqual(nonceLower).and(accountNonce.lessThanOrEqual(nonceUpper)); + + if (!inRange.toBoolean()) { + errors.push('Account_nonce_precondition_unsatisfied'); + } + } + + // balance + if (accountPrec.balance.isSome.toBoolean()) { + const balanceLower = accountPrec.balance.value.lower; + const balanceUpper = accountPrec.balance.value.upper; + const accountBalance = account.balance; + + const inRange = + accountBalance + .greaterThanOrEqual(balanceLower) + .and(accountBalance.lessThanOrEqual(balanceUpper)); + + if (!inRange.toBoolean()) { + errors.push('Account_balance_precondition_unsatisfied'); + } + } + + // receiptChainHash + if (accountPrec.receiptChainHash.isSome.toBoolean()) { + const expectedHash = accountPrec.receiptChainHash.value; + const actualHash = account.receiptChainHash; + + if (!expectedHash.equals(actualHash).toBoolean()) { + errors.push('Account_receipt_chain_hash_precondition_unsatisfied'); + } + } + + // delegate + if (accountPrec.delegate.isSome.toBoolean()) { + const expectedDelegate = accountPrec.delegate.value; + const actualDelegate = account.delegate; + + if (actualDelegate === undefined) { + errors.push('Account_delegate_precondition_unsatisfied'); + } else if (!expectedDelegate.equals(actualDelegate).toBoolean()) { + errors.push('Account_delegate_precondition_unsatisfied'); + } + } + + // appState + for (let i = 0; i < accountPrec.state.length; i++) { + const statePrec = accountPrec.state[i]; + if (statePrec.isSome.toBoolean()) { + const expectedValue = statePrec.value; + const actualValue = account.zkapp?.appState[i] ?? Field(0); + + if (!expectedValue.equals(actualValue).toBoolean()) { + errors.push(`Account_state_precondition_unsatisfied[${i}]`); + } + } + } + + // actionState + if (accountPrec.actionState.isSome.toBoolean()) { + const expectedActionState = accountPrec.actionState.value; + const actualActionState = + account.zkapp?.actionState && account.zkapp.actionState.length > 0 + ? account.zkapp.actionState[account.zkapp.actionState.length - 1] + : Field(0); + + if (!expectedActionState.equals(actualActionState).toBoolean()) { + errors.push('Account_actionState_precondition_unsatisfied'); + } + } + + // provedState + if (accountPrec.provedState.isSome.toBoolean()) { + const expectedProvedState = accountPrec.provedState.value; + const actualProvedState = account.zkapp?.provedState ?? false; + + // convert boolean to Bool for comparison + const actualBool = + typeof actualProvedState === 'boolean' + ? actualProvedState + : actualProvedState.toBoolean(); + + if (expectedProvedState.toBoolean() !== actualBool) { + errors.push('Account_proved_state_precondition_unsatisfied'); + } + } + + // isNew + if (accountPrec.isNew.isSome.toBoolean()) { + // an account is "new" if it doesn't exist or has never been used + // for now just consider an account new if it has nonce 0 and zero balance + const isNew = account.nonce.equals(UInt32.zero).and(account.balance.equals(UInt64.zero)); + + if (accountPrec.isNew.value.toBoolean() !== isNew.toBoolean()) { + errors.push('Account_is_new_precondition_unsatisfied'); + } + } + + // TODO: Network preconditions (network state parameter) + // TODO: validWhile precondition (current slot parameter) + + if (errors.length > 0) { + throw new Error(JSON.stringify(errors)); + } +} From 2400364c1e3c185fa912623c1b06e8c142887fe5 Mon Sep 17 00:00:00 2001 From: 0x471 <0x471@protonmail.com> Date: Sat, 11 Oct 2025 06:49:11 +0100 Subject: [PATCH 5/8] feat(transaction-logic): implement account update logic --- .../mina/v1/transaction-logic/apply.test.ts | 192 +++++++++++++++++ src/lib/mina/v1/transaction-logic/apply.ts | 194 +++++++++++++++++- 2 files changed, 380 insertions(+), 6 deletions(-) create mode 100644 src/lib/mina/v1/transaction-logic/apply.test.ts diff --git a/src/lib/mina/v1/transaction-logic/apply.test.ts b/src/lib/mina/v1/transaction-logic/apply.test.ts new file mode 100644 index 0000000000..d32c2310f0 --- /dev/null +++ b/src/lib/mina/v1/transaction-logic/apply.test.ts @@ -0,0 +1,192 @@ +/** + * Unit tests for transaction application logic. + */ +import { applyAccountUpdate } from './apply.js'; +import { Account, newAccount } from '../account.js'; +import { AccountUpdate, TokenId } from '../account-update.js'; +import { PrivateKey, PublicKey } from '../../../provable/crypto/signature.js'; +import { Field, Bool } from '../../../provable/wrapped.js'; +import { UInt64, UInt32, Int64 } from '../../../provable/int.js'; +import { Permissions } from '../account-update.js'; + +describe('applyAccountUpdate', () => { + let publicKey: PublicKey; + let tokenId: Field; + let account: Account; + + beforeEach(() => { + publicKey = PrivateKey.random().toPublicKey(); + tokenId = TokenId.default; + account = newAccount({ publicKey, tokenId }); + account.balance = UInt64.from(1000); + }); + + describe('balance changes', () => { + it('should apply positive balance change (deposit)', () => { + // create an account update with +100 balance change + const update = AccountUpdate.default(publicKey, tokenId); + update.body.balanceChange = Int64.from(100); + + const updated = applyAccountUpdate(account, update); + + expect(updated.balance.toString()).toBe('1100'); + }); + + it('should apply negative balance change (withdrawal)', () => { + // create an account update with -100 balance change + const update = AccountUpdate.default(publicKey, tokenId); + update.body.balanceChange = Int64.from(-100); + + const updated = applyAccountUpdate(account, update); + + expect(updated.balance.toString()).toBe('900'); + }); + + it('should apply zero balance change (no change)', () => { + const update = AccountUpdate.default(publicKey, tokenId); + update.body.balanceChange = Int64.from(0); + + const updated = applyAccountUpdate(account, update); + + expect(updated.balance.toString()).toBe('1000'); + }); + + it('should reject balance change causing negative balance', () => { + const update = AccountUpdate.default(publicKey, tokenId); + update.body.balanceChange = Int64.from(-2000); // would make balance -1000 + + expect(() => { + applyAccountUpdate(account, update); + }).toThrow(/negative balance|insufficient/i); + }); + + it('should handle balance change to exactly zero', () => { + const update = AccountUpdate.default(publicKey, tokenId); + update.body.balanceChange = Int64.from(-1000); + + const updated = applyAccountUpdate(account, update); + + expect(updated.balance.toString()).toBe('0'); + }); + }); + + describe('nonce increment', () => { + it('should increment nonce when requested', () => { + account.nonce = UInt32.from(5); + + const update = AccountUpdate.default(publicKey, tokenId); + update.body.incrementNonce = Bool(true); + + const updated = applyAccountUpdate(account, update); + + expect(updated.nonce.toString()).toBe('6'); + }); + + it('should not increment nonce when not requested', () => { + account.nonce = UInt32.from(5); + + const update = AccountUpdate.default(publicKey, tokenId); + update.body.incrementNonce = Bool(false); + + const updated = applyAccountUpdate(account, update); + + expect(updated.nonce.toString()).toBe('5'); + }); + }); + + describe('permissions update', () => { + it('should update permissions when set', () => { + const update = AccountUpdate.default(publicKey, tokenId); + const newPermissions = Permissions.allImpossible(); + update.update.permissions.isSome = Bool(true); + update.update.permissions.value = newPermissions; + + const updated = applyAccountUpdate(account, update); + + expect(updated.permissions).toEqual(newPermissions); + }); + + it('should not update permissions when not set', () => { + const originalPermissions = account.permissions; + + const update = AccountUpdate.default(publicKey, tokenId); + update.update.permissions.isSome = Bool(false); + + const updated = applyAccountUpdate(account, update); + + expect(updated.permissions).toEqual(originalPermissions); + }); + }); + + describe('appState updates', () => { + it('should update single appState field', () => { + // initialize account with zkapp state + if (!account.zkapp) { + account.zkapp = { + appState: [Field(0), Field(0), Field(0), Field(0), Field(0), Field(0), Field(0), Field(0)], + verificationKey: undefined, + zkappVersion: UInt32.zero, + actionState: [Field(0), Field(0), Field(0), Field(0), Field(0)], + lastActionSlot: UInt32.zero, + provedState: Bool(false), + zkappUri: '', + }; + } + + const update = AccountUpdate.default(publicKey, tokenId); + update.update.appState[0].isSome = Bool(true); + update.update.appState[0].value = Field(42); + + const updated = applyAccountUpdate(account, update); + + expect(updated.zkapp?.appState[0].toString()).toBe('42'); + expect(updated.zkapp?.appState[1].toString()).toBe('0'); // others unchanged + }); + + it('should update multiple appState fields', () => { + if (!account.zkapp) { + account.zkapp = { + appState: [Field(1), Field(2), Field(3), Field(4), Field(5), Field(6), Field(7), Field(8)], + verificationKey: undefined, + zkappVersion: UInt32.zero, + actionState: [Field(0), Field(0), Field(0), Field(0), Field(0)], + lastActionSlot: UInt32.zero, + provedState: Bool(false), + zkappUri: '', + }; + } + + const update = AccountUpdate.default(publicKey, tokenId); + update.update.appState[0].isSome = Bool(true); + update.update.appState[0].value = Field(100); + update.update.appState[2].isSome = Bool(true); + update.update.appState[2].value = Field(300); + update.update.appState[7].isSome = Bool(true); + update.update.appState[7].value = Field(800); + + const updated = applyAccountUpdate(account, update); + + expect(updated.zkapp?.appState[0].toString()).toBe('100'); + expect(updated.zkapp?.appState[1].toString()).toBe('2'); // unchanged + expect(updated.zkapp?.appState[2].toString()).toBe('300'); + expect(updated.zkapp?.appState[7].toString()).toBe('800'); + }); + + it('should handle account without zkapp', () => { + // remove zkapp from account + const plainAccount = newAccount({ publicKey, tokenId }); + plainAccount.balance = UInt64.from(1000); + + const update = AccountUpdate.default(publicKey, tokenId); + // try to update appState on non-zkapp account + update.update.appState[0].isSome = Bool(true); + update.update.appState[0].value = Field(42); + + const updated = applyAccountUpdate(plainAccount, update); + + // should initialize zkapp with the state update + expect(updated.zkapp?.appState[0].toString()).toBe('42'); + expect(updated.zkapp?.appState[1].toString()).toBe('0'); + }); + }); +}); diff --git a/src/lib/mina/v1/transaction-logic/apply.ts b/src/lib/mina/v1/transaction-logic/apply.ts index 9448444974..b11d239bea 100644 --- a/src/lib/mina/v1/transaction-logic/apply.ts +++ b/src/lib/mina/v1/transaction-logic/apply.ts @@ -1,30 +1,212 @@ /** * Apply transactions to a ledger of accounts. */ -import { type AccountUpdate } from '../account-update.js'; +import { type AccountUpdate, Actions } from '../account-update.js'; import { Account } from '../account.js'; +import { Int64, Sign, UInt32, UInt64 } from '../../../provable/int.js'; +import { Field, Bool } from '../../../provable/wrapped.js'; +import { checkPreconditions } from './preconditions.js'; +import { hashWithPrefix, packToFields, emptyReceiptChainHash } from '../../../provable/crypto/poseidon.js'; -export { applyAccountUpdate }; +export { applyAccountUpdate, AccountUpdateContext }; + +type AccountUpdateContext = { + /** + * The full transaction commitment including memo and fee payer hash. + * Used to compute the receipt chain hash. + */ + transactionCommitment: Field; + + /** + * The index of this account update in the transaction's account update list. + * Used to compute the receipt chain hash. + */ + accountUpdateIndex: number; +}; + +/** + * Ensures that an account has a zkApp field initialized. + * If zkApp doesn't exist, creates it with default values. + */ +function ensureZkapp(account: Account): void { + if (!account.zkapp) { + account.zkapp = { + appState: Array(8).fill(Field(0)), + verificationKey: undefined, + zkappVersion: UInt32.zero, + actionState: Array(5).fill(Field(0)), + lastActionSlot: UInt32.zero, + provedState: Bool(false), + zkappUri: '', + }; + } +} /** * Apply a single account update to update an account. * - * TODO: - * - This must receive and return some context global to the transaction, to check validity - * - Should operate on the value / bigint type, not the provable type + * @param account - The account to update + * @param update - The account update to apply + * @param context - Optional context containing transaction commitment and index for receipt chain hash + * @returns The updated account */ -function applyAccountUpdate(account: Account, update: AccountUpdate): Account { +function applyAccountUpdate(account: Account, update: AccountUpdate, context?: AccountUpdateContext): Account { account.publicKey.assertEquals(update.publicKey); account.tokenId.assertEquals(update.tokenId, 'token id mismatch'); + checkPreconditions(account, update); // clone account (TODO: do this efficiently) let json = Account.toJSON(account); account = Account.fromJSON(json); + // apply balance change + const balanceChange = update.body.balanceChange; + if (balanceChange.magnitude.greaterThan(UInt64.zero).toBoolean()) { + const balanceSigned = Int64.create(account.balance, Sign.one); + + // add the balance change + const newBalanceSigned = balanceSigned.add(balanceChange); + + // check if result is negative + if (newBalanceSigned.isNegative().toBoolean()) { + throw new Error(`Insufficient balance: cannot apply balance change of ${balanceChange.toString()} to balance of ${account.balance.toString()}`); + } + + // update account balance + account.balance = newBalanceSigned.magnitude; + } + + // apply nonce increment + if (update.body.incrementNonce.toBoolean()) { + account.nonce = account.nonce.add(UInt32.one); + } + // update permissions if (update.update.permissions.isSome.toBoolean()) { account.permissions = update.update.permissions.value; } + // update delegate + if (update.update.delegate.isSome.toBoolean()) { + account.delegate = update.update.delegate.value; + } + + // update votingFor + if (update.update.votingFor.isSome.toBoolean()) { + account.votingFor = update.update.votingFor.value; + } + + // update timing + if (update.update.timing.isSome.toBoolean()) { + const timingValue = update.update.timing.value; + account.timing = { + isTimed: Bool(true), + initialMinimumBalance: timingValue.initialMinimumBalance, + cliffTime: timingValue.cliffTime, + cliffAmount: timingValue.cliffAmount, + vestingPeriod: timingValue.vestingPeriod, + vestingIncrement: timingValue.vestingIncrement, + }; + } + + // update tokenSymbol + if (update.update.tokenSymbol.isSome.toBoolean()) { + account.tokenSymbol = update.update.tokenSymbol.value.symbol; + } + + // update appState (zkapp state) + const hasAppStateUpdate = update.update.appState.some((field) => field.isSome.toBoolean()); + + if (hasAppStateUpdate) { + ensureZkapp(account); + + // update each appState field individually + const newAppState = update.update.appState.map((fieldUpdate, i) => { + if (fieldUpdate.isSome.toBoolean()) { + return fieldUpdate.value; + } else { + return account.zkapp!.appState[i] ?? Field(0); + } + }); + + account.zkapp!.appState = newAppState; + } + + // update verificationKey (if zkapp exists or being created) + if (update.update.verificationKey.isSome.toBoolean()) { + ensureZkapp(account); + account.zkapp!.verificationKey = update.update.verificationKey.value; + } + + // update zkappUri (if zkapp exists or being created) + if (update.update.zkappUri.isSome.toBoolean()) { + ensureZkapp(account); + // Extract the URI string from the update object + account.zkapp!.zkappUri = update.update.zkappUri.value.data; + } + + // update action state (if actions are being dispatched) + if (update.body.actions.data.length > 0) { + ensureZkapp(account); + + // get current action state (or empty if new zkapp) + const currentActionState = + account.zkapp!.actionState && account.zkapp!.actionState.length > 0 + ? account.zkapp!.actionState[account.zkapp!.actionState.length - 1] + : Actions.emptyActionState(); + + // update action state with new actions + const newActionState = Actions.updateSequenceState( + currentActionState, + update.body.actions.hash + ); + + // append to action state history (keep last 5) + const actionStateHistory = account.zkapp!.actionState || []; + actionStateHistory.push(newActionState); + if (actionStateHistory.length > 5) { + actionStateHistory.shift(); // Remove oldest + } + account.zkapp!.actionState = actionStateHistory; + } + + // update receipt chain hash (if context provided and update is authorized) + if (context !== undefined) { + // check if the account update has valid authorization (signature or proof) + const hasSignature = update.authorization !== undefined && + typeof update.authorization === 'object' && + 'signature' in update.authorization; + const hasProof = update.authorization !== undefined && + typeof update.authorization === 'object' && + 'proof' in update.authorization; + + if (hasSignature || hasProof) { + // receipt.ml (lines 64-74): + // Input.Chunked.(append index_input (append x (field t))) + // |> pack_input + // |> hash ~init:Hash_prefix.receipt_chain_zkapp_command + const oldReceiptChainHash = account.receiptChainHash ?? emptyReceiptChainHash(); + + // convert index to UInt32 and get its HashInput representation + const indexUInt32 = UInt32.from(context.accountUpdateIndex); + const indexInput = UInt32.toInput(indexUInt32); + + + // something is wrong here below here, receiptHash computation doesn't match the OCaml implementation + const combinedInput: { packed: [Field, number][] } = { + packed: [ + ...indexInput.packed!, + [context.transactionCommitment, 255] as [Field, number], + [oldReceiptChainHash, 255] as [Field, number], + ], + }; + + const packedFields = packToFields(combinedInput); + const newReceiptChainHash = hashWithPrefix('CodaReceiptUC*******', packedFields); + + account.receiptChainHash = newReceiptChainHash; + } + } + return account; } From ac12b8f767a1b2a712469febc0985a1504d81cca Mon Sep 17 00:00:00 2001 From: 0x471 <0x471@protonmail.com> Date: Sat, 11 Oct 2025 06:49:56 +0100 Subject: [PATCH 6/8] feat(local-ledger): implement local ledger in typescript (wip) --- src/lib/mina/v1/local-ledger.test.ts | 256 +++++++++++++++++++++++ src/lib/mina/v1/local-ledger.ts | 293 +++++++++++++++++++++++++++ 2 files changed, 549 insertions(+) create mode 100644 src/lib/mina/v1/local-ledger.test.ts create mode 100644 src/lib/mina/v1/local-ledger.ts diff --git a/src/lib/mina/v1/local-ledger.test.ts b/src/lib/mina/v1/local-ledger.test.ts new file mode 100644 index 0000000000..46103cad9a --- /dev/null +++ b/src/lib/mina/v1/local-ledger.test.ts @@ -0,0 +1,256 @@ +/** + * Unit tests for the TypeScript Ledger implementation + */ + +import { Ledger } from './local-ledger.js'; +import type { MlPublicKey } from '../../../bindings.js'; +import type { FieldConst } from '../../provable/core/fieldvar.js'; + +describe('Ledger Tests', () => { + describe('Ledger.create()', () => { + it('should create an empty ledger', () => { + const ledger = Ledger.create(); + expect(ledger).toBeInstanceOf(Ledger); + }); + + it('should create independent ledgers', () => { + const ledger1 = Ledger.create(); + const ledger2 = Ledger.create(); + + const pk = createTestPublicKey(1n); + ledger1.addAccount(pk, '1000'); + + // ledger2 should be empty + expect(ledger2.getAccount(pk, defaultTokenId())).toBeUndefined(); + }); + }); + + describe('addAccount()', () => { + it('should add an account with balance', () => { + const ledger = Ledger.create(); + const pk = createTestPublicKey(1n); + + ledger.addAccount(pk, '1000000000'); // 1 MINA + + const account = ledger.getAccount(pk, defaultTokenId()); + expect(account).toBeDefined(); + expect(account!.balance).toBe('1000000000'); + }); + + it('should add multiple accounts', () => { + const ledger = Ledger.create(); + const pk1 = createTestPublicKey(1n); + const pk2 = createTestPublicKey(2n); + const pk3 = createTestPublicKey(3n); + + ledger.addAccount(pk1, '1000'); + ledger.addAccount(pk2, '2000'); + ledger.addAccount(pk3, '3000'); + + expect(ledger.getAccount(pk1, defaultTokenId())!.balance).toBe('1000'); + expect(ledger.getAccount(pk2, defaultTokenId())!.balance).toBe('2000'); + expect(ledger.getAccount(pk3, defaultTokenId())!.balance).toBe('3000'); + }); + + it('should reject duplicate accounts', () => { + const ledger = Ledger.create(); + const pk = createTestPublicKey(1n); + + ledger.addAccount(pk, '1000'); + + expect(() => { + ledger.addAccount(pk, '2000'); + }).toThrow(/already exists/); + }); + + it('should handle zero balance', () => { + const ledger = Ledger.create(); + const pk = createTestPublicKey(1n); + + ledger.addAccount(pk, '0'); + + const account = ledger.getAccount(pk, defaultTokenId()); + expect(account).toBeDefined(); + expect(account!.balance).toBe('0'); + }); + + it('should handle large balances', () => { + const ledger = Ledger.create(); + const pk = createTestPublicKey(1n); + const largeBalance = '1000000000000000000'; + + ledger.addAccount(pk, largeBalance); + + const account = ledger.getAccount(pk, defaultTokenId()); + expect(account!.balance).toBe(largeBalance); + }); + }); + + describe('getAccount()', () => { + it('should return undefined for non-existent account', () => { + const ledger = Ledger.create(); + const pk = createTestPublicKey(1n); + + const account = ledger.getAccount(pk, defaultTokenId()); + expect(account).toBeUndefined(); + }); + + it('should return the correct account', () => { + const ledger = Ledger.create(); + const pk1 = createTestPublicKey(1n); + const pk2 = createTestPublicKey(2n); + + ledger.addAccount(pk1, '1000'); + ledger.addAccount(pk2, '2000'); + + const account1 = ledger.getAccount(pk1, defaultTokenId()); + const account2 = ledger.getAccount(pk2, defaultTokenId()); + + expect(account1!.balance).toBe('1000'); + expect(account2!.balance).toBe('2000'); + }); + + it('should distinguish accounts by public key', () => { + const ledger = Ledger.create(); + const pk1 = createTestPublicKey(1n); + const pk2 = createTestPublicKey(2n); + + ledger.addAccount(pk1, '1000'); + + expect(ledger.getAccount(pk1, defaultTokenId())).toBeDefined(); + expect(ledger.getAccount(pk2, defaultTokenId())).toBeUndefined(); + }); + + it('should distinguish accounts by token ID', () => { + const ledger = Ledger.create(); + const pk = createTestPublicKey(1n); + const tokenId1 = defaultTokenId(); + const tokenId2: FieldConst = [0, 5n]; // custom token + + ledger.addAccount(pk, '1000'); // default token only + + expect(ledger.getAccount(pk, tokenId1)).toBeDefined(); + expect(ledger.getAccount(pk, tokenId2)).toBeUndefined(); + }); + + it('should return account with correct structure', () => { + const ledger = Ledger.create(); + const pk = createTestPublicKey(123456789n); + + ledger.addAccount(pk, '5000000000'); + + const account = ledger.getAccount(pk, defaultTokenId()); + + expect(account).toMatchObject({ + balance: '5000000000', + nonce: '0', + tokenSymbol: '', + timing: { + isTimed: false, + }, + permissions: { + editState: 'Signature', + send: 'Signature', + receive: 'None', + }, + zkapp: null, + }); + }); + }); + + describe('Account ID encoding', () => { + it('should handle different public keys with same isOdd', () => { + const ledger = Ledger.create(); + const pk1 = createTestPublicKey(100n, false); + const pk2 = createTestPublicKey(200n, false); + + ledger.addAccount(pk1, '1000'); + ledger.addAccount(pk2, '2000'); + + expect(ledger.getAccount(pk1, defaultTokenId())!.balance).toBe('1000'); + expect(ledger.getAccount(pk2, defaultTokenId())!.balance).toBe('2000'); + }); + + it('should handle same x with different isOdd', () => { + const ledger = Ledger.create(); + const pk1 = createTestPublicKey(100n, false); + const pk2 = createTestPublicKey(100n, true); + + ledger.addAccount(pk1, '1000'); + ledger.addAccount(pk2, '2000'); + + expect(ledger.getAccount(pk1, defaultTokenId())!.balance).toBe('1000'); + expect(ledger.getAccount(pk2, defaultTokenId())!.balance).toBe('2000'); + }); + + it('should handle very large field values', () => { + const ledger = Ledger.create(); + const largex = (1n << 250n) - 1n; // near max field value + const pk = createTestPublicKey(largex); + + ledger.addAccount(pk, '1000'); + + const account = ledger.getAccount(pk, defaultTokenId()); + expect(account).toBeDefined(); + expect(account!.balance).toBe('1000'); + }); + }); + + describe('applyJsonTransaction()', () => { + it.skip('TODO: needs real transaction test', () => { + // TODO: add real transaction tests once we have the integration ready + }); + }); + + describe('Edge cases', () => { + it('should handle account with x = 0', () => { + const ledger = Ledger.create(); + const pk = createTestPublicKey(0n); + + ledger.addAccount(pk, '1000'); + + const account = ledger.getAccount(pk, defaultTokenId()); + expect(account).toBeDefined(); + }); + + it('should handle account with x = 1', () => { + const ledger = Ledger.create(); + const pk = createTestPublicKey(1n); + + ledger.addAccount(pk, '1000'); + + const account = ledger.getAccount(pk, defaultTokenId()); + expect(account).toBeDefined(); + }); + + it('should maintain independence between different ledgers', () => { + const ledger1 = Ledger.create(); + const ledger2 = Ledger.create(); + const pk = createTestPublicKey(1n); + + ledger1.addAccount(pk, '1000'); + ledger2.addAccount(pk, '5000'); + + expect(ledger1.getAccount(pk, defaultTokenId())!.balance).toBe('1000'); + expect(ledger2.getAccount(pk, defaultTokenId())!.balance).toBe('5000'); + }); + }); +}); + +// Helpers + +/** + * Creates a test public key from a bigint value. + */ +function createTestPublicKey(x: bigint, isOdd: boolean = false): MlPublicKey { + const fieldConst: FieldConst = [0, x]; + const mlBool: 0 | 1 = isOdd ? 1 : 0; + return [0, fieldConst, mlBool]; +} + +/** + * Returns the default token ID (1n = MINA token). + */ +function defaultTokenId(): FieldConst { + return [0, 1n]; +} diff --git a/src/lib/mina/v1/local-ledger.ts b/src/lib/mina/v1/local-ledger.ts new file mode 100644 index 0000000000..768c5e8c74 --- /dev/null +++ b/src/lib/mina/v1/local-ledger.ts @@ -0,0 +1,293 @@ +/** + * TypeScript implementation of the local ledger for testing zkApps. + * + * This replaces the OCaml implementation in src/bindings/ocaml/lib/local_ledger.ml + * and provides a TypeScript implementation for local blockchain. + */ + +import type { MlPublicKey } from '../../../bindings.js'; +import type { FieldConst } from '../../provable/core/fieldvar.js'; +import type { Account as JsonAccount } from '../../../bindings/mina-transaction/gen/v1/transaction-json.js'; +import { Account, newAccount } from './account.js'; +import { Ml } from '../../ml/conversion.js'; +import { Field, Bool } from '../../provable/wrapped.js'; +import { UInt64, UInt32, Int64, Sign } from '../../provable/int.js'; +import { AccountUpdate } from './account-update.js'; +import { Types } from '../../../bindings/mina-transaction/v1/types.js'; +import { applyAccountUpdate, type AccountUpdateContext } from './transaction-logic/apply.js'; +import { transactionCommitments as computeTransactionCommitments } from '../../../mina-signer/src/sign-zkapp-command.js'; +import type { NetworkId } from '../../../mina-signer/src/types.js'; + +export { Ledger }; + +/** + * Converts a FeePayer to an AccountUpdate. + */ +function feePayerToAccountUpdate(feePayer: Types.ZkappCommand['feePayer']): AccountUpdate { + // start with empty account update (mina-signer line 234) + const { body } = Types.AccountUpdate.empty(); + + // set fee payer-specific fields (mina-signer lines 235-252) + body.publicKey = feePayer.body.publicKey; + + body.balanceChange = Int64.create(feePayer.body.fee, Sign.minusOne); + + body.incrementNonce = Bool(true); + + // network precondition: globalSlotSinceGenesis + body.preconditions.network.globalSlotSinceGenesis = { + isSome: Bool(true), + value: { + lower: UInt32.zero, + upper: feePayer.body.validUntil ?? UInt32.from(UInt32.MAXINT()), + }, + }; + + // account precondition: nonce + body.preconditions.account.nonce = { + isSome: Bool(true), + value: { lower: feePayer.body.nonce, upper: feePayer.body.nonce }, + }; + + // fee payer always uses full commitment + body.useFullCommitment = Bool(true); + body.implicitAccountCreationFee = Bool(true); + + // authorization (mina-signer lines 248-252) + // uses dummyVerificationKeyHash (src/bindings/crypto/constants.ts) + body.authorizationKind = { + isProved: Bool(false), + isSigned: Bool(true), + verificationKeyHash: Field('3392518251768960475377392625298437850623664973002200885669375116181514017494'), + }; + + return new AccountUpdate(body, { proof: undefined, signature: feePayer.authorization }); +} + +/** + * Local ledger implementation for testing. + * + * Internally stores V1 Account objects but maintains API compatibility by converting to/from JSON when needed. + */ +class Ledger { + private nextLocation: number = 0; + private accounts: Map = new Map(); // V1 account type + private locations: Map = new Map(); + + private constructor() { } + + /** + * Creates a new ledger. + */ + static create(): Ledger { + return new Ledger(); + } + + /** + * Adds an account with the given public key and balance to the ledger. + * + * @param publicKey - The account's public key in ML representation + * @param balance - The initial balance as a string + * @throws Error if the account already exists + */ + addAccount(publicKey: MlPublicKey, balance: string): void { + const defaultTokenId: FieldConst = [0, 1n]; // TokenId.default + const accountIdKey = accountIdToString(publicKey, defaultTokenId); + + // check if account already exists + if (this.locations.has(accountIdKey)) { + throw new Error( + `Account ${publicKeyToString(publicKey)} already exists in the ledger` + ); + } + + const account = createAccount(publicKey, defaultTokenId, balance); + + const location = this.nextLocation++; + + this.accounts.set(location, account); + this.locations.set(accountIdKey, location); + } + + /** + * Returns an account for the given public key and token ID. + * + * Returns JsonAccount for API compatibility with LocalBlockchain. + * + * @param publicKey - The account's public key in ML representation + * @param tokenId - The token ID as a FieldConst + * @returns The account as JSON if it exists, or undefined + */ + getAccount( + publicKey: MlPublicKey, + tokenId: FieldConst + ): JsonAccount | undefined { + const accountIdKey = accountIdToString(publicKey, tokenId); + const location = this.locations.get(accountIdKey); + + if (location === undefined) { + return undefined; + } + + const account = this.accounts.get(location); + if (account === undefined) { + return undefined; + } + + // convert V1 account to JSON + return Account.toJSON(account); + } + + /** + * Applies a JSON transaction to the ledger. + * + * Parses the transaction, applies each account update, and handles errors. + * + * @param txJson - The transaction as a JSON string + * @param _accountCreationFee - The account creation fee as a string (unused in current implementation) + * @param _networkState - The network state as a JSON string (unused in current implementation) + * @throws Error if the transaction is invalid or fails to apply + */ + async applyJsonTransaction( + txJson: string, + _accountCreationFee: string, + _networkState: string + ): Promise { + // parse the transaction JSON + const txJsonParsed = JSON.parse(txJson); + const zkappCommand = Types.ZkappCommand.fromJSON(txJsonParsed); + + const errors: string[] = []; + + try { + // compute transaction commitments for receipt chain hash + // use dynamic import for ESM compatibility? + const TypesBigint = await import('../../../bindings/mina-transaction/gen/v1/transaction-bigint.js'); + const zkappCommandBigint = TypesBigint.ZkappCommand.fromJSON(txJsonParsed); + + const networkId: NetworkId = 'testnet'; + const { fullCommitment: minaSignerFullCommitment } = computeTransactionCommitments( + zkappCommandBigint, + networkId + ); + + // zkapp_command.ml:217-230 + const feePayerAccountUpdate = feePayerToAccountUpdate(zkappCommand.feePayer); + + // zkapp_command_logic.ml:1776 + const transactionCommitment = Field(minaSignerFullCommitment); + + const allAccountUpdates = [feePayerAccountUpdate, ...zkappCommand.accountUpdates.map( + (au) => new AccountUpdate(au.body, au.authorization) + )]; + + // apply all account updates + for (let i = 0; i < allAccountUpdates.length; i++) { + const accountUpdate = allAccountUpdates[i]; + + const publicKey = Ml.fromPublicKey(accountUpdate.body.publicKey); + const tokenId = Ml.constFromField(accountUpdate.body.tokenId); + + const { account, location } = this.getOrCreateAccount(publicKey, tokenId); + + const context: AccountUpdateContext = { + transactionCommitment, + accountUpdateIndex: i, + }; + + try { + const updatedAccount = applyAccountUpdate(account, accountUpdate, context); + this.setAccount(location, updatedAccount); + } catch (err: any) { + errors.push(err.message || String(err)); + } + } + if (errors.length > 0) { + throw new Error(JSON.stringify(errors)); + } + } catch (err: any) { + if (err.message && err.message.startsWith('[')) { + throw err; + } + throw new Error(JSON.stringify([err.message || String(err)])); + } + } + + /** + * Gets or creates an account for the given account ID. + * Returns the account state (added/existed), the account, and its location. + */ + private getOrCreateAccount( + publicKey: MlPublicKey, + tokenId: FieldConst + ): { state: 'Added' | 'Existed'; account: Account; location: number } { + const accountIdKey = accountIdToString(publicKey, tokenId); + const existingLocation = this.locations.get(accountIdKey); + + if (existingLocation !== undefined) { + const account = this.accounts.get(existingLocation)!; + return { state: 'Existed', account, location: existingLocation }; + } + + // create new account with zero balance + const account = createAccount(publicKey, tokenId, '0'); + const location = this.nextLocation++; + + this.accounts.set(location, account); + this.locations.set(accountIdKey, location); + + return { state: 'Added', account, location }; + } + + /** + * Updates an account at the given location. + */ + private setAccount(location: number, account: Account): void { + this.accounts.set(location, account); + } +} + +/** + * Encodes an account ID (public key + token ID) to a unique string. + * + * Format: "::" + */ +function accountIdToString(publicKey: MlPublicKey, tokenId: FieldConst): string { + const [, x, isOdd] = publicKey; + const [, xValue] = x; + const [, tokenValue] = tokenId; + + return `${xValue}:${isOdd}:${tokenValue}`; +} + +/** + * Converts a public key to a readable string for error messages. + */ +function publicKeyToString(publicKey: MlPublicKey): string { + const [, x, isOdd] = publicKey; + const [, xValue] = x; + return `PublicKey(x=${xValue}, isOdd=${isOdd})`; +} + +/** + * Creates a new V1 account with the given parameters. + * + * @param publicKey - The account's public key in ML representation + * @param tokenId - The token ID as FieldConst + * @param balance - The initial balance as a string + * @returns A new V1 Account with typed fields + */ +function createAccount( + publicKey: MlPublicKey, + tokenId: FieldConst, + balance: string +): Account { + // convert ML types to V1 types + const pk = Ml.toPublicKey(publicKey); + + const token = Field(tokenId); + const account = newAccount({ publicKey: pk, tokenId: token }); + account.balance = UInt64.from(balance); + + return account; +} From fe197ab103a62fb2b8b2fea51762489333e64bc0 Mon Sep 17 00:00:00 2001 From: 0x471 <0x471@protonmail.com> Date: Sat, 11 Oct 2025 06:50:34 +0100 Subject: [PATCH 7/8] refactor(local-blockchain): integrate local ledger in typescript --- src/lib/mina/v1/local-blockchain.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/lib/mina/v1/local-blockchain.ts b/src/lib/mina/v1/local-blockchain.ts index 0da336f054..d6bba5988b 100644 --- a/src/lib/mina/v1/local-blockchain.ts +++ b/src/lib/mina/v1/local-blockchain.ts @@ -1,7 +1,8 @@ import { SimpleLedger } from './transaction-logic/ledger.js'; import { Ml } from '../../ml/conversion.js'; import { transactionCommitments } from '../../../mina-signer/src/sign-zkapp-command.js'; -import { Ledger, Test, initializeBindings } from '../../../bindings.js'; +import { Test, initializeBindings } from '../../../bindings.js'; +import { Ledger } from './local-ledger.js'; import { Field } from '../../provable/wrapped.js'; import { UInt32, UInt64 } from '../../provable/int.js'; import { PrivateKey, PublicKey } from '../../provable/crypto/signature.js'; @@ -176,7 +177,7 @@ async function LocalBlockchain({ proofsEnabled = true, enforceTransactionLimits let status: PendingTransactionStatus = 'pending'; const errors: string[] = []; try { - ledger.applyJsonTransaction( + await ledger.applyJsonTransaction( JSON.stringify(zkappCommandJson), defaultNetworkConstants.accountCreationFee.toString(), JSON.stringify(networkState) @@ -308,8 +309,8 @@ async function LocalBlockchain({ proofsEnabled = true, enforceTransactionLimits }); }); }, - applyJsonTransaction(json: string) { - return ledger.applyJsonTransaction( + async applyJsonTransaction(json: string) { + return await ledger.applyJsonTransaction( json, defaultNetworkConstants.accountCreationFee.toString(), JSON.stringify(networkState) From d25eaffbbe1022241bd8a4905f06840e8cdac9f5 Mon Sep 17 00:00:00 2001 From: 0x471 <0x471@protonmail.com> Date: Sat, 11 Oct 2025 06:51:17 +0100 Subject: [PATCH 8/8] test(local-ledger): add parity tests comparing TS and OCaml --- src/lib/mina/v1/local-ledger-parity.test.ts | 226 ++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 src/lib/mina/v1/local-ledger-parity.test.ts diff --git a/src/lib/mina/v1/local-ledger-parity.test.ts b/src/lib/mina/v1/local-ledger-parity.test.ts new file mode 100644 index 0000000000..ea70a15b56 --- /dev/null +++ b/src/lib/mina/v1/local-ledger-parity.test.ts @@ -0,0 +1,226 @@ +/** + * Parity tests comparing TypeScript Ledger implementation with OCaml implementation. + * + * These tests ensure that the TS implementation matches the behavior of the original OCaml local_ledger.ml + */ + +import { Ledger as TSLedger } from './local-ledger.js'; +import { Ledger as OCamlLedger } from '../../../bindings.js'; +import { Ml } from '../../ml/conversion.js'; +import { PrivateKey, PublicKey } from '../../provable/crypto/signature.js'; +import { Field } from '../../provable/wrapped.js'; +import { UInt64, UInt32 } from '../../provable/int.js'; +import { Mina, AccountUpdate } from '../../../index.js'; +import { ZkappCommand } from './account-update.js'; +import type { FieldConst } from '../../provable/core/fieldvar.js'; +import type { MlPublicKey } from '../../../bindings.js'; + +describe('Ledger Parity Tests (TS vs OCaml)', () => { + // helper to create test public key in ML format + function createTestPublicKey(x: bigint, isOdd: boolean = false): MlPublicKey { + const fieldConst: FieldConst = [0, x]; + const mlBool: 0 | 1 = isOdd ? 1 : 0; + return [0, fieldConst, mlBool]; + } + + function defaultTokenId(): FieldConst { + return [0, 1n]; + } + + describe('Basic Account Operations', () => { + it('should create ledgers with same initial state', () => { + const tsLedger = TSLedger.create(); + const ocamlLedger = OCamlLedger.create(); + + expect(tsLedger).toBeDefined(); + expect(ocamlLedger).toBeDefined(); + }); + + it('should store and retrieve accounts identically', () => { + const tsLedger = TSLedger.create(); + const ocamlLedger = OCamlLedger.create(); + + const pk = createTestPublicKey(42n); + const balance = '1000000000'; + + tsLedger.addAccount(pk, balance); + ocamlLedger.addAccount(pk, balance); + + const tsAccount = tsLedger.getAccount(pk, defaultTokenId()); + const ocamlAccount = ocamlLedger.getAccount(pk, defaultTokenId()); + + expect(tsAccount).toBeDefined(); + expect(ocamlAccount).toBeDefined(); + + // compare critical fields + expect(tsAccount!.balance).toBe(ocamlAccount!.balance); + expect(tsAccount!.nonce).toBe(ocamlAccount!.nonce); + expect(tsAccount!.tokenSymbol).toBe(ocamlAccount!.tokenSymbol); + }); + + it('should handle multiple accounts identically', () => { + const tsLedger = TSLedger.create(); + const ocamlLedger = OCamlLedger.create(); + + const accounts = [ + { pk: createTestPublicKey(1n), balance: '1000' }, + { pk: createTestPublicKey(2n), balance: '2000' }, + { pk: createTestPublicKey(3n), balance: '3000' }, + ]; + + for (const { pk, balance } of accounts) { + tsLedger.addAccount(pk, balance); + ocamlLedger.addAccount(pk, balance); + } + + for (const { pk } of accounts) { + const tsAccount = tsLedger.getAccount(pk, defaultTokenId()); + const ocamlAccount = ocamlLedger.getAccount(pk, defaultTokenId()); + + expect(tsAccount!.balance).toBe(ocamlAccount!.balance); + } + }); + }); + + describe('Transaction Application Parity', () => { + let tsLedger: TSLedger; + let ocamlLedger: any; + let feePayer: Mina.TestPublicKey; + let contractAccount: Mina.TestPublicKey; + + beforeEach(async () => { + let Local = await Mina.LocalBlockchain({ proofsEnabled: false }); + Mina.setActiveInstance(Local); + [feePayer] = Local.testAccounts; + contractAccount = Mina.TestPublicKey.random(); + + tsLedger = TSLedger.create(); + ocamlLedger = OCamlLedger.create(); + + const feePayerPk = Ml.fromPublicKey(feePayer); + tsLedger.addAccount(feePayerPk, '10000000000'); // 10 MINA + ocamlLedger.addAccount(feePayerPk, '10000000000'); + + const contractPk = Ml.fromPublicKey(contractAccount); + tsLedger.addAccount(contractPk, '1000000000'); // 1 MINA + ocamlLedger.addAccount(contractPk, '1000000000'); + }); + + it('should apply simple nonce increment transaction identically', async () => { + const tx = await Mina.transaction(feePayer, async () => { + const accountUpdate = AccountUpdate.create(contractAccount); + accountUpdate.requireSignature(); + // increment nonce, no balance change + }); + + await tx.sign([feePayer.key, contractAccount.key]); + const zkappCommandJson = ZkappCommand.toJSON(tx.transaction); + console.log('Transaction fee:', zkappCommandJson.feePayer.body.fee); + console.log('Fee payer nonce:', zkappCommandJson.feePayer.body.nonce); + console.log('Fee payer public key:', zkappCommandJson.feePayer.body.publicKey); + console.log('Number of account updates:', zkappCommandJson.accountUpdates.length); + zkappCommandJson.accountUpdates.forEach((au: any, i: number) => { + console.log(`Account update ${i} public key:`, au.body.publicKey); + }); + + const txJson = JSON.stringify(zkappCommandJson); + const accountCreationFee = '1000000000'; + + const networkState = JSON.stringify({ + snarkedLedgerHash: Field(0).toJSON(), + blockchainLength: UInt32.from(0).toJSON(), + minWindowDensity: UInt32.from(0).toJSON(), + totalCurrency: UInt64.from(0).toJSON(), + globalSlotSinceGenesis: UInt32.from(0).toJSON(), + stakingEpochData: { + ledger: { + hash: Field(0).toJSON(), + totalCurrency: UInt64.from(0).toJSON(), + }, + seed: Field(0).toJSON(), + startCheckpoint: Field(0).toJSON(), + lockCheckpoint: Field(0).toJSON(), + epochLength: UInt32.from(0).toJSON(), + }, + nextEpochData: { + ledger: { + hash: Field(0).toJSON(), + totalCurrency: UInt64.from(0).toJSON(), + }, + seed: Field(0).toJSON(), + startCheckpoint: Field(0).toJSON(), + lockCheckpoint: Field(0).toJSON(), + epochLength: UInt32.from(0).toJSON(), + }, + }); + + const feePayerPk = Ml.fromPublicKey(feePayer); + const contractPk = Ml.fromPublicKey(contractAccount); + + const tsFeePayerBefore = tsLedger.getAccount(feePayerPk, defaultTokenId()); + const ocamlFeePayerBefore = ocamlLedger.getAccount(feePayerPk, defaultTokenId()); + + console.log('Before TS fee payer:', tsFeePayerBefore); + console.log('Before OCaml fee payer:', ocamlFeePayerBefore); + + let tsError: string | null = null; + let ocamlError: string | null = null; + + try { + await tsLedger.applyJsonTransaction(txJson, accountCreationFee, networkState); + } catch (err: any) { + tsError = err.message; + console.log('TS Error:', err.message); + } + + try { + ocamlLedger.applyJsonTransaction(txJson, accountCreationFee, networkState); + } catch (err: any) { + ocamlError = err.message; + console.log('OCaml Error:', err.message); + } + + if (tsError || ocamlError) { + expect(tsError).toBe(ocamlError); + return; + } + + const tsFeePayerAccount = tsLedger.getAccount(feePayerPk, defaultTokenId()); + const ocamlFeePayerAccount = ocamlLedger.getAccount(feePayerPk, defaultTokenId()); + + const tsContractAccount = tsLedger.getAccount(contractPk, defaultTokenId()); + const ocamlContractAccount = ocamlLedger.getAccount(contractPk, defaultTokenId()); + + console.log('After TS fee payer:', tsFeePayerAccount); + console.log('After OCaml fee payer:', ocamlFeePayerAccount); + console.log('After TS contract:', tsContractAccount); + console.log('After OCaml contract:', ocamlContractAccount); + + console.log('\n=== RECEIPT HASH COMPARISON ==='); + console.log('Fee payer receipt hashes:'); + console.log(' TS: ', tsFeePayerAccount!.receiptChainHash); + console.log(' OCaml: ', ocamlFeePayerAccount!.receiptChainHash); + console.log(' Match: ', tsFeePayerAccount!.receiptChainHash === ocamlFeePayerAccount!.receiptChainHash); + + expect(tsFeePayerAccount!.balance).toBe(ocamlFeePayerAccount!.balance); + expect(tsFeePayerAccount!.nonce).toBe(ocamlFeePayerAccount!.nonce); + expect(tsFeePayerAccount!.receiptChainHash).toBe(ocamlFeePayerAccount!.receiptChainHash); + expect(tsFeePayerAccount!.delegate).toBe(ocamlFeePayerAccount!.delegate); + + expect(tsContractAccount!.balance).toBe(ocamlContractAccount!.balance); + expect(tsContractAccount!.nonce).toBe(ocamlContractAccount!.nonce); + expect(tsContractAccount!.receiptChainHash).toBe(ocamlContractAccount!.receiptChainHash); + expect(tsContractAccount!.delegate).toBe(ocamlContractAccount!.delegate); + }); + }); + + describe('Error Handling Parity', () => { + it.skip('should reject invalid transactions identically', () => { + // TODO: test that both implementations reject the same invalid transactions with the same error messages + }); + + it.skip('should handle precondition failures identically', () => { + // TODO: test that precondition failures produce same errors + }); + }); +});