Skip to content

Commit a95fd8d

Browse files
committed
test(e2e): import + deploy StarknetEth712Account, verify case-I EVM signature
Add starkware_accounts::StarknetEth712Account to the privacy test build's build-external-contracts so it is declarable on devnet. e2e/src/eth712-account-setup.ts deploys + initializes one (EVM key -> address, ownership signature over the fixed hash, UDC deploy + initialize). The test confirms the client's Eip712HashSigner produces a CallSet signature the deployed account accepts on-chain via is_custom_signature_valid (case I).
1 parent 36b7caf commit a95fd8d

4 files changed

Lines changed: 221 additions & 0 deletions

File tree

.github/workflows/e2e-devnet.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ jobs:
7171
# SubAccount + MockDapp come from the test build (sierra only); the e2e setup
7272
# compiles casm with universal-sierra-compiler (set up above) at declare time.
7373
scarb build -t -p sub_account_anonymizer
74+
# StarknetEth712Account (the EVM account the signing e2e deploys) is a
75+
# build-external-contract of the privacy test target; same sierra-only + USC-at-declare flow.
76+
scarb build -t -p privacy
7477
- name: Build ekubo contracts
7578
run: cd e2e/contracts/ekubo && scarb build
7679
- name: Build test token

e2e/src/eth712-account-setup.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { execFileSync } from "node:child_process";
2+
import { mkdtempSync, readFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { secp256k1 } from "@noble/curves/secp256k1";
6+
import { keccak_256 } from "@noble/hashes/sha3";
7+
import { Account, CallData, RpcProvider, num, type Abi } from "starknet";
8+
import { declareClass, deployContract, repoRoot } from "./utils.js";
9+
10+
/**
11+
* Deploys a `StarknetEth712Account` (from `starkware_accounts`, emitted by the privacy test build) on
12+
* devnet: a Starknet account that validates EVM/secp256k1 EIP-712 signatures. The account has no
13+
* constructor — deploy it via the UDC, then `initialize(eth_address, ownership_signature)` proves
14+
* EVM-key ownership and registers its SRC5 interfaces (incl. custom-signature-validation).
15+
*/
16+
17+
const TEST_BUILD_DIR = join(repoRoot(), "target/dev");
18+
const ACCOUNT_CONTRACT =
19+
"privacy_unittest_StarknetEth712Account.test.contract_class.json";
20+
21+
// keccak256("\x19Ethereum Signed Message:\n41Sign to verify that you own this account.")
22+
// (starkware_accounts::eth_712_utils::OWNERSHIP_TRANSFER_MSG_HASH) — signing it with the EVM key
23+
// proves account ownership at initialize().
24+
const OWNERSHIP_TRANSFER_MSG_HASH =
25+
0x3ce976d55131cd0bdd49f20afbded052d8e907dc6034d95cdf117a8fd7752e3cn;
26+
27+
function to32(value: bigint): Uint8Array {
28+
const out = new Uint8Array(32);
29+
let rest = value;
30+
for (let index = 31; index >= 0; index--) {
31+
out[index] = Number(rest & 0xffn);
32+
rest >>= 8n;
33+
}
34+
return out;
35+
}
36+
37+
function bytesToBigInt(bytes: Uint8Array): bigint {
38+
let value = 0n;
39+
for (const byte of bytes) value = (value << 8n) | BigInt(byte);
40+
return value;
41+
}
42+
43+
/** The EVM address (low 160 bits of keccak(uncompressed pubkey)) for a secp256k1 private key. */
44+
export function evmAddress(evmPrivateKey: bigint): bigint {
45+
const publicKey = secp256k1.getPublicKey(to32(evmPrivateKey), false).slice(1); // drop 0x04 prefix
46+
return bytesToBigInt(keccak_256(publicKey).slice(12));
47+
}
48+
49+
/**
50+
* The EVM ownership signature over the fixed `OWNERSHIP_TRANSFER_MSG_HASH`, as the account's
51+
* `Signature { r, s, y_parity }`. `y_parity` matches the 6-felt convention the account uses
52+
* elsewhere (`v = 27 + recovery`, `y_parity = v % 2 == 0`), i.e. an odd recovery id.
53+
*/
54+
function ownershipSignature(evmPrivateKey: bigint): {
55+
r: bigint;
56+
s: bigint;
57+
y_parity: boolean;
58+
} {
59+
const signature = secp256k1.sign(
60+
to32(OWNERSHIP_TRANSFER_MSG_HASH),
61+
to32(evmPrivateKey),
62+
);
63+
return {
64+
r: signature.r,
65+
s: signature.s,
66+
y_parity: signature.recovery % 2 === 1,
67+
};
68+
}
69+
70+
async function declareEth712Account(
71+
admin: Account,
72+
node: RpcProvider,
73+
): Promise<string> {
74+
const sierraPath = join(TEST_BUILD_DIR, ACCOUNT_CONTRACT);
75+
const casmPath = join(
76+
mkdtempSync(join(tmpdir(), "eth712-casm-")),
77+
"account.casm.json",
78+
);
79+
execFileSync("universal-sierra-compiler", [
80+
"compile-contract",
81+
"--sierra-path",
82+
sierraPath,
83+
"--output-path",
84+
casmPath,
85+
]);
86+
return declareClass(admin, node, sierraPath, casmPath);
87+
}
88+
89+
export interface Eth712Account {
90+
/** The deployed Starknet account address. */
91+
address: string;
92+
/** The EVM address the account validates signatures against. */
93+
ethAddress: bigint;
94+
/** The account ABI, for compiling `is_custom_signature_valid` / `execute_from_outside_v2` calls. */
95+
abi: Abi;
96+
}
97+
98+
/** Declare, UDC-deploy, and initialize a `StarknetEth712Account` owned by `evmPrivateKey`. */
99+
export async function deployEth712Account(
100+
admin: Account,
101+
node: RpcProvider,
102+
evmPrivateKey: bigint,
103+
): Promise<Eth712Account> {
104+
const classHash = await declareEth712Account(admin, node);
105+
const abi: Abi = JSON.parse(
106+
readFileSync(join(TEST_BUILD_DIR, ACCOUNT_CONTRACT), "utf8"),
107+
).abi;
108+
const address = await deployContract(admin, node, classHash, [], "0xe712");
109+
110+
const ethAddress = evmAddress(evmPrivateKey);
111+
const initialize = await admin.execute({
112+
contractAddress: address,
113+
entrypoint: "initialize",
114+
calldata: new CallData(abi).compile("initialize", {
115+
eth_address: num.toHex(ethAddress),
116+
signature: ownershipSignature(evmPrivateKey),
117+
}),
118+
});
119+
await node.waitForTransaction(initialize.transaction_hash);
120+
121+
return { address, ethAddress, abi };
122+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
2+
import { CallData, hash, num, shortString, type Call } from "starknet";
3+
import {
4+
Devnet,
5+
type DevnetEnvironment,
6+
} from "@starkware-libs/starknet-privacy-sdk/testing";
7+
import {
8+
Eip712HashSigner,
9+
secp256k1SignFn,
10+
} from "@starkware-libs/starknet-privacy-client/signers";
11+
import {
12+
deployEth712Account,
13+
type Eth712Account,
14+
} from "../../src/eth712-account-setup.js";
15+
import { E2E_TIMEOUTS } from "../../src/timeouts.js";
16+
17+
/**
18+
* Deploys a real `StarknetEth712Account` and checks that the client's `Eip712HashSigner` produces a
19+
* `CallSet` signature the account accepts on-chain via `is_custom_signature_valid` (case I). This is
20+
* the setup branch's own verification: the imported account + the client's EVM signer agree on the
21+
* EIP-712 `CallSet` hash.
22+
*/
23+
describe("StarknetEth712Account custom-signature validation on devnet", () => {
24+
let devnet: Devnet;
25+
let env: DevnetEnvironment;
26+
let account: Eth712Account;
27+
28+
// Same EVM key as starkware_accounts' test fixtures — its eth address is what the account is
29+
// initialized with, so signatures with it validate.
30+
const EVM_KEY =
31+
0xa6d86467b6ec9e161649b27edfd8519e75a2e1cf5f4c309c628706e6999780e8n;
32+
const VALIDATED = BigInt(shortString.encodeShortString("VALID"));
33+
34+
beforeAll(async () => {
35+
devnet = new Devnet();
36+
env = await devnet.initialize();
37+
account = await deployEth712Account(env.admin, env.node, EVM_KEY);
38+
}, E2E_TIMEOUTS.hook);
39+
40+
afterAll(async () => {
41+
await devnet?.cleanup();
42+
});
43+
44+
it(
45+
"accepts a client Eip712 CallSet signature (case I)",
46+
async () => {
47+
const approveCalldata = ["0x1234", "0x1f4", "0x0"];
48+
const signer = new Eip712HashSigner({
49+
accountAddress: account.address,
50+
snChainName: "SN_SEPOLIA", // devnet chain id — keccak'd into the EIP-712 domain name
51+
evmChainId: 1n,
52+
sign: secp256k1SignFn(EVM_KEY),
53+
});
54+
55+
const signature = (await signer.signTransaction(
56+
[
57+
{
58+
contractAddress: "0x111",
59+
entrypoint: "approve",
60+
calldata: approveCalldata,
61+
},
62+
],
63+
{} as never,
64+
)) as string[];
65+
66+
// The on-chain Call uses the raw selector; it must be the same call the signer hashed.
67+
const onChainCalls: Call[] = [
68+
{
69+
contractAddress: "0x111",
70+
entrypoint: hash.getSelectorFromName("approve"),
71+
calldata: approveCalldata,
72+
},
73+
];
74+
const result = await env.node.callContract({
75+
contractAddress: account.address,
76+
entrypoint: "is_custom_signature_valid",
77+
calldata: new CallData(account.abi).compile(
78+
"is_custom_signature_valid",
79+
{
80+
calls: onChainCalls.map((call) => ({
81+
to: call.contractAddress,
82+
selector: call.entrypoint,
83+
calldata: call.calldata,
84+
})),
85+
additional_data: [],
86+
signature,
87+
},
88+
),
89+
});
90+
91+
expect(num.toBigInt(result[0])).toBe(VALIDATED);
92+
},
93+
E2E_TIMEOUTS.test,
94+
);
95+
});

packages/privacy/Scarb.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ name = "privacy_unittest"
2929
build-external-contracts = [
3030
"starkware_utils::erc20::erc20_mocks::DualCaseERC20Mock",
3131
"starkware_accounts::sub_account::SubAccount",
32+
"starkware_accounts::eth_712_account::StarknetEth712Account",
3233
"ekubo_swap_anonymizer::ekubo_swap_anonymizer::EkuboSwapAnonymizer",
3334
"ekubo_swap_anonymizer::test_utils_contracts::mock_ekubo_amm::MockEkuboAMM",
3435
"sub_account_anonymizer::sub_account_anonymizer::SubAccountAnonymizer",

0 commit comments

Comments
 (0)