Skip to content
This repository was archived by the owner on Jul 30, 2025. It is now read-only.

Commit 87aa4be

Browse files
authored
Validate account against onchain data (#249)
1 parent 8189048 commit 87aa4be

6 files changed

Lines changed: 221 additions & 6 deletions

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { it, expect, describe, beforeEach, vi } from "vitest";
2+
import { AccountOnchain } from "../../src/state/accountOnchain";
3+
import { AccountStateMerkleIndexed } from "../../src/state/types";
4+
import { IContract } from "../../src/chain/contract";
5+
import { AccountNotOnChainError } from "../../src/errors";
6+
import {
7+
Scalar,
8+
scalarToBigint
9+
} from "@cardinal-cryptography/shielder-sdk-crypto";
10+
import { nativeToken } from "../../src/utils";
11+
import { ContractFunctionRevertedError, encodeErrorResult } from "viem";
12+
13+
describe("AccountOnchain", () => {
14+
let accountOnchain: AccountOnchain;
15+
let mockContract: IContract;
16+
let testAccountState: AccountStateMerkleIndexed;
17+
18+
beforeEach(() => {
19+
// Create a mock contract
20+
mockContract = {
21+
getMerklePath: vi.fn()
22+
} as any;
23+
24+
// Create test account state
25+
testAccountState = {
26+
id: Scalar.fromBigint(123n),
27+
token: nativeToken(),
28+
nonce: 1n,
29+
balance: 1000n,
30+
currentNote: Scalar.fromBigint(456n),
31+
currentNoteIndex: 10n
32+
};
33+
34+
accountOnchain = new AccountOnchain(mockContract);
35+
});
36+
37+
describe("constructor", () => {
38+
it("should create an instance with the provided contract", () => {
39+
expect(accountOnchain).toBeInstanceOf(AccountOnchain);
40+
});
41+
});
42+
43+
describe("validateAccountState", () => {
44+
it("should validate successfully when merkle path matches current note", async () => {
45+
// Setup: Mock getMerklePath to return a path where first element matches currentNote
46+
const expectedMerklePath = [
47+
scalarToBigint(testAccountState.currentNote), // First element matches
48+
789n,
49+
101112n
50+
] as const;
51+
52+
vi.mocked(mockContract.getMerklePath).mockResolvedValue(
53+
expectedMerklePath
54+
);
55+
56+
// Act & Assert: Should not throw
57+
await expect(
58+
accountOnchain.validateAccountState(testAccountState)
59+
).resolves.toBeUndefined();
60+
61+
// Verify contract was called with correct index
62+
expect(mockContract.getMerklePath).toHaveBeenCalledWith(
63+
testAccountState.currentNoteIndex
64+
);
65+
expect(mockContract.getMerklePath).toHaveBeenCalledTimes(1);
66+
});
67+
68+
it("should throw AccountNotOnChainError when merkle path does not match current note", async () => {
69+
// Setup: Mock getMerklePath to return a path where first element does NOT match
70+
const mismatchedMerklePath = [
71+
999n, // Different from currentNote (456n)
72+
789n,
73+
101112n
74+
] as const;
75+
76+
vi.mocked(mockContract.getMerklePath).mockResolvedValue(
77+
mismatchedMerklePath
78+
);
79+
80+
// Act & Assert: Should throw AccountNotOnChainError
81+
await expect(
82+
accountOnchain.validateAccountState(testAccountState)
83+
).rejects.toThrow(AccountNotOnChainError);
84+
85+
await expect(
86+
accountOnchain.validateAccountState(testAccountState)
87+
).rejects.toThrow(
88+
`Account state with merkle index ${testAccountState.currentNoteIndex} does not match on-chain data.`
89+
);
90+
91+
// Verify contract was called with correct index
92+
expect(mockContract.getMerklePath).toHaveBeenCalledWith(
93+
testAccountState.currentNoteIndex
94+
);
95+
});
96+
97+
it("should throw AccountNotOnChainError when contract getMerklePath fails", async () => {
98+
// Setup: Mock getMerklePath to throw an error
99+
// const contractError = new Error("Contract connection failed");
100+
const contractError = new ContractFunctionRevertedError({
101+
abi: [
102+
{
103+
type: "error",
104+
name: "LeafNotExisting",
105+
inputs: []
106+
}
107+
],
108+
functionName: "getMerklePath",
109+
data: encodeErrorResult({
110+
abi: [
111+
{
112+
type: "error",
113+
name: "LeafNotExisting",
114+
inputs: []
115+
}
116+
],
117+
errorName: "LeafNotExisting"
118+
})
119+
});
120+
vi.mocked(mockContract.getMerklePath).mockRejectedValue(contractError);
121+
122+
// Act & Assert: Should throw AccountNotOnChainError with specific message
123+
await expect(
124+
accountOnchain.validateAccountState(testAccountState)
125+
).rejects.toThrow(AccountNotOnChainError);
126+
127+
await expect(
128+
accountOnchain.validateAccountState(testAccountState)
129+
).rejects.toThrow(
130+
`Account state with index ${testAccountState.currentNoteIndex} does not exist on-chain.`
131+
);
132+
133+
expect(mockContract.getMerklePath).toHaveBeenCalledWith(
134+
testAccountState.currentNoteIndex
135+
);
136+
});
137+
});
138+
});

ts/shielder-sdk/__tests/state/sync/synchronizer.test.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { StateSynchronizer } from "../../../src/state/sync/synchronizer";
33
import { AccountRegistry } from "../../../src/state/accountRegistry";
44
import { TokenAccountFinder } from "../../../src/state/sync/tokenAccountFinder";
55
import {
6-
AccountState,
76
AccountStateMerkleIndexed,
87
ShielderTransaction
98
} from "../../../src/state/types";
@@ -24,6 +23,7 @@ describe("StateSynchronizer", () => {
2423
let mockGetTokenByAccountIndex: any;
2524
let mockFindStateTransition: any;
2625
let mockFindTokenByAccountIndex: any;
26+
let mockValidateAccountState: any;
2727

2828
beforeEach(() => {
2929
mockState = {
@@ -55,11 +55,15 @@ describe("StateSynchronizer", () => {
5555
} as any;
5656

5757
mockSyncCallback = vi.fn();
58+
mockValidateAccountState = vi.fn().mockResolvedValue(undefined);
5859

5960
stateSynchronizer = new StateSynchronizer(
6061
mockAccountRegistry,
6162
mockChainStateTransition,
6263
mockTokenAccountFinder,
64+
{
65+
validateAccountState: mockValidateAccountState
66+
} as any,
6367
mockSyncCallback
6468
);
6569
});
@@ -90,7 +94,8 @@ describe("StateSynchronizer", () => {
9094
amount: 50n,
9195
txHash: "0x1",
9296
block: 1n,
93-
token: nativeToken()
97+
token: nativeToken(),
98+
newNote: Scalar.fromBigint(100n)
9499
};
95100

96101
mockGetAccountState.mockResolvedValue(null);
@@ -117,6 +122,9 @@ describe("StateSynchronizer", () => {
117122
expect(mockFindStateTransition).toHaveBeenNthCalledWith(2, newState);
118123

119124
expect(mockSyncCallback).toHaveBeenCalledWith(transaction);
125+
126+
// validateAccountState should NOT be called when no existing state is found
127+
expect(mockValidateAccountState).not.toHaveBeenCalled();
120128
});
121129

122130
it("should sync single state transition", async () => {
@@ -133,7 +141,8 @@ describe("StateSynchronizer", () => {
133141
amount: 50n,
134142
txHash: "0x1",
135143
block: 1n,
136-
token: nativeToken()
144+
token: nativeToken(),
145+
newNote: Scalar.fromBigint(200n)
137146
};
138147

139148
mockFindStateTransition
@@ -153,6 +162,10 @@ describe("StateSynchronizer", () => {
153162
expect(mockFindStateTransition).toHaveBeenNthCalledWith(2, newState);
154163

155164
expect(mockSyncCallback).toHaveBeenCalledWith(transaction);
165+
166+
// validateAccountState should be called once with the existing state
167+
expect(mockValidateAccountState).toHaveBeenCalledTimes(1);
168+
expect(mockValidateAccountState).toHaveBeenCalledWith(mockState);
156169
});
157170

158171
it("should sync multiple state transitions", async () => {
@@ -177,7 +190,8 @@ describe("StateSynchronizer", () => {
177190
amount: 50n,
178191
txHash: "0x1",
179192
block: 1n,
180-
token: nativeToken()
193+
token: nativeToken(),
194+
newNote: Scalar.fromBigint(200n)
181195
};
182196

183197
const tx2: ShielderTransaction = {
@@ -187,7 +201,8 @@ describe("StateSynchronizer", () => {
187201
relayerFee: 1n,
188202
txHash: "0x2",
189203
block: 2n,
190-
token: nativeToken()
204+
token: nativeToken(),
205+
newNote: Scalar.fromBigint(300n)
191206
};
192207

193208
mockFindStateTransition
@@ -218,6 +233,10 @@ describe("StateSynchronizer", () => {
218233
expect(mockSyncCallback).toHaveBeenCalledTimes(2);
219234
expect(mockSyncCallback).toHaveBeenNthCalledWith(1, tx1);
220235
expect(mockSyncCallback).toHaveBeenNthCalledWith(2, tx2);
236+
237+
// validateAccountState should be called once with the existing state
238+
expect(mockValidateAccountState).toHaveBeenCalledTimes(1);
239+
expect(mockValidateAccountState).toHaveBeenCalledWith(mockState);
221240
});
222241
});
223242

ts/shielder-sdk/src/client/factories.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from "@/storage/storageSchema";
2424
import { LocalStateTransition } from "@/state/localStateTransition";
2525
import { ChainStateTransition } from "@/state/sync/chainStateTransition";
26+
import { AccountOnchain } from "@/state/accountOnchain";
2627

2728
// Base config with common properties
2829
type BaseShielderConfig = {
@@ -204,10 +205,14 @@ function createSyncComponents(
204205
config.cryptoClient,
205206
config.idManager
206207
);
208+
209+
const accountOnchain = new AccountOnchain(config.contract);
210+
207211
const stateSynchronizer = new StateSynchronizer(
208212
config.accountRegistry,
209213
chainStateTransition,
210214
tokenAccountFinder,
215+
accountOnchain,
211216
config.callbacks?.onNewTransaction
212217
);
213218
const historyFetcher = new HistoryFetcher(

ts/shielder-sdk/src/errors.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,9 @@ export class OutdatedSdkError extends CustomError {
55
super(message);
66
}
77
}
8+
9+
export class AccountNotOnChainError extends CustomError {
10+
public constructor(message: string) {
11+
super(message);
12+
}
13+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { IContract } from "@/chain/contract";
2+
import { AccountStateMerkleIndexed } from "./types";
3+
import { scalarToBigint } from "@cardinal-cryptography/shielder-sdk-crypto";
4+
import { AccountNotOnChainError } from "@/errors";
5+
import { BaseError, ContractFunctionRevertedError } from "viem";
6+
7+
export class AccountOnchain {
8+
constructor(private contract: IContract) {}
9+
10+
async validateAccountState(
11+
accountState: AccountStateMerkleIndexed
12+
): Promise<void> {
13+
let merklePath: readonly bigint[] = [];
14+
try {
15+
merklePath = await this.contract.getMerklePath(
16+
accountState.currentNoteIndex
17+
);
18+
} catch (error) {
19+
if (error instanceof BaseError) {
20+
const revertError = error.walk(
21+
(err) => err instanceof ContractFunctionRevertedError
22+
);
23+
if (revertError instanceof ContractFunctionRevertedError) {
24+
const errorName = revertError.data?.errorName ?? "";
25+
if (errorName === "LeafNotExisting") {
26+
throw new AccountNotOnChainError(
27+
`Account state with index ${accountState.currentNoteIndex} does not exist on-chain.`
28+
);
29+
}
30+
}
31+
}
32+
throw error;
33+
}
34+
35+
if (!merklePath.includes(scalarToBigint(accountState.currentNote))) {
36+
throw new AccountNotOnChainError(
37+
`Account state with merkle index ${accountState.currentNoteIndex} does not match on-chain data.`
38+
);
39+
}
40+
}
41+
}

ts/shielder-sdk/src/state/sync/synchronizer.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
ShielderTransaction
1010
} from "../types";
1111
import { firstAccountIndex } from "@/constants";
12+
import { AccountOnchain } from "../accountOnchain";
1213

1314
export class StateSynchronizer {
1415
private singleTokenMutex: Mutex = new Mutex();
@@ -17,6 +18,7 @@ export class StateSynchronizer {
1718
private accountRegistry: AccountRegistry,
1819
private chainStateTransition: ChainStateTransition,
1920
private tokenAccountFinder: TokenAccountFinder,
21+
private accountOnchain: AccountOnchain,
2022
private syncCallback?: (shielderTransaction: ShielderTransaction) => unknown
2123
) {}
2224

@@ -48,8 +50,12 @@ export class StateSynchronizer {
4850
*/
4951
async syncSingleAccount(token: Token) {
5052
await this.singleTokenMutex.runExclusive(async () => {
53+
const stateExisting = await this.accountRegistry.getAccountState(token);
54+
if (stateExisting) {
55+
await this.accountOnchain.validateAccountState(stateExisting);
56+
}
5157
let state: AccountState =
52-
(await this.accountRegistry.getAccountState(token)) ??
58+
stateExisting ??
5359
(await this.accountRegistry.createEmptyAccountState(token));
5460
while (true) {
5561
const stateTransition =

0 commit comments

Comments
 (0)