@coordination-network/user-client 0.0.1
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/LICENSE +21 -0
- package/README.md +111 -0
- package/dist/index.cjs +446 -0
- package/dist/index.js +412 -0
- package/package.json +57 -0
- package/src/bytes.ts +52 -0
- package/src/constants.ts +17 -0
- package/src/eip712.ts +57 -0
- package/src/errors.ts +40 -0
- package/src/http-submitter.ts +151 -0
- package/src/index.ts +13 -0
- package/src/member-server.ts +26 -0
- package/src/meta.ts +13 -0
- package/src/register.ts +70 -0
- package/src/registry.ts +32 -0
- package/src/request.ts +89 -0
- package/src/types.ts +71 -0
- package/types/index.d.ts +185 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { encodeMetaStealthAddress, META_STEALTH_ADDRESS_LENGTH, validateMetaStealthAddress, computeNetworkId, deriveKeys, metaStealthAddressUri } from '@coordination-network/identity';
|
|
2
|
+
export { InvalidMetaStealthAddressError, metaStealthAddressUri as displayMetaStealthAddress } from '@coordination-network/identity';
|
|
3
|
+
import { keccak_256 } from '@noble/hashes/sha3';
|
|
4
|
+
import { hexToBytes as hexToBytes$1, bytesToHex as bytesToHex$1, concatBytes as concatBytes$1, utf8ToBytes } from '@noble/hashes/utils';
|
|
5
|
+
import { secp256k1 } from '@noble/curves/secp256k1';
|
|
6
|
+
|
|
7
|
+
/** Base error class for all errors thrown by this package, discriminated by `code`. */
|
|
8
|
+
class UserClientError extends Error {
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "UserClientError";
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
class SubmissionFailedError extends UserClientError {
|
|
16
|
+
constructor(message, cause) {
|
|
17
|
+
super("SUBMISSION_FAILED", message);
|
|
18
|
+
this.name = "SubmissionFailedError";
|
|
19
|
+
if (cause !== undefined) {
|
|
20
|
+
// Non-enumerable, matching the standard Error.cause. Provider errors hold circular
|
|
21
|
+
// transport graphs, so an enumerable copy makes JSON.stringify throw inside a
|
|
22
|
+
// caller's error handler and drags the provider's URL into its logs.
|
|
23
|
+
Object.defineProperty(this, "cause", { value: cause, writable: true, configurable: true });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
class RegistryReadFailedError extends UserClientError {
|
|
28
|
+
constructor(message, cause) {
|
|
29
|
+
super("REGISTRY_READ_FAILED", message);
|
|
30
|
+
this.name = "RegistryReadFailedError";
|
|
31
|
+
if (cause !== undefined) {
|
|
32
|
+
Object.defineProperty(this, "cause", { value: cause, writable: true, configurable: true });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** ERC-6538 scheme 1 (secp256k1 stealth addresses), the schemeId field of the consent struct. */
|
|
38
|
+
const SCHEME_ID = 1;
|
|
39
|
+
/** networkId is a bytes32 on chain, and abi.encode splices it in as one word. */
|
|
40
|
+
const NETWORK_ID_LENGTH = 32;
|
|
41
|
+
/** r || s || v, the 65-byte Ethereum signature layout. */
|
|
42
|
+
const SIGNATURE_LENGTH = 65;
|
|
43
|
+
/** keccak256 output, the only thing this package will sign. */
|
|
44
|
+
const DIGEST_LENGTH = 32;
|
|
45
|
+
/** Ethereum transaction hash, as HttpSubmissionError's 201 branch expects it. */
|
|
46
|
+
const TX_HASH_LENGTH = 32;
|
|
47
|
+
/** Wire-format version of RegistrationRequest. */
|
|
48
|
+
const REQUEST_VERSION = "v1";
|
|
49
|
+
const IDENTITY_REGISTRY_DOMAIN_NAME = "IdentityRegistry";
|
|
50
|
+
const IDENTITY_REGISTRY_DOMAIN_VERSION = "1";
|
|
51
|
+
/** Signed by the user as consent to register; the member server verifies it, the contract never reads it. */
|
|
52
|
+
const REGISTER_IDENTITY_TYPE = "RegisterIdentity(bytes32 networkId,uint256 schemeId,bytes metaStealthAddress)";
|
|
53
|
+
const EIP712_DOMAIN_TYPE = "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)";
|
|
54
|
+
|
|
55
|
+
/** Encodes a device's spending and viewing keys as its ERC-6538 scheme-1 meta-stealth address. */
|
|
56
|
+
function metaStealthAddressFromKeys(keys) {
|
|
57
|
+
return encodeMetaStealthAddress({
|
|
58
|
+
spendingPublicKey: keys.spending.publicKey,
|
|
59
|
+
viewingPublicKey: keys.viewing.publicKey
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Parses a 0x-prefixed hex string into bytes. Throws on missing prefix, odd length, or non-hex chars. */
|
|
64
|
+
function hexToBytes(hex) {
|
|
65
|
+
if (typeof hex !== "string" || !hex.startsWith("0x")) {
|
|
66
|
+
throw new UserClientError("INVALID_INPUT", `Expected a 0x-prefixed hex string, got ${typeof hex}`);
|
|
67
|
+
}
|
|
68
|
+
const body = hex.slice(2);
|
|
69
|
+
if (body.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(body)) {
|
|
70
|
+
throw new UserClientError("INVALID_INPUT", `Malformed hex string of length ${body.length}`);
|
|
71
|
+
}
|
|
72
|
+
return hexToBytes$1(body);
|
|
73
|
+
}
|
|
74
|
+
function bytesToHex(bytes) {
|
|
75
|
+
return `0x${bytesToHex$1(bytes)}`;
|
|
76
|
+
}
|
|
77
|
+
function concatBytes(...arrays) {
|
|
78
|
+
return concatBytes$1(...arrays);
|
|
79
|
+
}
|
|
80
|
+
/** Big-endian 32-byte word encoding of a non-negative safe-integer uint256, as abi.encode would produce. */
|
|
81
|
+
function numberToWord(value) {
|
|
82
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
83
|
+
throw new UserClientError("INVALID_INPUT", `Expected a non-negative safe integer, got ${typeof value} ${value}`);
|
|
84
|
+
}
|
|
85
|
+
const word = new Uint8Array(32);
|
|
86
|
+
let remaining = BigInt(value);
|
|
87
|
+
for (let i = 31; i >= 0 && remaining > 0n; i -= 1) {
|
|
88
|
+
word[i] = Number(remaining & 0xffn);
|
|
89
|
+
remaining >>= 8n;
|
|
90
|
+
}
|
|
91
|
+
return word;
|
|
92
|
+
}
|
|
93
|
+
/** Left-pads a 20-byte address into a 32-byte word, as abi.encode would produce. */
|
|
94
|
+
function addressToWord(address) {
|
|
95
|
+
const addressBytes = hexToBytes(address);
|
|
96
|
+
if (addressBytes.length !== 20) {
|
|
97
|
+
throw new UserClientError("INVALID_INPUT", `Expected a 20-byte address, got ${addressBytes.length} bytes`);
|
|
98
|
+
}
|
|
99
|
+
const word = new Uint8Array(32);
|
|
100
|
+
word.set(addressBytes, 12);
|
|
101
|
+
return word;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const EIP712_DOMAIN_TYPEHASH = keccak_256(utf8ToBytes(EIP712_DOMAIN_TYPE));
|
|
105
|
+
const REGISTER_IDENTITY_TYPEHASH = keccak_256(utf8ToBytes(REGISTER_IDENTITY_TYPE));
|
|
106
|
+
/** Every input is already a 32-byte word, so concatenating them is what abi.encode would emit. */
|
|
107
|
+
function hashEncodedWords(...words) {
|
|
108
|
+
return keccak_256(concatBytes(...words));
|
|
109
|
+
}
|
|
110
|
+
function identityRegistryDomainSeparator(domain) {
|
|
111
|
+
const nameHash = keccak_256(utf8ToBytes(IDENTITY_REGISTRY_DOMAIN_NAME));
|
|
112
|
+
const versionHash = keccak_256(utf8ToBytes(IDENTITY_REGISTRY_DOMAIN_VERSION));
|
|
113
|
+
return bytesToHex(hashEncodedWords(EIP712_DOMAIN_TYPEHASH, nameHash, versionHash, numberToWord(domain.chainId), addressToWord(domain.verifyingContract)));
|
|
114
|
+
}
|
|
115
|
+
function registerIdentityStructHash(networkId, metaStealthAddress) {
|
|
116
|
+
const networkIdWord = hexToBytes(networkId);
|
|
117
|
+
if (networkIdWord.length !== NETWORK_ID_LENGTH) {
|
|
118
|
+
throw new UserClientError("INVALID_INPUT", `Expected a ${NETWORK_ID_LENGTH}-byte networkId, got ${networkIdWord.length} bytes`);
|
|
119
|
+
}
|
|
120
|
+
const metaHash = keccak_256(hexToBytes(metaStealthAddress));
|
|
121
|
+
return bytesToHex(hashEncodedWords(REGISTER_IDENTITY_TYPEHASH, networkIdWord, numberToWord(SCHEME_ID), metaHash));
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The digest the user signs as consent to register this meta-stealth address at this registry on
|
|
125
|
+
* this chain. The member server verifies it before submitting; the contract never reads it.
|
|
126
|
+
*/
|
|
127
|
+
function registerIdentityDigest(domain, networkId, metaStealthAddress) {
|
|
128
|
+
const domainSeparator = hexToBytes(identityRegistryDomainSeparator(domain));
|
|
129
|
+
const structHash = hexToBytes(registerIdentityStructHash(networkId, metaStealthAddress));
|
|
130
|
+
return bytesToHex(keccak_256(concatBytes(Uint8Array.from([0x19, 0x01]), domainSeparator, structHash)));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Returns a 65-byte `r || s || v` signature, with `v = 27 + recovery id` as the contract expects. */
|
|
134
|
+
function signRegisterIdentity(accountPrivateKey, digest) {
|
|
135
|
+
const digestBytes = hexToBytes(digest);
|
|
136
|
+
if (digestBytes.length !== DIGEST_LENGTH) {
|
|
137
|
+
// Exported, so refuse to sign anything that is not a digest this package produced.
|
|
138
|
+
throw new UserClientError("INVALID_INPUT", `Expected a ${DIGEST_LENGTH}-byte digest, got ${digestBytes.length}`);
|
|
139
|
+
}
|
|
140
|
+
const signature = secp256k1.sign(digestBytes, hexToBytes(accountPrivateKey), { lowS: true });
|
|
141
|
+
return bytesToHex(concatBytes(signature.toCompactRawBytes(), Uint8Array.from([27 + signature.recovery])));
|
|
142
|
+
}
|
|
143
|
+
function buildRegistrationRequest(keys, domain) {
|
|
144
|
+
const metaStealthAddress = metaStealthAddressFromKeys(keys);
|
|
145
|
+
const digest = registerIdentityDigest(domain, keys.networkId, metaStealthAddress);
|
|
146
|
+
return {
|
|
147
|
+
version: REQUEST_VERSION,
|
|
148
|
+
networkId: keys.networkId,
|
|
149
|
+
schemeId: SCHEME_ID,
|
|
150
|
+
metaStealthAddress,
|
|
151
|
+
chainId: domain.chainId,
|
|
152
|
+
verifyingContract: domain.verifyingContract,
|
|
153
|
+
signature: signRegisterIdentity(keys.account.privateKey, digest)
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Whether a member server pinned to `expected` should accept the request: field widths, a
|
|
158
|
+
* well-formed meta-stealth address, the domain, and a signer matching the full `networkId`.
|
|
159
|
+
*
|
|
160
|
+
* `expected` is required because the domain inside a request is attacker-controlled — verifying
|
|
161
|
+
* against it would only prove the request is self-consistent, not that it targets this registry.
|
|
162
|
+
*
|
|
163
|
+
* Returns false rather than throwing, so an untrusted request can be passed in as-is.
|
|
164
|
+
*/
|
|
165
|
+
function verifyRegistrationRequest(request, expected) {
|
|
166
|
+
try {
|
|
167
|
+
if (request.version !== REQUEST_VERSION || request.schemeId !== SCHEME_ID)
|
|
168
|
+
return false;
|
|
169
|
+
// Neither field is covered by the signature, so both have to be pinned to the caller's own
|
|
170
|
+
// registry. Otherwise a signature made for another chain or contract replays here.
|
|
171
|
+
if (request.chainId !== expected.chainId)
|
|
172
|
+
return false;
|
|
173
|
+
if (request.verifyingContract.toLowerCase() !== expected.verifyingContract.toLowerCase())
|
|
174
|
+
return false;
|
|
175
|
+
// The contract types these as bytes32 and a 66-byte bytes. A request signed self-consistently
|
|
176
|
+
// over the wrong width verifies against itself but cannot be submitted.
|
|
177
|
+
const networkIdBytes = hexToBytes(request.networkId);
|
|
178
|
+
if (networkIdBytes.length !== NETWORK_ID_LENGTH)
|
|
179
|
+
return false;
|
|
180
|
+
if (hexToBytes(request.metaStealthAddress).length !== META_STEALTH_ADDRESS_LENGTH)
|
|
181
|
+
return false;
|
|
182
|
+
// Width alone admits 66 bytes the contract would store but no one could ever pay to.
|
|
183
|
+
if (!validateMetaStealthAddress(request.metaStealthAddress))
|
|
184
|
+
return false;
|
|
185
|
+
const signatureBytes = hexToBytes(request.signature);
|
|
186
|
+
if (signatureBytes.length !== SIGNATURE_LENGTH)
|
|
187
|
+
return false;
|
|
188
|
+
const rs = signatureBytes.slice(0, 64);
|
|
189
|
+
const recovery = signatureBytes[64] - 27;
|
|
190
|
+
if (recovery !== 0 && recovery !== 1)
|
|
191
|
+
return false;
|
|
192
|
+
const digest = registerIdentityDigest(expected, request.networkId, request.metaStealthAddress);
|
|
193
|
+
const signature = secp256k1.Signature.fromCompact(rs).addRecoveryBit(recovery);
|
|
194
|
+
// Signing uses `lowS` (EIP-2), so verification rejects high-S to agree with it.
|
|
195
|
+
if (signature.hasHighS())
|
|
196
|
+
return false;
|
|
197
|
+
const recoveredPublicKey = signature.recoverPublicKey(hexToBytes(digest)).toBytes(false);
|
|
198
|
+
// computeNetworkId is keccak256(X || Y); compare all 32 bytes. An address-style check over the
|
|
199
|
+
// low 20 would leave the top 12 unauthenticated and let one key claim 2^96 ids. The contract
|
|
200
|
+
// itself checks no signer at all.
|
|
201
|
+
return computeNetworkId(recoveredPublicKey).toLowerCase() === request.networkId.toLowerCase();
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* In-memory stand-in for the registry's read model and the two reverts on its register path,
|
|
210
|
+
* length then duplicate; updateIdentity/removeIdentity are not modelled. `tests/integration`
|
|
211
|
+
* asserts both reverts against the real contract and this fake side by side.
|
|
212
|
+
*/
|
|
213
|
+
class InMemoryIdentityRegistry {
|
|
214
|
+
constructor() {
|
|
215
|
+
this.identities = new Map();
|
|
216
|
+
}
|
|
217
|
+
registerIdentity(networkId, metaStealthAddress) {
|
|
218
|
+
const length = hexToBytes(metaStealthAddress).length;
|
|
219
|
+
if (length !== META_STEALTH_ADDRESS_LENGTH) {
|
|
220
|
+
throw new SubmissionFailedError(`InvalidMetaStealthAddressLength: expected ${META_STEALTH_ADDRESS_LENGTH} bytes, got ${length}`);
|
|
221
|
+
}
|
|
222
|
+
const key = networkId.toLowerCase();
|
|
223
|
+
if (this.identities.has(key)) {
|
|
224
|
+
throw new SubmissionFailedError(`AlreadyRegistered: ${networkId}`);
|
|
225
|
+
}
|
|
226
|
+
this.identities.set(key, metaStealthAddress);
|
|
227
|
+
}
|
|
228
|
+
async getIdentity(networkId) {
|
|
229
|
+
return this.identities.get(networkId.toLowerCase()) ?? null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Stands in for an approved network member's server: checks the user's consent the way a real
|
|
235
|
+
* server must before writing, then writes. The Semaphore proof a real server builds before
|
|
236
|
+
* submitting on chain is implied by construction, not modeled here.
|
|
237
|
+
*/
|
|
238
|
+
class InMemoryMemberServer {
|
|
239
|
+
constructor(registry, domain) {
|
|
240
|
+
this.registry = registry;
|
|
241
|
+
this.domain = domain;
|
|
242
|
+
}
|
|
243
|
+
async submit(request) {
|
|
244
|
+
if (!verifyRegistrationRequest(request, this.domain)) {
|
|
245
|
+
throw new SubmissionFailedError("InvalidRegistrationRequest");
|
|
246
|
+
}
|
|
247
|
+
this.registry.registerIdentity(request.networkId, request.metaStealthAddress);
|
|
248
|
+
return { txHash: bytesToHex(keccak_256(hexToBytes(request.signature))) };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function onboard(deviceSecret) {
|
|
253
|
+
const keys = deriveKeys(deviceSecret);
|
|
254
|
+
const metaStealthAddress = metaStealthAddressFromKeys(keys);
|
|
255
|
+
return {
|
|
256
|
+
keys,
|
|
257
|
+
networkId: keys.networkId,
|
|
258
|
+
metaStealthAddress,
|
|
259
|
+
displayMetaStealthAddress: metaStealthAddressUri(metaStealthAddress)
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* IdentityRegistry stores the mapping as `bytes`, so its generated getter answers an unregistered
|
|
264
|
+
* networkId with empty bytes. A reader built straight onto viem or ethers therefore returns "0x"
|
|
265
|
+
* where this package's port requires null, and treating that as an entry would report success while
|
|
266
|
+
* never registering anything.
|
|
267
|
+
*/
|
|
268
|
+
function readExistingEntry(value, networkId) {
|
|
269
|
+
if (value === null || value === "0x")
|
|
270
|
+
return null;
|
|
271
|
+
const length = hexToBytes(value).length;
|
|
272
|
+
if (length !== META_STEALTH_ADDRESS_LENGTH) {
|
|
273
|
+
throw new UserClientError("INVALID_INPUT", `RegistryReader returned a ${length}-byte entry for ${networkId}; expected ${META_STEALTH_ADDRESS_LENGTH} bytes or null`);
|
|
274
|
+
}
|
|
275
|
+
return value;
|
|
276
|
+
}
|
|
277
|
+
async function register(params) {
|
|
278
|
+
const { keys, networkId, metaStealthAddress } = onboard(params.deviceSecret);
|
|
279
|
+
let existingRaw;
|
|
280
|
+
try {
|
|
281
|
+
existingRaw = await params.registry.getIdentity(networkId);
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
throw new RegistryReadFailedError(`registry read failed for ${networkId}`, error);
|
|
285
|
+
}
|
|
286
|
+
const existing = readExistingEntry(existingRaw, networkId);
|
|
287
|
+
if (existing !== null) {
|
|
288
|
+
// A mismatch means this device's keys do not derive the address its networkId currently
|
|
289
|
+
// resolves to. Only a network member calling updateIdentity can change that entry.
|
|
290
|
+
return {
|
|
291
|
+
status: "alreadyRegistered",
|
|
292
|
+
networkId,
|
|
293
|
+
metaStealthAddress: existing,
|
|
294
|
+
derivedMetaStealthAddress: metaStealthAddress,
|
|
295
|
+
matchesDerived: existing.toLowerCase() === metaStealthAddress.toLowerCase()
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
const request = buildRegistrationRequest(keys, params.domain);
|
|
299
|
+
let receipt;
|
|
300
|
+
try {
|
|
301
|
+
receipt = await params.submitter.submit(request);
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
if (error instanceof SubmissionFailedError)
|
|
305
|
+
throw error;
|
|
306
|
+
throw new SubmissionFailedError(`Registration submission failed for ${networkId}`, error);
|
|
307
|
+
}
|
|
308
|
+
return { status: "registered", networkId, metaStealthAddress, request, receipt };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Thrown by {@link createHttpRegistrationSubmitter}. `alreadyRegistered`
|
|
313
|
+
* carries the registry's current entry so a caller can re-run `register()`
|
|
314
|
+
* and land in its own `alreadyRegistered` branch once that's visible to
|
|
315
|
+
* `params.registry.getIdentity`.
|
|
316
|
+
*/
|
|
317
|
+
class HttpSubmissionError extends SubmissionFailedError {
|
|
318
|
+
constructor(message, init) {
|
|
319
|
+
super(message, init.cause);
|
|
320
|
+
this.name = "HttpSubmissionError";
|
|
321
|
+
this.status = init.status;
|
|
322
|
+
this.reason = init.reason;
|
|
323
|
+
this.serverCode = init.serverCode;
|
|
324
|
+
this.metaStealthAddress = init.metaStealthAddress;
|
|
325
|
+
this.matchesRequest = init.matchesRequest;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/** Parses `value` as a 32-byte `0x` hex tx hash, or `null` if it isn't one. */
|
|
329
|
+
function parseTxHash(value) {
|
|
330
|
+
if (typeof value !== "string")
|
|
331
|
+
return null;
|
|
332
|
+
try {
|
|
333
|
+
return hexToBytes(value).length === TX_HASH_LENGTH ? value : null;
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* HTTP `RegistrationSubmitter` for `apps/issuer-service`'s `POST /v1/registrations`.
|
|
341
|
+
* Never logs or throws the request body or its signature — only the response
|
|
342
|
+
* status and, where sent, the server's own error code.
|
|
343
|
+
*/
|
|
344
|
+
function createHttpRegistrationSubmitter({ url, fetch: fetchImpl = fetch }) {
|
|
345
|
+
return {
|
|
346
|
+
async submit(request) {
|
|
347
|
+
let response;
|
|
348
|
+
try {
|
|
349
|
+
response = await fetchImpl(`${url}/v1/registrations`, {
|
|
350
|
+
method: "POST",
|
|
351
|
+
headers: { "Content-Type": "application/json" },
|
|
352
|
+
body: JSON.stringify(request)
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
throw new HttpSubmissionError("Registration submission failed: network error", {
|
|
357
|
+
reason: "network",
|
|
358
|
+
cause: error
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
let body;
|
|
362
|
+
try {
|
|
363
|
+
body = await response.json();
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
throw new HttpSubmissionError(`Registration submission returned ${response.status} with no JSON body`, {
|
|
367
|
+
status: response.status,
|
|
368
|
+
reason: "invalidResponse",
|
|
369
|
+
cause: error
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
if (response.status === 201) {
|
|
373
|
+
const txHash = parseTxHash(body?.txHash);
|
|
374
|
+
if (txHash === null) {
|
|
375
|
+
throw new HttpSubmissionError("Registration submission returned 201 with a malformed body", {
|
|
376
|
+
status: 201,
|
|
377
|
+
reason: "invalidResponse"
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
return { txHash };
|
|
381
|
+
}
|
|
382
|
+
if (response.status === 200) {
|
|
383
|
+
const { metaStealthAddress, matchesRequest } = (body ?? {});
|
|
384
|
+
throw new HttpSubmissionError("Registration already exists for this networkId", {
|
|
385
|
+
status: 200,
|
|
386
|
+
reason: "alreadyRegistered",
|
|
387
|
+
metaStealthAddress,
|
|
388
|
+
matchesRequest
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
if (response.status === 400) {
|
|
392
|
+
throw new HttpSubmissionError("Registration submission was rejected", {
|
|
393
|
+
status: 400,
|
|
394
|
+
reason: "rejected"
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
if (response.status === 503) {
|
|
398
|
+
throw new HttpSubmissionError("Registration service is unavailable", {
|
|
399
|
+
status: 503,
|
|
400
|
+
reason: "unavailable",
|
|
401
|
+
serverCode: body?.code
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
throw new HttpSubmissionError(`Registration submission returned unexpected status ${response.status}`, {
|
|
405
|
+
status: response.status,
|
|
406
|
+
reason: "invalidResponse"
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export { DIGEST_LENGTH, EIP712_DOMAIN_TYPE, HttpSubmissionError, IDENTITY_REGISTRY_DOMAIN_NAME, IDENTITY_REGISTRY_DOMAIN_VERSION, InMemoryIdentityRegistry, InMemoryMemberServer, NETWORK_ID_LENGTH, REGISTER_IDENTITY_TYPE, REQUEST_VERSION, RegistryReadFailedError, SCHEME_ID, SIGNATURE_LENGTH, SubmissionFailedError, TX_HASH_LENGTH, UserClientError, buildRegistrationRequest, createHttpRegistrationSubmitter, identityRegistryDomainSeparator, metaStealthAddressFromKeys, onboard, register, registerIdentityDigest, registerIdentityStructHash, signRegisterIdentity, verifyRegistrationRequest };
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@coordination-network/user-client",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Single-call onboarding and registration for the coordination network: derive device keys, encode the meta-stealth address, and register networkId -> metaStealthAddress on IdentityRegistry.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/zk-kit/coordination-network.git",
|
|
9
|
+
"directory": "packages/user-client"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/zk-kit/coordination-network/tree/main/packages/user-client",
|
|
12
|
+
"bugs": "https://github.com/zk-kit/coordination-network/issues",
|
|
13
|
+
"sideEffects": false,
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "dist/index.cjs",
|
|
16
|
+
"module": "dist/index.js",
|
|
17
|
+
"types": "types/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./types/index.d.ts",
|
|
21
|
+
"require": "./dist/index.cjs",
|
|
22
|
+
"default": "./dist/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist/",
|
|
27
|
+
"types/",
|
|
28
|
+
"src/",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "rimraf dist types && rollup -c rollup.config.mjs"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@coordination-network/identity": "^0.0.1",
|
|
40
|
+
"@noble/curves": "^1.9.7",
|
|
41
|
+
"@noble/hashes": "^1.8.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@coordination-network/network-member-client": "^0.0.1",
|
|
45
|
+
"@coordination-network/network-member-proof": "^0.0.1",
|
|
46
|
+
"@rollup/plugin-typescript": "^12.3.0",
|
|
47
|
+
"@semaphore-protocol/group": "4.14.3",
|
|
48
|
+
"@semaphore-protocol/identity": "4.14.3",
|
|
49
|
+
"rimraf": "^6.1.3",
|
|
50
|
+
"rollup": "^4.62.2",
|
|
51
|
+
"rollup-plugin-cleanup": "^3.2.1",
|
|
52
|
+
"rollup-plugin-dts": "^6.4.1",
|
|
53
|
+
"tslib": "^2.8.0",
|
|
54
|
+
"typescript": "^6.0.3",
|
|
55
|
+
"viem": "^2.55.11"
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/bytes.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {
|
|
2
|
+
hexToBytes as nobleHexToBytes,
|
|
3
|
+
bytesToHex as nobleBytesToHex,
|
|
4
|
+
concatBytes as nobleConcatBytes
|
|
5
|
+
} from "@noble/hashes/utils"
|
|
6
|
+
import { Hex } from "./types"
|
|
7
|
+
import { UserClientError } from "./errors"
|
|
8
|
+
|
|
9
|
+
/** Parses a 0x-prefixed hex string into bytes. Throws on missing prefix, odd length, or non-hex chars. */
|
|
10
|
+
export function hexToBytes(hex: Hex): Uint8Array {
|
|
11
|
+
if (typeof hex !== "string" || !hex.startsWith("0x")) {
|
|
12
|
+
throw new UserClientError("INVALID_INPUT", `Expected a 0x-prefixed hex string, got ${typeof hex}`)
|
|
13
|
+
}
|
|
14
|
+
const body = hex.slice(2)
|
|
15
|
+
if (body.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(body)) {
|
|
16
|
+
throw new UserClientError("INVALID_INPUT", `Malformed hex string of length ${body.length}`)
|
|
17
|
+
}
|
|
18
|
+
return nobleHexToBytes(body)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function bytesToHex(bytes: Uint8Array): Hex {
|
|
22
|
+
return `0x${nobleBytesToHex(bytes)}` as Hex
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function concatBytes(...arrays: Uint8Array[]): Uint8Array {
|
|
26
|
+
return nobleConcatBytes(...arrays)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Big-endian 32-byte word encoding of a non-negative safe-integer uint256, as abi.encode would produce. */
|
|
30
|
+
export function numberToWord(value: number): Uint8Array {
|
|
31
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
32
|
+
throw new UserClientError("INVALID_INPUT", `Expected a non-negative safe integer, got ${typeof value} ${value}`)
|
|
33
|
+
}
|
|
34
|
+
const word = new Uint8Array(32)
|
|
35
|
+
let remaining = BigInt(value)
|
|
36
|
+
for (let i = 31; i >= 0 && remaining > 0n; i -= 1) {
|
|
37
|
+
word[i] = Number(remaining & 0xffn)
|
|
38
|
+
remaining >>= 8n
|
|
39
|
+
}
|
|
40
|
+
return word
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Left-pads a 20-byte address into a 32-byte word, as abi.encode would produce. */
|
|
44
|
+
export function addressToWord(address: Hex): Uint8Array {
|
|
45
|
+
const addressBytes = hexToBytes(address)
|
|
46
|
+
if (addressBytes.length !== 20) {
|
|
47
|
+
throw new UserClientError("INVALID_INPUT", `Expected a 20-byte address, got ${addressBytes.length} bytes`)
|
|
48
|
+
}
|
|
49
|
+
const word = new Uint8Array(32)
|
|
50
|
+
word.set(addressBytes, 12)
|
|
51
|
+
return word
|
|
52
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** ERC-6538 scheme 1 (secp256k1 stealth addresses), the schemeId field of the consent struct. */
|
|
2
|
+
export const SCHEME_ID = 1 as const
|
|
3
|
+
/** networkId is a bytes32 on chain, and abi.encode splices it in as one word. */
|
|
4
|
+
export const NETWORK_ID_LENGTH = 32
|
|
5
|
+
/** r || s || v, the 65-byte Ethereum signature layout. */
|
|
6
|
+
export const SIGNATURE_LENGTH = 65
|
|
7
|
+
/** keccak256 output, the only thing this package will sign. */
|
|
8
|
+
export const DIGEST_LENGTH = 32
|
|
9
|
+
/** Ethereum transaction hash, as HttpSubmissionError's 201 branch expects it. */
|
|
10
|
+
export const TX_HASH_LENGTH = 32
|
|
11
|
+
/** Wire-format version of RegistrationRequest. */
|
|
12
|
+
export const REQUEST_VERSION = "v1" as const
|
|
13
|
+
export const IDENTITY_REGISTRY_DOMAIN_NAME = "IdentityRegistry"
|
|
14
|
+
export const IDENTITY_REGISTRY_DOMAIN_VERSION = "1"
|
|
15
|
+
/** Signed by the user as consent to register; the member server verifies it, the contract never reads it. */
|
|
16
|
+
export const REGISTER_IDENTITY_TYPE = "RegisterIdentity(bytes32 networkId,uint256 schemeId,bytes metaStealthAddress)"
|
|
17
|
+
export const EIP712_DOMAIN_TYPE = "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
|
package/src/eip712.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { keccak_256 } from "@noble/hashes/sha3"
|
|
2
|
+
import { utf8ToBytes } from "@noble/hashes/utils"
|
|
3
|
+
import { ChainDomain, Hex } from "./types"
|
|
4
|
+
import { UserClientError } from "./errors"
|
|
5
|
+
import { hexToBytes, bytesToHex, concatBytes, numberToWord, addressToWord } from "./bytes"
|
|
6
|
+
import {
|
|
7
|
+
REGISTER_IDENTITY_TYPE,
|
|
8
|
+
EIP712_DOMAIN_TYPE,
|
|
9
|
+
IDENTITY_REGISTRY_DOMAIN_NAME,
|
|
10
|
+
IDENTITY_REGISTRY_DOMAIN_VERSION,
|
|
11
|
+
NETWORK_ID_LENGTH,
|
|
12
|
+
SCHEME_ID
|
|
13
|
+
} from "./constants"
|
|
14
|
+
|
|
15
|
+
const EIP712_DOMAIN_TYPEHASH = keccak_256(utf8ToBytes(EIP712_DOMAIN_TYPE))
|
|
16
|
+
const REGISTER_IDENTITY_TYPEHASH = keccak_256(utf8ToBytes(REGISTER_IDENTITY_TYPE))
|
|
17
|
+
|
|
18
|
+
/** Every input is already a 32-byte word, so concatenating them is what abi.encode would emit. */
|
|
19
|
+
function hashEncodedWords(...words: Uint8Array[]): Uint8Array {
|
|
20
|
+
return keccak_256(concatBytes(...words))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function identityRegistryDomainSeparator(domain: ChainDomain): Hex {
|
|
24
|
+
const nameHash = keccak_256(utf8ToBytes(IDENTITY_REGISTRY_DOMAIN_NAME))
|
|
25
|
+
const versionHash = keccak_256(utf8ToBytes(IDENTITY_REGISTRY_DOMAIN_VERSION))
|
|
26
|
+
return bytesToHex(
|
|
27
|
+
hashEncodedWords(
|
|
28
|
+
EIP712_DOMAIN_TYPEHASH,
|
|
29
|
+
nameHash,
|
|
30
|
+
versionHash,
|
|
31
|
+
numberToWord(domain.chainId),
|
|
32
|
+
addressToWord(domain.verifyingContract)
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function registerIdentityStructHash(networkId: Hex, metaStealthAddress: Hex): Hex {
|
|
38
|
+
const networkIdWord = hexToBytes(networkId)
|
|
39
|
+
if (networkIdWord.length !== NETWORK_ID_LENGTH) {
|
|
40
|
+
throw new UserClientError(
|
|
41
|
+
"INVALID_INPUT",
|
|
42
|
+
`Expected a ${NETWORK_ID_LENGTH}-byte networkId, got ${networkIdWord.length} bytes`
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
const metaHash = keccak_256(hexToBytes(metaStealthAddress))
|
|
46
|
+
return bytesToHex(hashEncodedWords(REGISTER_IDENTITY_TYPEHASH, networkIdWord, numberToWord(SCHEME_ID), metaHash))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The digest the user signs as consent to register this meta-stealth address at this registry on
|
|
51
|
+
* this chain. The member server verifies it before submitting; the contract never reads it.
|
|
52
|
+
*/
|
|
53
|
+
export function registerIdentityDigest(domain: ChainDomain, networkId: Hex, metaStealthAddress: Hex): Hex {
|
|
54
|
+
const domainSeparator = hexToBytes(identityRegistryDomainSeparator(domain))
|
|
55
|
+
const structHash = hexToBytes(registerIdentityStructHash(networkId, metaStealthAddress))
|
|
56
|
+
return bytesToHex(keccak_256(concatBytes(Uint8Array.from([0x19, 0x01]), domainSeparator, structHash)))
|
|
57
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type UserClientErrorCode = "INVALID_INPUT" | "SUBMISSION_FAILED" | "REGISTRY_READ_FAILED"
|
|
2
|
+
|
|
3
|
+
/** Base error class for all errors thrown by this package, discriminated by `code`. */
|
|
4
|
+
export class UserClientError extends Error {
|
|
5
|
+
readonly code: UserClientErrorCode
|
|
6
|
+
constructor(code: UserClientErrorCode, message: string) {
|
|
7
|
+
super(message)
|
|
8
|
+
this.name = "UserClientError"
|
|
9
|
+
this.code = code
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class SubmissionFailedError extends UserClientError {
|
|
14
|
+
// `declare` emits no field: at ES2022 one would be created enumerable, defeating defineProperty.
|
|
15
|
+
declare readonly cause?: unknown
|
|
16
|
+
|
|
17
|
+
constructor(message: string, cause?: unknown) {
|
|
18
|
+
super("SUBMISSION_FAILED", message)
|
|
19
|
+
this.name = "SubmissionFailedError"
|
|
20
|
+
if (cause !== undefined) {
|
|
21
|
+
// Non-enumerable, matching the standard Error.cause. Provider errors hold circular
|
|
22
|
+
// transport graphs, so an enumerable copy makes JSON.stringify throw inside a
|
|
23
|
+
// caller's error handler and drags the provider's URL into its logs.
|
|
24
|
+
Object.defineProperty(this, "cause", { value: cause, writable: true, configurable: true })
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class RegistryReadFailedError extends UserClientError {
|
|
30
|
+
// See SubmissionFailedError's constructor for why cause is declared and non-enumerable.
|
|
31
|
+
declare readonly cause?: unknown
|
|
32
|
+
|
|
33
|
+
constructor(message: string, cause?: unknown) {
|
|
34
|
+
super("REGISTRY_READ_FAILED", message)
|
|
35
|
+
this.name = "RegistryReadFailedError"
|
|
36
|
+
if (cause !== undefined) {
|
|
37
|
+
Object.defineProperty(this, "cause", { value: cause, writable: true, configurable: true })
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|