Skip to content

Commit 75c2104

Browse files
committed
Derive counterfactual Starknet stealth addresses
Add computeStarknetStealthAccount to stealth-chain-starknet: the CSAP Starknet stealth address is the deploy-account address of the pinned StealthAccount class whose signer is the one-time P_stealth. Salt is sn_keccak(R) over the ephemeral key, so sender and recipient derive the same address from the announcement; per-payment unlinkability holds for any salt because P_stealth is itself the constructor calldata. Pedersen comes from @scure/starknet; the formula and the [x.low, x.high, y.low, y.high] calldata layout are cross-validated against starknet.js and the on-chain account Serde.
1 parent 0adf9b8 commit 75c2104

6 files changed

Lines changed: 254 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ All notable changes to the Opaque SDK packages.
3131
`{ ethereum, solana }` shape, so a new scan chain can't break the type.
3232

3333
### Added
34+
- **`@opaquecash/stealth-chain-starknet` — counterfactual stealth address
35+
derivation.** `computeStarknetStealthAccount(pStealthUncompressed,
36+
ephemeralPubKey)` returns the CSAP Starknet stealth address (the
37+
deploy-account address of the pinned `StealthAccount` class), its salt
38+
(`sn_keccak(R)`), and the `[x.low, x.high, y.low, y.high]` constructor
39+
calldata. Pedersen from `@scure/starknet`; the formula and calldata layout
40+
are cross-validated against `starknet.js` and the on-chain account Serde.
3441
- **`@opaquecash/stealth-chain-starknet` 0.1.0 (new package).** Starknet
3542
`ChainAdapter` (Opaque-assigned chain id `0x534e` "SN"): announcement
3643
fetching via `starknet_getEvents` with a Cairo `ByteArray` codec and

package-lock.json

