Skip to content

Commit 36b7caf

Browse files
committed
test(e2e): SNIP-12 CallSet signer deposit through the dapp client on devnet
1 parent 7ca537f commit 36b7caf

1 file changed

Lines changed: 210 additions & 0 deletions

File tree

e2e/tests/devnet/signing.test.ts

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
2+
import { constants, ec, num } from "starknet";
3+
import type { TypedData } from "starknet";
4+
import { Devnet } from "@starkware-libs/starknet-privacy-sdk/testing";
5+
import type {
6+
Paymaster,
7+
PaymasterBuild,
8+
PaymasterCall,
9+
PaymasterExecute,
10+
PaymasterQuote,
11+
PrivacyClient,
12+
} from "@starkware-libs/starknet-privacy-client";
13+
import { Snip12CallSetSigner } from "@starkware-libs/starknet-privacy-client/signers";
14+
import {
15+
makeSdkWalletClient,
16+
tokenBalance,
17+
broadcastAppliedActions,
18+
} from "../../src/signing-client.js";
19+
import { createE2eTestEnv, type E2eTestEnv } from "../../src/harness.js";
20+
import { E2E_TIMEOUTS } from "../../src/timeouts.js";
21+
22+
/**
23+
* A legacy SN wallet (e.g. Fordefi) authorizes a privacy operation by signing the SNIP-12 `CallSet`
24+
* message — not the synthetic proving transaction. It consumes the dapp client (`Snip12CallSetSigner`
25+
* behind `CorePrivateTransfersProver` + `SdkWallet`), so this drives a real deposit through the actual
26+
* client stack: `client.build().with(token).deposit(...).submit()`.
27+
*
28+
* Only the paymaster is mocked — devnet has no AVNU, so the mock quotes a pool-funded fee, fronts the
29+
* deposit's approve directly as alice (the token owner — a devnet stand-in for the paymaster relaying
30+
* it; `SdkWallet` still signMessage-signs the approve typed data, but this simplified mock does not
31+
* consume that signature — the EVM test's `execute_from_outside_v2` does), and broadcasts the proven
32+
* `apply_actions` call with an ordinary account (`executeOutside`) — the public part a real paymaster
33+
* performs. The pool authorizes the deposit via `is_valid_signature(compute_call_set_hash(...))`
34+
* (case III), reproduced off-chain by the mock prover's `compile_actions_authorized` path — so a
35+
* deposit signed by alice's own key succeeds, and one signed by any other key is rejected during proving.
36+
*/
37+
describe("dapp client: SNIP-12 CallSet signer deposit on devnet", () => {
38+
let devnet: Devnet;
39+
let env: E2eTestEnv;
40+
41+
const AMOUNT = 100n;
42+
const FEE = 1n;
43+
const KEPT = AMOUNT - FEE;
44+
45+
beforeAll(async () => {
46+
devnet = new Devnet();
47+
env = await createE2eTestEnv(devnet, {
48+
indexer: { logFile: "signing-snip12-indexer.log" },
49+
});
50+
}, E2E_TIMEOUTS.hook);
51+
52+
afterAll(async () => {
53+
await env?.indexer.shutdown();
54+
await devnet?.cleanup();
55+
});
56+
57+
/** Alice's STARK private key, read from the devnet's predeployed accounts. */
58+
async function alicePrivateKey(): Promise<string> {
59+
const response = await fetch(devnet.url, {
60+
method: "POST",
61+
headers: { "Content-Type": "application/json" },
62+
body: JSON.stringify({
63+
jsonrpc: "2.0",
64+
id: 1,
65+
method: "devnet_getPredeployedAccounts",
66+
}),
67+
});
68+
const { result } = (await response.json()) as {
69+
result: Array<{ address: string; private_key: string }>;
70+
};
71+
const account = result.find(
72+
(candidate) =>
73+
BigInt(candidate.address) === BigInt(env.env.alice.address),
74+
);
75+
if (!account) throw new Error("alice not found among predeployed accounts");
76+
return account.private_key;
77+
}
78+
79+
// Stand-in for the paymaster's approve typed data — SdkWallet has the user signMessage it.
80+
const APPROVE_TYPED_DATA: TypedData = {
81+
domain: { name: "Privacy", version: "1", chainId: "TEST", revision: "1" },
82+
primaryType: "Approve",
83+
types: {
84+
StarknetDomain: [
85+
{ name: "name", type: "shortstring" },
86+
{ name: "version", type: "shortstring" },
87+
{ name: "chainId", type: "shortstring" },
88+
{ name: "revision", type: "shortstring" },
89+
],
90+
Approve: [{ name: "spender", type: "ContractAddress" }],
91+
},
92+
message: { spender: "0x0" },
93+
};
94+
95+
/**
96+
* A devnet stand-in for the AVNU paymaster (no SNIP-29 endpoint, no fee sponsorship). It quotes a
97+
* tiny pool-funded fee, and on execute runs the deposit's `approve` as the user (a real paymaster
98+
* relays it in the user-signed invoke) then broadcasts the proven `apply_actions` call with an
99+
* ordinary account via `executeOutside`.
100+
*/
101+
function mockPaymaster(): Paymaster {
102+
const { admin, strk, node } = env.env;
103+
let approveCalls: PaymasterCall[] = [];
104+
return {
105+
async buildTransaction(build: PaymasterBuild): Promise<PaymasterQuote> {
106+
approveCalls = build.kind === "invokeAndApplyAction" ? build.calls : [];
107+
return {
108+
feeAction: {
109+
type: "withdraw",
110+
recipient: admin.address,
111+
token: strk,
112+
amount: num.toHex(FEE),
113+
},
114+
typedData:
115+
build.kind === "invokeAndApplyAction"
116+
? APPROVE_TYPED_DATA
117+
: undefined,
118+
};
119+
},
120+
async executeTransaction(
121+
execute: PaymasterExecute,
122+
): Promise<{ transactionHash: string }> {
123+
for (const call of approveCalls) {
124+
const tx = await env.env.alice.execute({
125+
contractAddress: call.to,
126+
entrypoint: "approve",
127+
calldata: call.calldata,
128+
});
129+
await node.waitForTransaction(tx.transaction_hash);
130+
}
131+
const { transaction_hash } = await broadcastAppliedActions(
132+
devnet,
133+
execute,
134+
);
135+
return { transactionHash: transaction_hash };
136+
},
137+
};
138+
}
139+
140+
/**
141+
* A dapp client whose account signs (CallSet proof authorization + the approve's SNIP-12 message)
142+
* with `signingKey`. The pool verifies against alice's on-chain public key, so a `signingKey` other
143+
* than alice's fails the signature check during proving.
144+
*/
145+
function buildClient(signingKey: string): PrivacyClient {
146+
const { alice, privacy, node } = env.env;
147+
const signer = new Snip12CallSetSigner({
148+
accountAddress: alice.address,
149+
chainId: constants.StarknetChainId.SN_SEPOLIA,
150+
sign: (messageHash) =>
151+
ec.starkCurve.sign(num.toHex(messageHash), signingKey),
152+
});
153+
return makeSdkWalletClient({
154+
signer,
155+
address: alice.address,
156+
passphrase: "e2e-signing-passphrase",
157+
node,
158+
indexerApiUrl: env.indexer.apiUrl,
159+
poolAddress: privacy.address,
160+
paymaster: mockPaymaster(),
161+
});
162+
}
163+
164+
/** STRK the privacy pool holds. */
165+
const poolStrkBalance = (): Promise<bigint> =>
166+
tokenBalance(env.env.node, env.env.strk, env.env.privacy.address);
167+
168+
it(
169+
"applies a deposit authorized by a SNIP-12 CallSet signature",
170+
async () => {
171+
const { strk, alice } = env.env;
172+
const poolBefore = await poolStrkBalance();
173+
174+
// Deposit and keep the balance (minus the paymaster fee) in alice's own note.
175+
await buildClient(await alicePrivateKey())
176+
.build()
177+
.with(strk)
178+
.deposit({ amount: AMOUNT })
179+
.with(strk)
180+
.transfer({ amount: KEPT, recipient: alice.address })
181+
.submit();
182+
await env.indexer.waitForBlock(devnet.url);
183+
184+
// Funds moved into the pool (deposit minus the withdrawn fee) — the deposit's CallSet signature
185+
// was accepted (case III) and the mock fronted its approve as alice.
186+
expect((await poolStrkBalance()) - poolBefore).toBe(KEPT);
187+
},
188+
E2E_TIMEOUTS.test,
189+
);
190+
191+
it(
192+
"rejects a deposit signed with the wrong key during proving",
193+
async () => {
194+
const { strk, alice } = env.env;
195+
196+
// A valid STARK key that is not alice's — the pool's is_valid_signature rejects every OR branch.
197+
const client = buildClient("0x1234567890abcdef1234567890abcdef");
198+
await expect(
199+
client
200+
.build()
201+
.with(strk)
202+
.deposit({ amount: AMOUNT })
203+
.with(strk)
204+
.transfer({ amount: KEPT, recipient: alice.address })
205+
.submit(),
206+
).rejects.toThrow();
207+
},
208+
E2E_TIMEOUTS.test,
209+
);
210+
});

0 commit comments

Comments
 (0)