@oleary-labs/signet-sdk 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +210 -0
- package/dist/scopedSign.d.ts +27 -3
- package/dist/scopedSign.d.ts.map +1 -1
- package/dist/scopedSign.js +71 -11
- package/dist/scopedSign.js.map +1 -1
- package/package.json +2 -2
- package/src/scopedSign.test.ts +97 -0
- package/src/scopedSign.ts +92 -8
package/README.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# @oleary-labs/signet-sdk
|
|
2
|
+
|
|
3
|
+
TypeScript client for the [Signet](https://github.com/oleary-labs/signet-protocol) threshold-signing protocol. Handles session keys, OAuth + ZK auth, threshold keygen, threshold signing across three signing schemes (FROST/secp256k1, FROST/Ed25519, threshold ECDSA/secp256k1), delegation tokens, scoped sub-keys, ERC-4337 user operations, and x402 payments.
|
|
4
|
+
|
|
5
|
+
Extracted from `signet-ui`; now consumed by `signet-ui` and `signet-better-mcp`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add @oleary-labs/signet-sdk
|
|
11
|
+
# or: npm install @oleary-labs/signet-sdk
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Optional peer dependencies (only needed if you call the corresponding subpaths):
|
|
15
|
+
|
|
16
|
+
| Peer dep | Required for | Default state |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| `viem` >=2.0.0 | `./userop`, `./bundler`, `./x402`, anything that touches EVM ABI encoding | strongly recommended for most consumers |
|
|
19
|
+
| `@noir-lang/noir_js` 1.0.0-beta.11 | `./proof`, `./witness` (in-browser ZK proving) | optional — only needed if you prove client-side |
|
|
20
|
+
| `@aztec/bb.js` 0.82.2 | `./proof`, `./witness` | optional — same as above |
|
|
21
|
+
| `@oleary-labs/signet-circuits` ^0.3.0 | `./proof`, `./witness` | optional — server-side proving via `signet-min-bundler`'s `POST /v1/prove` doesn't require this peer at all |
|
|
22
|
+
|
|
23
|
+
Most consumers delegate ZK proof generation to `signet-min-bundler`'s `POST /v1/prove` (see `./server-prover`) and don't install the Noir/bb peers at all.
|
|
24
|
+
|
|
25
|
+
## Concept overview — what's where
|
|
26
|
+
|
|
27
|
+
Signet's protocol nodes accept a `curve` parameter on every signing endpoint and dispatch to one of three schemes:
|
|
28
|
+
|
|
29
|
+
| Curve string | Scheme | Where it's used |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| `frost_secp256k1` | FROST Schnorr / secp256k1 (RFC 9591) | Admin signing, ERC-4337 user-op signing, anything FROST-based |
|
|
32
|
+
| `frost_ed25519` | FROST Schnorr / Ed25519 | Solana and other Ed25519 chains |
|
|
33
|
+
| `ecdsa_secp256k1` | Threshold ECDSA / secp256k1 (DJNPO20, 4-round robust) | Scoped sub-keys, x402 agent payments, EIP-712 typed-data signing where the verifier expects raw ECDSA |
|
|
34
|
+
|
|
35
|
+
The SDK reflects this with a unified function-per-operation API rather than separate client classes — every signing function takes a `curve` argument (or hard-codes one where it makes sense). The HTTP responses for signing carry both `signature` (the scheme's native format) and `ecdsa_signature` (a 65-byte r‖s‖v variant) where applicable.
|
|
36
|
+
|
|
37
|
+
Session authentication and auth-key certificates always use ephemeral local ECDSA — that's universal and lives entirely in the client.
|
|
38
|
+
|
|
39
|
+
## Subpath exports
|
|
40
|
+
|
|
41
|
+
The SDK ships with 19 subpath exports. Import the one you need; the entry point (`@oleary-labs/signet-sdk`) re-exports the most common identifiers.
|
|
42
|
+
|
|
43
|
+
### Core
|
|
44
|
+
|
|
45
|
+
| Subpath | Purpose |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `./session` | Generate/import an ephemeral secp256k1 session keypair; hex helpers |
|
|
48
|
+
| `./types` | `SessionKeypair`, `IdTokenClaims`, and shared types |
|
|
49
|
+
| `./request` | Build canonical request hashes; sign them with the session ECDSA key (used internally by most other modules) |
|
|
50
|
+
|
|
51
|
+
### Auth
|
|
52
|
+
|
|
53
|
+
| Subpath | Purpose |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `./oauth` | OAuth code exchange and JWT extraction (`handleOAuthCallback`, `getOAuthReturnTo`) |
|
|
56
|
+
| `./jwt` | JWT decode / validate |
|
|
57
|
+
| `./jwks` | JWKS fetch and key lookup |
|
|
58
|
+
| `./bootstrap` | Bootstrap-group authentication wrapper |
|
|
59
|
+
| `./authkey-session` | Auth-key certificate session — server-side flow that lets a backend authenticate with a long-lived ECDSA key instead of an OAuth bearer token |
|
|
60
|
+
|
|
61
|
+
### Keygen and signing
|
|
62
|
+
|
|
63
|
+
| Subpath | Purpose |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `./keygen` | Threshold keygen request (`keygen(config, keypair, claims, keySuffix?, identity?, curve?, scope?)`) |
|
|
66
|
+
| `./admin` | Admin API auth — bootstrap-group FROST signing for admin endpoints |
|
|
67
|
+
| `./delegate` | Mint and redeem delegation JWTs (`requestDelegation`, `authenticateWithDelegation`) for autonomous-agent flows |
|
|
68
|
+
| `./scopedSign` | EIP-712 structured signing with scoped sub-keys (`signTypedData(...)`; `buildEIP712ScopeForTypedData` / `buildEIP712Scope` / `eip712TypeHash`; `CHAIN_PRESETS`) |
|
|
69
|
+
| `./frostVerify` | Client-side FROST Schnorr verification (RFC 9591) — useful for tests and round-trip checks |
|
|
70
|
+
|
|
71
|
+
### ERC-4337 and payments
|
|
72
|
+
|
|
73
|
+
| Subpath | Purpose |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `./userop` | Build ERC-4337 v0.7 user operations and FROST-sign them |
|
|
76
|
+
| `./bundler` | JSON-RPC client for `signet-min-bundler` (send/estimate/receipt) |
|
|
77
|
+
| `./x402` | `x402Fetch` — performs the full x402 dance (request → 402 → sign → retry) |
|
|
78
|
+
|
|
79
|
+
### ZK proofs
|
|
80
|
+
|
|
81
|
+
| Subpath | Purpose |
|
|
82
|
+
|---|---|
|
|
83
|
+
| `./witness` | Build the witness for the `jwt_auth` Noir circuit |
|
|
84
|
+
| `./proof` | Generate the proof in-browser using `@aztec/bb.js` + `@noir-lang/noir_js` |
|
|
85
|
+
| `./server-prover` | Delegate proof generation to `signet-min-bundler`'s `POST /v1/prove` (recommended for most apps) |
|
|
86
|
+
|
|
87
|
+
## The `curve` parameter contract
|
|
88
|
+
|
|
89
|
+
Functions that hit a signing endpoint accept a curve string. Pass one of the three canonical values exactly:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
"frost_secp256k1" | "frost_ed25519" | "ecdsa_secp256k1"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Mismatches between what you pass here and the key's actual scheme will fail at the node, not in the client.
|
|
96
|
+
|
|
97
|
+
The session itself (the ephemeral keypair from `./session`) and auth-key certificates always use local ECDSA — there's no curve parameter for those; only the threshold-signing operations take one.
|
|
98
|
+
|
|
99
|
+
For the canonical per-curve reference (algorithms, storage prefixes, response shapes, picking guidance), see [`signet-protocol/docs/CURVES.md`](https://github.com/oleary-labs/signet-protocol/blob/main/docs/CURVES.md).
|
|
100
|
+
|
|
101
|
+
## Example flows
|
|
102
|
+
|
|
103
|
+
### A. FROST keygen via OAuth (Console main flow)
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { generateSessionKeypair } from "@oleary-labs/signet-sdk/session";
|
|
107
|
+
import { handleOAuthCallback } from "@oleary-labs/signet-sdk/oauth";
|
|
108
|
+
import { keygen } from "@oleary-labs/signet-sdk/keygen";
|
|
109
|
+
|
|
110
|
+
const keypair = await generateSessionKeypair();
|
|
111
|
+
const { claims } = await handleOAuthCallback(/* ... */);
|
|
112
|
+
|
|
113
|
+
const result = await keygen(
|
|
114
|
+
{
|
|
115
|
+
nodeUrls: ["https://node-1.example.com", "https://node-2.example.com", "https://node-3.example.com"],
|
|
116
|
+
groupId: "0xf0700...",
|
|
117
|
+
proxyEndpoint: "/api/node/proxy", // optional CORS proxy
|
|
118
|
+
},
|
|
119
|
+
keypair,
|
|
120
|
+
claims,
|
|
121
|
+
/* keySuffix */ undefined,
|
|
122
|
+
/* identity */ undefined,
|
|
123
|
+
/* curve */ "frost_secp256k1",
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
console.log(result.ethereumAddress, result.groupPublicKey);
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### B. ECDSA scoped sub-key + EIP-712 signing (x402 agent flow)
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
import { keygen } from "@oleary-labs/signet-sdk/keygen";
|
|
133
|
+
import { requestDelegation, authenticateWithDelegation } from "@oleary-labs/signet-sdk/delegate";
|
|
134
|
+
import { signTypedData, buildEIP712ScopeForTypedData, CHAIN_PRESETS } from "@oleary-labs/signet-sdk/scopedSign";
|
|
135
|
+
import { buildTransferAuthorization, x402Fetch } from "@oleary-labs/signet-sdk/x402";
|
|
136
|
+
|
|
137
|
+
// Operator side: mint a scoped sub-key bound to USDC on Base AND the
|
|
138
|
+
// TransferWithAuthorization method. A 0x03 scope binds chainId +
|
|
139
|
+
// verifyingContract + the EIP-712 primary type, so the key cannot sign a
|
|
140
|
+
// different method (e.g. an EIP-2612 permit) on the same token. Derive the
|
|
141
|
+
// scope from a sample typed-data payload (message values are irrelevant):
|
|
142
|
+
const preset = CHAIN_PRESETS[0]; // USDC on Base
|
|
143
|
+
const zero = "0x0000000000000000000000000000000000000000";
|
|
144
|
+
const sample = buildTransferAuthorization(
|
|
145
|
+
zero, zero, "0", preset.verifyingContract, preset.chainId, preset.eip712Name, preset.eip712Version,
|
|
146
|
+
);
|
|
147
|
+
const scope = buildEIP712ScopeForTypedData(sample);
|
|
148
|
+
const subkey = await keygen(config, keypair, claims, /* keySuffix */ "agent-1", /* identity */ undefined, "ecdsa_secp256k1", scope);
|
|
149
|
+
|
|
150
|
+
// Mint a delegation JWT for the agent
|
|
151
|
+
const delegation = await requestDelegation({ /* ... */ curve: "ecdsa_secp256k1" });
|
|
152
|
+
|
|
153
|
+
// Agent side: redeem the delegation and sign
|
|
154
|
+
const agentSession = await authenticateWithDelegation(delegation, /* ... */);
|
|
155
|
+
const signed = await signTypedData(
|
|
156
|
+
nodeUrl,
|
|
157
|
+
proxyEndpoint,
|
|
158
|
+
groupId,
|
|
159
|
+
subkey.keyId,
|
|
160
|
+
"ecdsa_secp256k1",
|
|
161
|
+
typedData,
|
|
162
|
+
agentSession.keypair,
|
|
163
|
+
agentSession.claims,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// Use it for an x402 invoice
|
|
167
|
+
const response = await x402Fetch("https://api.example.com/pay", { /* ... */ });
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### C. Admin signing via bootstrap group (FROST)
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
import { adminSign } from "@oleary-labs/signet-sdk/admin";
|
|
174
|
+
|
|
175
|
+
const adminSig = await adminSign({
|
|
176
|
+
groupId,
|
|
177
|
+
nodeUrl,
|
|
178
|
+
proxyEndpoint,
|
|
179
|
+
// ...
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
See `signet-ui` (Console, x402 demo) and `signet-better-mcp` (MCP server) for full working examples.
|
|
184
|
+
|
|
185
|
+
## Relation to other Signet repos
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
signet-circuits ──► signet-protocol (embeds VK)
|
|
189
|
+
──► signet-min-bundler (embeds circuit, runs `nargo` + `bb`)
|
|
190
|
+
──► signet-sdk (peer dep, optional — for browser proving)
|
|
191
|
+
|
|
192
|
+
signet-protocol ◄── HTTP /v1/* ── signet-sdk ◄── signet-ui, signet-better-mcp
|
|
193
|
+
signet-min-bundler ◄── /v1/prove ── signet-sdk (./server-prover)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The SDK has no opinion about hosting — caller supplies all URLs at runtime (no hardcoded endpoints). For local development point it at the signet-protocol devnet (`http://localhost:8080..8082`) and `signet-min-bundler` (`http://localhost:4337`).
|
|
197
|
+
|
|
198
|
+
## Build
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
bun install
|
|
202
|
+
bun run build # tsc to ./dist (ESM + .d.ts)
|
|
203
|
+
bun run typecheck # tsc --noEmit
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
ESM-only. `prepublishOnly` runs the build so `bun publish` (or `npm publish`) ships a current `dist/`.
|
|
207
|
+
|
|
208
|
+
## License
|
|
209
|
+
|
|
210
|
+
MIT.
|
package/dist/scopedSign.d.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* structured payload that the node verifies against the key's scope
|
|
6
6
|
* before computing the hash and signing.
|
|
7
7
|
*/
|
|
8
|
+
import type { Hex } from "viem";
|
|
8
9
|
import type { SessionKeypair, IdTokenClaims } from "./types";
|
|
9
10
|
export interface EIP712Domain {
|
|
10
11
|
name?: string;
|
|
@@ -26,13 +27,35 @@ export interface ScopedSignResult {
|
|
|
26
27
|
ecdsaSignature: string;
|
|
27
28
|
curve: string;
|
|
28
29
|
}
|
|
30
|
+
type EIP712Types = Record<string, ReadonlyArray<{
|
|
31
|
+
name: string;
|
|
32
|
+
type: string;
|
|
33
|
+
}>>;
|
|
29
34
|
/**
|
|
30
|
-
*
|
|
35
|
+
* EIP-712 typeHash: keccak256(encodeType(primaryType)). This is the value a
|
|
36
|
+
* 0x03 scope binds, and the same value the verifying contract uses — so a
|
|
37
|
+
* key scoped to one method (e.g. TransferWithAuthorization) cannot sign a
|
|
38
|
+
* different method on the same contract (e.g. permit).
|
|
39
|
+
*/
|
|
40
|
+
export declare function eip712TypeHash(primaryType: string, types: EIP712Types): Hex;
|
|
41
|
+
/**
|
|
42
|
+
* Build an EIP-712 domain+type scope (scheme 0x03).
|
|
31
43
|
*
|
|
32
44
|
* Format: 0x03 | chainId (8 bytes, uint64 BE) | verifyingContract (20 bytes)
|
|
33
|
-
* Total:
|
|
45
|
+
* | typeHash (32 bytes). Total: 61 bytes.
|
|
46
|
+
*
|
|
47
|
+
* `typeHash` is keccak256(encodeType(primaryType)) — see {@link eip712TypeHash}.
|
|
48
|
+
* Binding the type (not just the domain) prevents a key authorized for one
|
|
49
|
+
* typed-data method from signing a different method on the same contract.
|
|
50
|
+
*/
|
|
51
|
+
export declare function buildEIP712Scope(chainId: number, verifyingContract: string, typeHash: Hex): string;
|
|
52
|
+
/**
|
|
53
|
+
* Convenience: build a 0x03 scope directly from an EIP-712 typed-data sample,
|
|
54
|
+
* deriving chainId, verifyingContract, and typeHash from it. Any sample with
|
|
55
|
+
* the intended domain + primary type works (message values are irrelevant to
|
|
56
|
+
* the scope). This is the recommended way to scope a key for a given method.
|
|
34
57
|
*/
|
|
35
|
-
export declare function
|
|
58
|
+
export declare function buildEIP712ScopeForTypedData(typedData: Pick<EIP712TypedData, "domain" | "types" | "primaryType">): string;
|
|
36
59
|
/**
|
|
37
60
|
* Sign a structured EIP-712 payload with a scoped key.
|
|
38
61
|
*
|
|
@@ -79,4 +102,5 @@ export declare const CHAIN_PRESETS: readonly [{
|
|
|
79
102
|
readonly eip712Name: "USD Coin";
|
|
80
103
|
readonly eip712Version: "2";
|
|
81
104
|
}];
|
|
105
|
+
export {};
|
|
82
106
|
//# sourceMappingURL=scopedSign.d.ts.map
|
package/dist/scopedSign.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scopedSign.d.ts","sourceRoot":"","sources":["../src/scopedSign.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;
|
|
1
|
+
{"version":3,"file":"scopedSign.d.ts","sourceRoot":"","sources":["../src/scopedSign.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,MAAM,CAAC;AAChC,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAQ7D,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,iBAAiB,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,YAAY,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAC;IAC7D,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;CACf;AAMD,KAAK,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC,CAAC;AAyBjF;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,GAAG,GAAG,CAK3E;AAED;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,MAAM,EACf,iBAAiB,EAAE,MAAM,EACzB,QAAQ,EAAE,GAAG,GACZ,MAAM,CA2BR;AAED;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAC1C,SAAS,EAAE,IAAI,CAAC,eAAe,EAAE,QAAQ,GAAG,OAAO,GAAG,aAAa,CAAC,GACnE,MAAM,CAOR;AAMD;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,aAAa,CACjC,OAAO,EAAE,MAAM,EACf,aAAa,EAAE,MAAM,EACrB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,eAAe,EAC1B,cAAc,EAAE,cAAc,EAC9B,MAAM,EAAE,aAAa,EACrB,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,gBAAgB,CAAC,CA0D3B;AAMD,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAiChB,CAAC"}
|
package/dist/scopedSign.js
CHANGED
|
@@ -5,18 +5,60 @@
|
|
|
5
5
|
* structured payload that the node verifies against the key's scope
|
|
6
6
|
* before computing the hash and signing.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
import { hashTypedData, keccak256, stringToBytes } from "viem";
|
|
9
|
+
import { signSignRequest } from "./request";
|
|
10
|
+
import { hexToBytes } from "./session";
|
|
11
|
+
/**
|
|
12
|
+
* Encode an EIP-712 type per the spec: the primary type's definition
|
|
13
|
+
* followed by its referenced struct types in alphabetical order, e.g.
|
|
14
|
+
* `TransferWithAuthorization(address from,address to,uint256 value,...)`.
|
|
15
|
+
* Matches go-ethereum's `apitypes.TypeHash` encoding used by the node, so
|
|
16
|
+
* the resulting typeHash byte-matches what the node verifies.
|
|
17
|
+
*/
|
|
18
|
+
function encodeEIP712Type(primaryType, types) {
|
|
19
|
+
const deps = new Set();
|
|
20
|
+
const visit = (t) => {
|
|
21
|
+
const base = t.replace(/(\[\d*\])+$/, ""); // strip array suffixes
|
|
22
|
+
if (!types[base] || deps.has(base))
|
|
23
|
+
return;
|
|
24
|
+
deps.add(base);
|
|
25
|
+
for (const f of types[base])
|
|
26
|
+
visit(f.type);
|
|
27
|
+
};
|
|
28
|
+
for (const f of types[primaryType] ?? [])
|
|
29
|
+
visit(f.type);
|
|
30
|
+
const sorted = [...deps].filter((d) => d !== primaryType).sort();
|
|
31
|
+
const encodeOne = (t) => `${t}(${types[t].map((f) => `${f.type} ${f.name}`).join(",")})`;
|
|
32
|
+
return [primaryType, ...sorted].map(encodeOne).join("");
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* EIP-712 typeHash: keccak256(encodeType(primaryType)). This is the value a
|
|
36
|
+
* 0x03 scope binds, and the same value the verifying contract uses — so a
|
|
37
|
+
* key scoped to one method (e.g. TransferWithAuthorization) cannot sign a
|
|
38
|
+
* different method on the same contract (e.g. permit).
|
|
39
|
+
*/
|
|
40
|
+
export function eip712TypeHash(primaryType, types) {
|
|
41
|
+
if (!types[primaryType]) {
|
|
42
|
+
throw new Error(`primaryType "${primaryType}" not declared in types`);
|
|
43
|
+
}
|
|
44
|
+
return keccak256(stringToBytes(encodeEIP712Type(primaryType, types)));
|
|
45
|
+
}
|
|
12
46
|
/**
|
|
13
|
-
* Build an EIP-712 domain scope (scheme 0x03).
|
|
47
|
+
* Build an EIP-712 domain+type scope (scheme 0x03).
|
|
14
48
|
*
|
|
15
49
|
* Format: 0x03 | chainId (8 bytes, uint64 BE) | verifyingContract (20 bytes)
|
|
16
|
-
* Total:
|
|
50
|
+
* | typeHash (32 bytes). Total: 61 bytes.
|
|
51
|
+
*
|
|
52
|
+
* `typeHash` is keccak256(encodeType(primaryType)) — see {@link eip712TypeHash}.
|
|
53
|
+
* Binding the type (not just the domain) prevents a key authorized for one
|
|
54
|
+
* typed-data method from signing a different method on the same contract.
|
|
17
55
|
*/
|
|
18
|
-
export function buildEIP712Scope(chainId, verifyingContract) {
|
|
19
|
-
const
|
|
56
|
+
export function buildEIP712Scope(chainId, verifyingContract, typeHash) {
|
|
57
|
+
const th = typeHash.startsWith("0x") ? typeHash.slice(2) : typeHash;
|
|
58
|
+
if (th.length !== 64) {
|
|
59
|
+
throw new Error(`typeHash must be 32 bytes (64 hex chars), got ${th.length}`);
|
|
60
|
+
}
|
|
61
|
+
const buf = new Uint8Array(61);
|
|
20
62
|
buf[0] = 0x03;
|
|
21
63
|
// chainId as 8-byte big-endian
|
|
22
64
|
const view = new DataView(buf.buffer);
|
|
@@ -28,8 +70,22 @@ export function buildEIP712Scope(chainId, verifyingContract) {
|
|
|
28
70
|
for (let i = 0; i < 20; i++) {
|
|
29
71
|
buf[9 + i] = parseInt(addr.slice(i * 2, i * 2 + 2), 16);
|
|
30
72
|
}
|
|
73
|
+
// typeHash as 32 bytes
|
|
74
|
+
for (let i = 0; i < 32; i++) {
|
|
75
|
+
buf[29 + i] = parseInt(th.slice(i * 2, i * 2 + 2), 16);
|
|
76
|
+
}
|
|
31
77
|
return "0x" + Array.from(buf).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
32
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Convenience: build a 0x03 scope directly from an EIP-712 typed-data sample,
|
|
81
|
+
* deriving chainId, verifyingContract, and typeHash from it. Any sample with
|
|
82
|
+
* the intended domain + primary type works (message values are irrelevant to
|
|
83
|
+
* the scope). This is the recommended way to scope a key for a given method.
|
|
84
|
+
*/
|
|
85
|
+
export function buildEIP712ScopeForTypedData(typedData) {
|
|
86
|
+
const typeHash = eip712TypeHash(typedData.primaryType, typedData.types);
|
|
87
|
+
return buildEIP712Scope(typedData.domain.chainId, typedData.domain.verifyingContract, typeHash);
|
|
88
|
+
}
|
|
33
89
|
// ---------------------------------------------------------------------------
|
|
34
90
|
// Structured signing
|
|
35
91
|
// ---------------------------------------------------------------------------
|
|
@@ -50,13 +106,17 @@ export function buildEIP712Scope(chainId, verifyingContract) {
|
|
|
50
106
|
* @param identity - For auth key cert sessions
|
|
51
107
|
*/
|
|
52
108
|
export async function signTypedData(nodeUrl, proxyEndpoint, groupId, keyId, curve, typedData, sessionKeypair, claims, identity) {
|
|
53
|
-
//
|
|
54
|
-
// The canonical hash must use the full sub-key ID (identity + suffix).
|
|
109
|
+
// The canonical request hash must use the full sub-key ID (identity + suffix).
|
|
55
110
|
// Extract suffix from keyId: "oauth:iss:sub:suffix" → suffix is last segment
|
|
56
111
|
// The identity param is "iss:sub", so we need to add the suffix.
|
|
57
112
|
const keyParts = keyId.split(":");
|
|
58
113
|
const keySuffix = keyParts.length > 1 ? keyParts[keyParts.length - 1] : undefined;
|
|
59
|
-
|
|
114
|
+
// Compute the EIP-712 hash locally and bind it into the session request
|
|
115
|
+
// signature. The nodes recompute hashTypedData from the payload and verify
|
|
116
|
+
// the request signature against it — without this binding, a malicious
|
|
117
|
+
// initiator could substitute any payload matching the key's scope.
|
|
118
|
+
const payloadHash = hexToBytes(hashTypedData(typedData).slice(2));
|
|
119
|
+
const signReq = await signSignRequest(sessionKeypair, claims, groupId, payloadHash, keySuffix, identity);
|
|
60
120
|
const res = await fetch(proxyEndpoint, {
|
|
61
121
|
method: "POST",
|
|
62
122
|
headers: {
|
package/dist/scopedSign.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scopedSign.js","sourceRoot":"","sources":["../src/scopedSign.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;
|
|
1
|
+
{"version":3,"file":"scopedSign.js","sourceRoot":"","sources":["../src/scopedSign.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oleary-labs/signet-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Signet DKMS SDK — threshold signing, key management, delegation, and x402 payments",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -100,7 +100,7 @@
|
|
|
100
100
|
"viem": ">=2.0.0",
|
|
101
101
|
"@noir-lang/noir_js": "1.0.0-beta.11",
|
|
102
102
|
"@aztec/bb.js": "0.82.2",
|
|
103
|
-
"@oleary-labs/signet-circuits": "0.
|
|
103
|
+
"@oleary-labs/signet-circuits": "^0.3.0"
|
|
104
104
|
},
|
|
105
105
|
"peerDependenciesMeta": {
|
|
106
106
|
"@noir-lang/noir_js": {
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
eip712TypeHash,
|
|
4
|
+
buildEIP712Scope,
|
|
5
|
+
buildEIP712ScopeForTypedData,
|
|
6
|
+
} from "./scopedSign";
|
|
7
|
+
|
|
8
|
+
// Canonical EIP-3009 TransferWithAuthorization typehash, as used by USDC and
|
|
9
|
+
// every EIP-712 verifier. If our encodeType/typeHash matches this, it matches
|
|
10
|
+
// both the on-chain contract and the node (go-ethereum apitypes.TypeHash).
|
|
11
|
+
const TWA_TYPEHASH =
|
|
12
|
+
"0x7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267";
|
|
13
|
+
|
|
14
|
+
const TWA_TYPES = {
|
|
15
|
+
EIP712Domain: [
|
|
16
|
+
{ name: "name", type: "string" },
|
|
17
|
+
{ name: "version", type: "string" },
|
|
18
|
+
{ name: "chainId", type: "uint256" },
|
|
19
|
+
{ name: "verifyingContract", type: "address" },
|
|
20
|
+
],
|
|
21
|
+
TransferWithAuthorization: [
|
|
22
|
+
{ name: "from", type: "address" },
|
|
23
|
+
{ name: "to", type: "address" },
|
|
24
|
+
{ name: "value", type: "uint256" },
|
|
25
|
+
{ name: "validAfter", type: "uint256" },
|
|
26
|
+
{ name: "validBefore", type: "uint256" },
|
|
27
|
+
{ name: "nonce", type: "bytes32" },
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
test("eip712TypeHash matches the canonical EIP-3009 typehash", () => {
|
|
32
|
+
expect(eip712TypeHash("TransferWithAuthorization", TWA_TYPES)).toBe(TWA_TYPEHASH);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("eip712TypeHash differs for a different method (permit)", () => {
|
|
36
|
+
const permitTypes = {
|
|
37
|
+
Permit: [
|
|
38
|
+
{ name: "owner", type: "address" },
|
|
39
|
+
{ name: "spender", type: "address" },
|
|
40
|
+
{ name: "value", type: "uint256" },
|
|
41
|
+
{ name: "nonce", type: "uint256" },
|
|
42
|
+
{ name: "deadline", type: "uint256" },
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
expect(eip712TypeHash("Permit", permitTypes)).not.toBe(TWA_TYPEHASH);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("eip712TypeHash includes nested struct dependencies, sorted", () => {
|
|
49
|
+
// encodeType must append referenced structs alphabetically:
|
|
50
|
+
// "Mail(Person from,Person to)Person(address wallet)"
|
|
51
|
+
const types = {
|
|
52
|
+
Mail: [
|
|
53
|
+
{ name: "from", type: "Person" },
|
|
54
|
+
{ name: "to", type: "Person" },
|
|
55
|
+
],
|
|
56
|
+
Person: [{ name: "wallet", type: "address" }],
|
|
57
|
+
};
|
|
58
|
+
// keccak256 of the expected encodeType string.
|
|
59
|
+
// (verified against viem hashStruct / go-ethereum elsewhere)
|
|
60
|
+
const expected =
|
|
61
|
+
eip712TypeHash("Mail", types);
|
|
62
|
+
// sanity: a layout change (drop nested field) yields a different hash.
|
|
63
|
+
const altered = {
|
|
64
|
+
Mail: [
|
|
65
|
+
{ name: "from", type: "Person" },
|
|
66
|
+
{ name: "to", type: "Person" },
|
|
67
|
+
],
|
|
68
|
+
Person: [{ name: "name", type: "string" }],
|
|
69
|
+
};
|
|
70
|
+
expect(eip712TypeHash("Mail", altered)).not.toBe(expected);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("buildEIP712Scope produces a 61-byte 0x03 scope with the typeHash", () => {
|
|
74
|
+
const contract = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
|
|
75
|
+
const scope = buildEIP712Scope(8453, contract, TWA_TYPEHASH);
|
|
76
|
+
const bytes = scope.slice(2);
|
|
77
|
+
expect(bytes.length).toBe(61 * 2);
|
|
78
|
+
expect(bytes.slice(0, 2)).toBe("03"); // scheme
|
|
79
|
+
// typeHash occupies the final 32 bytes.
|
|
80
|
+
expect("0x" + bytes.slice(29 * 2)).toBe(TWA_TYPEHASH);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("buildEIP712Scope rejects a bad-length typeHash", () => {
|
|
84
|
+
expect(() => buildEIP712Scope(1, "0x" + "11".repeat(20), "0x1234")).toThrow();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("buildEIP712ScopeForTypedData derives chain/contract/type from a sample", () => {
|
|
88
|
+
const contract = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
|
|
89
|
+
const td = {
|
|
90
|
+
domain: { name: "USD Coin", version: "2", chainId: 8453, verifyingContract: contract },
|
|
91
|
+
types: TWA_TYPES,
|
|
92
|
+
primaryType: "TransferWithAuthorization",
|
|
93
|
+
message: {},
|
|
94
|
+
};
|
|
95
|
+
const scope = buildEIP712ScopeForTypedData(td);
|
|
96
|
+
expect(scope).toBe(buildEIP712Scope(8453, contract, TWA_TYPEHASH));
|
|
97
|
+
});
|
package/src/scopedSign.ts
CHANGED
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
* before computing the hash and signing.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { hashTypedData, keccak256, stringToBytes } from "viem";
|
|
10
|
+
import type { Hex } from "viem";
|
|
9
11
|
import type { SessionKeypair, IdTokenClaims } from "./types";
|
|
10
|
-
import {
|
|
12
|
+
import { signSignRequest } from "./request";
|
|
13
|
+
import { hexToBytes } from "./session";
|
|
11
14
|
|
|
12
15
|
// ---------------------------------------------------------------------------
|
|
13
16
|
// Types
|
|
@@ -37,14 +40,65 @@ export interface ScopedSignResult {
|
|
|
37
40
|
// Scope construction
|
|
38
41
|
// ---------------------------------------------------------------------------
|
|
39
42
|
|
|
43
|
+
type EIP712Types = Record<string, ReadonlyArray<{ name: string; type: string }>>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Encode an EIP-712 type per the spec: the primary type's definition
|
|
47
|
+
* followed by its referenced struct types in alphabetical order, e.g.
|
|
48
|
+
* `TransferWithAuthorization(address from,address to,uint256 value,...)`.
|
|
49
|
+
* Matches go-ethereum's `apitypes.TypeHash` encoding used by the node, so
|
|
50
|
+
* the resulting typeHash byte-matches what the node verifies.
|
|
51
|
+
*/
|
|
52
|
+
function encodeEIP712Type(primaryType: string, types: EIP712Types): string {
|
|
53
|
+
const deps = new Set<string>();
|
|
54
|
+
const visit = (t: string) => {
|
|
55
|
+
const base = t.replace(/(\[\d*\])+$/, ""); // strip array suffixes
|
|
56
|
+
if (!types[base] || deps.has(base)) return;
|
|
57
|
+
deps.add(base);
|
|
58
|
+
for (const f of types[base]) visit(f.type);
|
|
59
|
+
};
|
|
60
|
+
for (const f of types[primaryType] ?? []) visit(f.type);
|
|
61
|
+
|
|
62
|
+
const sorted = [...deps].filter((d) => d !== primaryType).sort();
|
|
63
|
+
const encodeOne = (t: string) =>
|
|
64
|
+
`${t}(${types[t].map((f) => `${f.type} ${f.name}`).join(",")})`;
|
|
65
|
+
return [primaryType, ...sorted].map(encodeOne).join("");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* EIP-712 typeHash: keccak256(encodeType(primaryType)). This is the value a
|
|
70
|
+
* 0x03 scope binds, and the same value the verifying contract uses — so a
|
|
71
|
+
* key scoped to one method (e.g. TransferWithAuthorization) cannot sign a
|
|
72
|
+
* different method on the same contract (e.g. permit).
|
|
73
|
+
*/
|
|
74
|
+
export function eip712TypeHash(primaryType: string, types: EIP712Types): Hex {
|
|
75
|
+
if (!types[primaryType]) {
|
|
76
|
+
throw new Error(`primaryType "${primaryType}" not declared in types`);
|
|
77
|
+
}
|
|
78
|
+
return keccak256(stringToBytes(encodeEIP712Type(primaryType, types)));
|
|
79
|
+
}
|
|
80
|
+
|
|
40
81
|
/**
|
|
41
|
-
* Build an EIP-712 domain scope (scheme 0x03).
|
|
82
|
+
* Build an EIP-712 domain+type scope (scheme 0x03).
|
|
42
83
|
*
|
|
43
84
|
* Format: 0x03 | chainId (8 bytes, uint64 BE) | verifyingContract (20 bytes)
|
|
44
|
-
* Total:
|
|
85
|
+
* | typeHash (32 bytes). Total: 61 bytes.
|
|
86
|
+
*
|
|
87
|
+
* `typeHash` is keccak256(encodeType(primaryType)) — see {@link eip712TypeHash}.
|
|
88
|
+
* Binding the type (not just the domain) prevents a key authorized for one
|
|
89
|
+
* typed-data method from signing a different method on the same contract.
|
|
45
90
|
*/
|
|
46
|
-
export function buildEIP712Scope(
|
|
47
|
-
|
|
91
|
+
export function buildEIP712Scope(
|
|
92
|
+
chainId: number,
|
|
93
|
+
verifyingContract: string,
|
|
94
|
+
typeHash: Hex,
|
|
95
|
+
): string {
|
|
96
|
+
const th = typeHash.startsWith("0x") ? typeHash.slice(2) : typeHash;
|
|
97
|
+
if (th.length !== 64) {
|
|
98
|
+
throw new Error(`typeHash must be 32 bytes (64 hex chars), got ${th.length}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const buf = new Uint8Array(61);
|
|
48
102
|
buf[0] = 0x03;
|
|
49
103
|
|
|
50
104
|
// chainId as 8-byte big-endian
|
|
@@ -59,9 +113,31 @@ export function buildEIP712Scope(chainId: number, verifyingContract: string): st
|
|
|
59
113
|
buf[9 + i] = parseInt(addr.slice(i * 2, i * 2 + 2), 16);
|
|
60
114
|
}
|
|
61
115
|
|
|
116
|
+
// typeHash as 32 bytes
|
|
117
|
+
for (let i = 0; i < 32; i++) {
|
|
118
|
+
buf[29 + i] = parseInt(th.slice(i * 2, i * 2 + 2), 16);
|
|
119
|
+
}
|
|
120
|
+
|
|
62
121
|
return "0x" + Array.from(buf).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
63
122
|
}
|
|
64
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Convenience: build a 0x03 scope directly from an EIP-712 typed-data sample,
|
|
126
|
+
* deriving chainId, verifyingContract, and typeHash from it. Any sample with
|
|
127
|
+
* the intended domain + primary type works (message values are irrelevant to
|
|
128
|
+
* the scope). This is the recommended way to scope a key for a given method.
|
|
129
|
+
*/
|
|
130
|
+
export function buildEIP712ScopeForTypedData(
|
|
131
|
+
typedData: Pick<EIP712TypedData, "domain" | "types" | "primaryType">,
|
|
132
|
+
): string {
|
|
133
|
+
const typeHash = eip712TypeHash(typedData.primaryType, typedData.types);
|
|
134
|
+
return buildEIP712Scope(
|
|
135
|
+
typedData.domain.chainId,
|
|
136
|
+
typedData.domain.verifyingContract,
|
|
137
|
+
typeHash,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
65
141
|
// ---------------------------------------------------------------------------
|
|
66
142
|
// Structured signing
|
|
67
143
|
// ---------------------------------------------------------------------------
|
|
@@ -93,17 +169,25 @@ export async function signTypedData(
|
|
|
93
169
|
claims: IdTokenClaims,
|
|
94
170
|
identity?: string,
|
|
95
171
|
): Promise<ScopedSignResult> {
|
|
96
|
-
//
|
|
97
|
-
// The canonical hash must use the full sub-key ID (identity + suffix).
|
|
172
|
+
// The canonical request hash must use the full sub-key ID (identity + suffix).
|
|
98
173
|
// Extract suffix from keyId: "oauth:iss:sub:suffix" → suffix is last segment
|
|
99
174
|
// The identity param is "iss:sub", so we need to add the suffix.
|
|
100
175
|
const keyParts = keyId.split(":");
|
|
101
176
|
const keySuffix = keyParts.length > 1 ? keyParts[keyParts.length - 1] : undefined;
|
|
102
177
|
|
|
103
|
-
|
|
178
|
+
// Compute the EIP-712 hash locally and bind it into the session request
|
|
179
|
+
// signature. The nodes recompute hashTypedData from the payload and verify
|
|
180
|
+
// the request signature against it — without this binding, a malicious
|
|
181
|
+
// initiator could substitute any payload matching the key's scope.
|
|
182
|
+
const payloadHash = hexToBytes(
|
|
183
|
+
hashTypedData(typedData as Parameters<typeof hashTypedData>[0]).slice(2),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const signReq = await signSignRequest(
|
|
104
187
|
sessionKeypair,
|
|
105
188
|
claims,
|
|
106
189
|
groupId,
|
|
190
|
+
payloadHash,
|
|
107
191
|
keySuffix,
|
|
108
192
|
identity,
|
|
109
193
|
);
|