Lines changed: 45 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/stealth-chain-starknet/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
},
2121
"dependencies": {
2222
"@noble/hashes": "^1.5.0",
23-
"@opaquecash/adapter": "0.2.0"
23+
"@opaquecash/adapter": "0.2.0",
24+
"@scure/starknet": "^2.2.0"
2425
},
2526
"sideEffects": false,
2627
"license": "Apache-2.0",
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { pedersen } from "@scure/starknet";
2+
import { starknetKeccak } from "./bytearray.js";
3+
import { STARKNET_SEPOLIA } from "./deployment.js";
4+
5+
function asBigInt(hexOrValue: string): bigint {
6+
return BigInt(hexOrValue.startsWith("0x") ? hexOrValue : `0x${hexOrValue}`);
7+
}
8+
9+
/**
10+
* Starknet's array-hash `H(...H(H(0, a0), a1)..., n)` over Pedersen, as used
11+
* for contract-address computation and constructor-calldata hashing.
12+
*/
13+
function pedersenOnElements(data: bigint[]): bigint {
14+
let acc = 0n;
15+
for (const x of data) acc = asBigInt(pedersen(acc, x));
16+
return asBigInt(pedersen(acc, BigInt(data.length)));
17+
}
18+
19+
/**
20+
* Counterfactual Starknet stealth address derivation (CSAP custody,
21+
* spec/starknet-integration.md §7.1).
22+
*
23+
* The address is the deploy-account address of the pinned `StealthAccount`
24+
* class whose sole signer is the one-time secp256k1 key `P_stealth`. Because
25+
* the constructor calldata IS `P_stealth` — fresh per payment via CSAP's
26+
* per-payment ephemeral key — the address is unlinkable across payments
27+
* regardless of salt. The salt is nonetheless derived deterministically from
28+
* the announced ephemeral key so the sender (computing where to pay) and the
29+
* recipient (deploying to spend) arrive at the identical address from public
30+
* data alone.
31+
*/
32+
33+
// felt("STARKNET_CONTRACT_ADDRESS") — the address-hash domain prefix.
34+
const CONTRACT_ADDRESS_PREFIX =
35+
0x535441524b4e45545f434f4e54524143545f41444452455353n;
36+
// Starknet addresses live in [0, 2^251 - 256).
37+
const L2_ADDRESS_UPPER_BOUND = 2n ** 251n - 256n;
38+
39+
const U128_MASK = (1n << 128n) - 1n;
40+
41+
function bytesToBigInt(bytes: Uint8Array): bigint {
42+
let v = 0n;
43+
for (const b of bytes) v = (v << 8n) | BigInt(b);
44+
return v;
45+
}
46+
47+
/**
48+
* Serialise a secp256k1 point as the Cairo `Secp256k1Point` (EthPublicKey)
49+
* constructor calldata: `[x.low, x.high, y.low, y.high]` (each `u256` as
50+
* little-endian 128-bit limbs).
51+
*/
52+
export function ethPublicKeyCalldata(uncompressedPubKey: Uint8Array): bigint[] {
53+
if (uncompressedPubKey.length !== 65 || uncompressedPubKey[0] !== 0x04) {
54+
throw new Error("Expected a 65-byte uncompressed secp256k1 public key (0x04 ‖ x ‖ y)");
55+
}
56+
const x = bytesToBigInt(uncompressedPubKey.subarray(1, 33));
57+
const y = bytesToBigInt(uncompressedPubKey.subarray(33, 65));
58+
return [x & U128_MASK, x >> 128n, y & U128_MASK, y >> 128n];
59+
}
60+
61+
/**
62+
* The per-payment salt: `sn_keccak(ephemeral_pubkey)`. Derivable by anyone
63+
* from the announcement, so sender and recipient agree without interaction.
64+
*/
65+
export function stealthAccountSalt(ephemeralPubKey: Uint8Array): bigint {
66+
if (ephemeralPubKey.length !== 33) {
67+
throw new Error("Ephemeral public key must be 33 bytes (compressed secp256k1)");
68+
}
69+
let hex = "";
70+
for (const b of ephemeralPubKey) hex += b.toString(16).padStart(2, "0");
71+
return starknetKeccak(hex);
72+
}
73+
74+
export interface StarknetStealthAccount {
75+
/** The counterfactual account address, `0x`-hex felt. */
76+
address: `0x${string}`;
77+
/** The salt used (a function of the ephemeral key). */
78+
salt: bigint;
79+
/** Constructor calldata: `P_stealth` as `[x.low, x.high, y.low, y.high]`. */
80+
constructorCalldata: bigint[];
81+
/** The account class the address is a counterfactual deployment of. */
82+
classHash: `0x${string}`;
83+
}
84+
85+
/**
86+
* Compute the Starknet stealth account for a payment.
87+
*
88+
* @param stealthPubKeyUncompressed - `P_stealth` (65-byte uncompressed
89+
* secp256k1), from `prepareStealthSend(...).stealthPubKey`.
90+
* @param ephemeralPubKey - the 33-byte compressed ephemeral key `R` from the
91+
* same send (drives the salt).
92+
* @param classHash - the pinned `StealthAccount` class hash (defaults to the
93+
* Sepolia deployment).
94+
*/
95+
export function computeStarknetStealthAccount(
96+
stealthPubKeyUncompressed: Uint8Array,
97+
ephemeralPubKey: Uint8Array,
98+
classHash: string = STARKNET_SEPOLIA.stealthAccountClassHash,
99+
): StarknetStealthAccount {
100+
const constructorCalldata = ethPublicKeyCalldata(stealthPubKeyUncompressed);
101+
const salt = stealthAccountSalt(ephemeralPubKey);
102+
const classHashFelt = BigInt(classHash);
103+
104+
const address =
105+
pedersenOnElements([
106+
CONTRACT_ADDRESS_PREFIX,
107+
0n, // deployer address: 0 for a self-deploying (deploy_account) account
108+
salt,
109+
classHashFelt,
110+
pedersenOnElements(constructorCalldata),
111+
]) % L2_ADDRESS_UPPER_BOUND;
112+
113+
return {
114+
address: `0x${address.toString(16)}`,
115+
salt,
116+
constructorCalldata,
117+
classHash: `0x${classHashFelt.toString(16)}`,
118+
};
119+
}

packages/stealth-chain-starknet/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ export {
55
starknetKeccak,
66
toFeltHex,
77
} from "./bytearray.js";
8+
export {
9+
computeStarknetStealthAccount,
10+
ethPublicKeyCalldata,
11+
stealthAccountSalt,
12+
type StarknetStealthAccount,
13+
} from "./address.js";
814
export {
915
buildAnnounceCall,
1016
buildRegisterKeysCall,

tests/starknet-address.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* Counterfactual Starknet stealth address derivation.
3+
*
4+
* The golden address below was cross-validated three ways for CSAP canonical
5+
* vector 1's one-time key (`0x9d1fcbe…1e2e`):
6+
* 1. `starknet.js` `hash.calculateContractAddressFromHash` — the ecosystem
7+
* reference used by every wallet — produced the identical address.
8+
* 2. `starknet.js` `EthSigner.getPubKey()` produced the identical (x, y),
9+
* confirming the `[x.low, x.high, y.low, y.high]` constructor calldata.
10+
* 3. The on-chain `StealthAccount` Serde round-trips that same layout
11+
* (starknet repo `test_account_reports_its_stealth_public_key`).
12+
* The computation here is dependency-light (Pedersen from `@scure/starknet`).
13+
*/
14+
import { describe, expect, it } from "vitest";
15+
import { secp256k1 } from "@noble/curves/secp256k1";
16+
import {
17+
computeStarknetStealthAccount,
18+
ethPublicKeyCalldata,
19+
STARKNET_SEPOLIA,
20+
stealthAccountSalt,
21+
} from "@opaquecash/stealth-chain-starknet";
22+
23+
const ONE_TIME_KEY =
24+
0x9d1fcbe17267729a88091556cadd19b3c11e33029883163d1d7118bc21a61e2en;
25+
const EPHEMERAL = Uint8Array.from(
26+
Buffer.from(
27+
"02b95c249d84f417e3e395a127425428b540671cc15881eb828c17b722a53fc599",
28+
"hex",
29+
),
30+
);
31+
32+
function pStealthUncompressed(): Uint8Array {
33+
return secp256k1.getPublicKey(ONE_TIME_KEY, false);
34+
}
35+
36+
describe("computeStarknetStealthAccount", () => {
37+
it("matches the starknet.js-validated golden address", () => {
38+
const acct = computeStarknetStealthAccount(pStealthUncompressed(), EPHEMERAL);
39+
expect(acct.address).toBe(
40+
"0x5c12134bc82dc4cf9f56ae036a0ba5ec85948c73304a3a6c1e08aa11e806ca2",
41+
);
42+
expect(acct.classHash).toBe(
43+
`0x${BigInt(STARKNET_SEPOLIA.stealthAccountClassHash).toString(16)}`,
44+
);
45+
});
46+
47+
it("lays out the constructor calldata as [x.low, x.high, y.low, y.high]", () => {
48+
// The (x, y) starknet.js EthSigner derives for this key.
49+
const x =
50+
0xdce27b82cd1ed4232b569e14121f4825a2ef189d14807d43c8d093fa8354f61bn;
51+
const y =
52+
0xbe8434ee5844371e291dcd5d93c072ea6e6f3eeab87a5bf42b3c08c52b66f2bcn;
53+
const mask = (1n << 128n) - 1n;
54+
expect(ethPublicKeyCalldata(pStealthUncompressed())).toEqual([
55+
x & mask,
56+
x >> 128n,
57+
y & mask,
58+
y >> 128n,
59+
]);
60+
});
61+
62+
it("derives the salt from the ephemeral key (announcement-derivable)", () => {
63+
const acct = computeStarknetStealthAccount(pStealthUncompressed(), EPHEMERAL);
64+
expect(acct.salt).toBe(stealthAccountSalt(EPHEMERAL));
65+
// A different ephemeral key gives a different salt (and address).
66+
const other = Uint8Array.from(EPHEMERAL);
67+
other[1] ^= 0x01;
68+
expect(stealthAccountSalt(other)).not.toBe(acct.salt);
69+
});
70+
71+
it("rejects malformed inputs", () => {
72+
expect(() => ethPublicKeyCalldata(new Uint8Array(64))).toThrow(/uncompressed/);
73+
expect(() => stealthAccountSalt(new Uint8Array(32))).toThrow(/33 bytes/);
74+
});
75+
});

0 commit comments

Comments
 (0)