@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.
@@ -0,0 +1,151 @@
1
+ import { Hex, RegistrationRequest, RegistrationSubmitter, SubmissionReceipt } from "./types"
2
+ import { hexToBytes } from "./bytes"
3
+ import { TX_HASH_LENGTH } from "./constants"
4
+ import { SubmissionFailedError } from "./errors"
5
+
6
+ export type HttpSubmissionReason = "alreadyRegistered" | "rejected" | "unavailable" | "network" | "invalidResponse"
7
+
8
+ export interface HttpSubmissionErrorInit {
9
+ status?: number
10
+ reason: HttpSubmissionReason
11
+ /** The server's own error code, where the response body carried one. */
12
+ serverCode?: string
13
+ /** The registry's current entry, on an `alreadyRegistered` response. */
14
+ metaStealthAddress?: Hex
15
+ matchesRequest?: boolean
16
+ cause?: unknown
17
+ }
18
+
19
+ /**
20
+ * Thrown by {@link createHttpRegistrationSubmitter}. `alreadyRegistered`
21
+ * carries the registry's current entry so a caller can re-run `register()`
22
+ * and land in its own `alreadyRegistered` branch once that's visible to
23
+ * `params.registry.getIdentity`.
24
+ */
25
+ export class HttpSubmissionError extends SubmissionFailedError {
26
+ readonly status?: number
27
+ readonly reason: HttpSubmissionReason
28
+ readonly serverCode?: string
29
+ readonly metaStealthAddress?: Hex
30
+ readonly matchesRequest?: boolean
31
+
32
+ constructor(message: string, init: HttpSubmissionErrorInit) {
33
+ super(message, init.cause)
34
+ this.name = "HttpSubmissionError"
35
+ this.status = init.status
36
+ this.reason = init.reason
37
+ this.serverCode = init.serverCode
38
+ this.metaStealthAddress = init.metaStealthAddress
39
+ this.matchesRequest = init.matchesRequest
40
+ }
41
+ }
42
+
43
+ export interface HttpRegistrationSubmitterOptions {
44
+ url: string
45
+ fetch?: typeof fetch
46
+ }
47
+
48
+ interface SubmittedBody {
49
+ status: "submitted"
50
+ txHash?: unknown
51
+ }
52
+
53
+ interface AlreadyRegisteredBody {
54
+ status: "alreadyRegistered"
55
+ metaStealthAddress?: Hex
56
+ matchesRequest?: boolean
57
+ }
58
+
59
+ interface ErrorBody {
60
+ code?: string
61
+ }
62
+
63
+ /** Parses `value` as a 32-byte `0x` hex tx hash, or `null` if it isn't one. */
64
+ function parseTxHash(value: unknown): Hex | null {
65
+ if (typeof value !== "string") return null
66
+ try {
67
+ return hexToBytes(value as Hex).length === TX_HASH_LENGTH ? (value as Hex) : null
68
+ } catch {
69
+ return null
70
+ }
71
+ }
72
+
73
+ /**
74
+ * HTTP `RegistrationSubmitter` for `apps/issuer-service`'s `POST /v1/registrations`.
75
+ * Never logs or throws the request body or its signature — only the response
76
+ * status and, where sent, the server's own error code.
77
+ */
78
+ export function createHttpRegistrationSubmitter({
79
+ url,
80
+ fetch: fetchImpl = fetch
81
+ }: HttpRegistrationSubmitterOptions): RegistrationSubmitter {
82
+ return {
83
+ async submit(request: RegistrationRequest): Promise<SubmissionReceipt> {
84
+ let response: Response
85
+ try {
86
+ response = await fetchImpl(`${url}/v1/registrations`, {
87
+ method: "POST",
88
+ headers: { "Content-Type": "application/json" },
89
+ body: JSON.stringify(request)
90
+ })
91
+ } catch (error) {
92
+ throw new HttpSubmissionError("Registration submission failed: network error", {
93
+ reason: "network",
94
+ cause: error
95
+ })
96
+ }
97
+
98
+ let body: unknown
99
+ try {
100
+ body = await response.json()
101
+ } catch (error) {
102
+ throw new HttpSubmissionError(`Registration submission returned ${response.status} with no JSON body`, {
103
+ status: response.status,
104
+ reason: "invalidResponse",
105
+ cause: error
106
+ })
107
+ }
108
+
109
+ if (response.status === 201) {
110
+ const txHash = parseTxHash((body as SubmittedBody | null)?.txHash)
111
+ if (txHash === null) {
112
+ throw new HttpSubmissionError("Registration submission returned 201 with a malformed body", {
113
+ status: 201,
114
+ reason: "invalidResponse"
115
+ })
116
+ }
117
+ return { txHash }
118
+ }
119
+
120
+ if (response.status === 200) {
121
+ const { metaStealthAddress, matchesRequest } = (body ?? {}) as AlreadyRegisteredBody
122
+ throw new HttpSubmissionError("Registration already exists for this networkId", {
123
+ status: 200,
124
+ reason: "alreadyRegistered",
125
+ metaStealthAddress,
126
+ matchesRequest
127
+ })
128
+ }
129
+
130
+ if (response.status === 400) {
131
+ throw new HttpSubmissionError("Registration submission was rejected", {
132
+ status: 400,
133
+ reason: "rejected"
134
+ })
135
+ }
136
+
137
+ if (response.status === 503) {
138
+ throw new HttpSubmissionError("Registration service is unavailable", {
139
+ status: 503,
140
+ reason: "unavailable",
141
+ serverCode: (body as ErrorBody | null)?.code
142
+ })
143
+ }
144
+
145
+ throw new HttpSubmissionError(`Registration submission returned unexpected status ${response.status}`, {
146
+ status: response.status,
147
+ reason: "invalidResponse"
148
+ })
149
+ }
150
+ }
151
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export type { DerivedKeys } from "@coordination-network/identity"
2
+ export { InvalidMetaStealthAddressError } from "@coordination-network/identity"
3
+
4
+ export * from "./errors"
5
+ export * from "./types"
6
+ export * from "./constants"
7
+ export * from "./meta"
8
+ export * from "./eip712"
9
+ export * from "./request"
10
+ export * from "./registry"
11
+ export * from "./member-server"
12
+ export * from "./register"
13
+ export * from "./http-submitter"
@@ -0,0 +1,26 @@
1
+ import { keccak_256 } from "@noble/hashes/sha3"
2
+ import { ChainDomain, RegistrationRequest, RegistrationSubmitter, SubmissionReceipt } from "./types"
3
+ import { hexToBytes, bytesToHex } from "./bytes"
4
+ import { verifyRegistrationRequest } from "./request"
5
+ import { SubmissionFailedError } from "./errors"
6
+ import { InMemoryIdentityRegistry } from "./registry"
7
+
8
+ /**
9
+ * Stands in for an approved network member's server: checks the user's consent the way a real
10
+ * server must before writing, then writes. The Semaphore proof a real server builds before
11
+ * submitting on chain is implied by construction, not modeled here.
12
+ */
13
+ export class InMemoryMemberServer implements RegistrationSubmitter {
14
+ constructor(
15
+ private readonly registry: InMemoryIdentityRegistry,
16
+ private readonly domain: ChainDomain
17
+ ) {}
18
+
19
+ async submit(request: RegistrationRequest): Promise<SubmissionReceipt> {
20
+ if (!verifyRegistrationRequest(request, this.domain)) {
21
+ throw new SubmissionFailedError("InvalidRegistrationRequest")
22
+ }
23
+ this.registry.registerIdentity(request.networkId, request.metaStealthAddress)
24
+ return { txHash: bytesToHex(keccak_256(hexToBytes(request.signature))) }
25
+ }
26
+ }
package/src/meta.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { DerivedKeys, encodeMetaStealthAddress } from "@coordination-network/identity"
2
+ import { Hex } from "./types"
3
+
4
+ /** Encodes a device's spending and viewing keys as its ERC-6538 scheme-1 meta-stealth address. */
5
+ export function metaStealthAddressFromKeys(keys: DerivedKeys): Hex {
6
+ return encodeMetaStealthAddress({
7
+ spendingPublicKey: keys.spending.publicKey,
8
+ viewingPublicKey: keys.viewing.publicKey
9
+ })
10
+ }
11
+
12
+ /** Aliased, not reimplemented, so the `st:eth:` prefix lives in one place. */
13
+ export { metaStealthAddressUri as displayMetaStealthAddress } from "@coordination-network/identity"
@@ -0,0 +1,70 @@
1
+ import { deriveKeys, META_STEALTH_ADDRESS_LENGTH } from "@coordination-network/identity"
2
+ import { Hex, Onboarding, RegisterParams, RegisterResult } from "./types"
3
+ import { metaStealthAddressFromKeys, displayMetaStealthAddress } from "./meta"
4
+ import { buildRegistrationRequest } from "./request"
5
+ import { hexToBytes } from "./bytes"
6
+ import { RegistryReadFailedError, SubmissionFailedError, UserClientError } from "./errors"
7
+
8
+ export function onboard(deviceSecret: Uint8Array | Hex): Onboarding {
9
+ const keys = deriveKeys(deviceSecret)
10
+ const metaStealthAddress = metaStealthAddressFromKeys(keys)
11
+ return {
12
+ keys,
13
+ networkId: keys.networkId,
14
+ metaStealthAddress,
15
+ displayMetaStealthAddress: displayMetaStealthAddress(metaStealthAddress)
16
+ }
17
+ }
18
+
19
+ /**
20
+ * IdentityRegistry stores the mapping as `bytes`, so its generated getter answers an unregistered
21
+ * networkId with empty bytes. A reader built straight onto viem or ethers therefore returns "0x"
22
+ * where this package's port requires null, and treating that as an entry would report success while
23
+ * never registering anything.
24
+ */
25
+ function readExistingEntry(value: Hex | null, networkId: Hex): Hex | null {
26
+ if (value === null || value === "0x") return null
27
+
28
+ const length = hexToBytes(value).length
29
+ if (length !== META_STEALTH_ADDRESS_LENGTH) {
30
+ throw new UserClientError(
31
+ "INVALID_INPUT",
32
+ `RegistryReader returned a ${length}-byte entry for ${networkId}; expected ${META_STEALTH_ADDRESS_LENGTH} bytes or null`
33
+ )
34
+ }
35
+ return value
36
+ }
37
+
38
+ export async function register(params: RegisterParams): Promise<RegisterResult> {
39
+ const { keys, networkId, metaStealthAddress } = onboard(params.deviceSecret)
40
+
41
+ let existingRaw: Hex | null
42
+ try {
43
+ existingRaw = await params.registry.getIdentity(networkId)
44
+ } catch (error) {
45
+ throw new RegistryReadFailedError(`registry read failed for ${networkId}`, error)
46
+ }
47
+ const existing = readExistingEntry(existingRaw, networkId)
48
+ if (existing !== null) {
49
+ // A mismatch means this device's keys do not derive the address its networkId currently
50
+ // resolves to. Only a network member calling updateIdentity can change that entry.
51
+ return {
52
+ status: "alreadyRegistered",
53
+ networkId,
54
+ metaStealthAddress: existing,
55
+ derivedMetaStealthAddress: metaStealthAddress,
56
+ matchesDerived: existing.toLowerCase() === metaStealthAddress.toLowerCase()
57
+ }
58
+ }
59
+
60
+ const request = buildRegistrationRequest(keys, params.domain)
61
+ let receipt
62
+ try {
63
+ receipt = await params.submitter.submit(request)
64
+ } catch (error) {
65
+ if (error instanceof SubmissionFailedError) throw error
66
+ throw new SubmissionFailedError(`Registration submission failed for ${networkId}`, error)
67
+ }
68
+
69
+ return { status: "registered", networkId, metaStealthAddress, request, receipt }
70
+ }
@@ -0,0 +1,32 @@
1
+ import { META_STEALTH_ADDRESS_LENGTH } from "@coordination-network/identity"
2
+ import { Hex, RegistryReader } from "./types"
3
+ import { hexToBytes } from "./bytes"
4
+ import { SubmissionFailedError } from "./errors"
5
+
6
+ /**
7
+ * In-memory stand-in for the registry's read model and the two reverts on its register path,
8
+ * length then duplicate; updateIdentity/removeIdentity are not modelled. `tests/integration`
9
+ * asserts both reverts against the real contract and this fake side by side.
10
+ */
11
+ export class InMemoryIdentityRegistry implements RegistryReader {
12
+ private readonly identities = new Map<Hex, Hex>()
13
+
14
+ registerIdentity(networkId: Hex, metaStealthAddress: Hex): void {
15
+ const length = hexToBytes(metaStealthAddress).length
16
+ if (length !== META_STEALTH_ADDRESS_LENGTH) {
17
+ throw new SubmissionFailedError(
18
+ `InvalidMetaStealthAddressLength: expected ${META_STEALTH_ADDRESS_LENGTH} bytes, got ${length}`
19
+ )
20
+ }
21
+
22
+ const key = networkId.toLowerCase() as Hex
23
+ if (this.identities.has(key)) {
24
+ throw new SubmissionFailedError(`AlreadyRegistered: ${networkId}`)
25
+ }
26
+ this.identities.set(key, metaStealthAddress)
27
+ }
28
+
29
+ async getIdentity(networkId: Hex): Promise<Hex | null> {
30
+ return this.identities.get(networkId.toLowerCase() as Hex) ?? null
31
+ }
32
+ }
package/src/request.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { secp256k1 } from "@noble/curves/secp256k1"
2
+ import {
3
+ computeNetworkId,
4
+ DerivedKeys,
5
+ META_STEALTH_ADDRESS_LENGTH,
6
+ validateMetaStealthAddress
7
+ } from "@coordination-network/identity"
8
+ import { ChainDomain, Hex, RegistrationRequest } from "./types"
9
+ import { hexToBytes, bytesToHex, concatBytes } from "./bytes"
10
+ import { UserClientError } from "./errors"
11
+ import { registerIdentityDigest } from "./eip712"
12
+ import { metaStealthAddressFromKeys } from "./meta"
13
+ import { DIGEST_LENGTH, NETWORK_ID_LENGTH, REQUEST_VERSION, SCHEME_ID, SIGNATURE_LENGTH } from "./constants"
14
+
15
+ /** Returns a 65-byte `r || s || v` signature, with `v = 27 + recovery id` as the contract expects. */
16
+ export function signRegisterIdentity(accountPrivateKey: Hex, digest: Hex): Hex {
17
+ const digestBytes = hexToBytes(digest)
18
+ if (digestBytes.length !== DIGEST_LENGTH) {
19
+ // Exported, so refuse to sign anything that is not a digest this package produced.
20
+ throw new UserClientError("INVALID_INPUT", `Expected a ${DIGEST_LENGTH}-byte digest, got ${digestBytes.length}`)
21
+ }
22
+ const signature = secp256k1.sign(digestBytes, hexToBytes(accountPrivateKey), { lowS: true })
23
+ return bytesToHex(concatBytes(signature.toCompactRawBytes(), Uint8Array.from([27 + signature.recovery])))
24
+ }
25
+
26
+ export function buildRegistrationRequest(keys: DerivedKeys, domain: ChainDomain): RegistrationRequest {
27
+ const metaStealthAddress = metaStealthAddressFromKeys(keys)
28
+ const digest = registerIdentityDigest(domain, keys.networkId, metaStealthAddress)
29
+ return {
30
+ version: REQUEST_VERSION,
31
+ networkId: keys.networkId,
32
+ schemeId: SCHEME_ID,
33
+ metaStealthAddress,
34
+ chainId: domain.chainId,
35
+ verifyingContract: domain.verifyingContract,
36
+ signature: signRegisterIdentity(keys.account.privateKey, digest)
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Whether a member server pinned to `expected` should accept the request: field widths, a
42
+ * well-formed meta-stealth address, the domain, and a signer matching the full `networkId`.
43
+ *
44
+ * `expected` is required because the domain inside a request is attacker-controlled — verifying
45
+ * against it would only prove the request is self-consistent, not that it targets this registry.
46
+ *
47
+ * Returns false rather than throwing, so an untrusted request can be passed in as-is.
48
+ */
49
+ export function verifyRegistrationRequest(request: RegistrationRequest, expected: ChainDomain): boolean {
50
+ try {
51
+ if (request.version !== REQUEST_VERSION || request.schemeId !== SCHEME_ID) return false
52
+
53
+ // Neither field is covered by the signature, so both have to be pinned to the caller's own
54
+ // registry. Otherwise a signature made for another chain or contract replays here.
55
+ if (request.chainId !== expected.chainId) return false
56
+ if (request.verifyingContract.toLowerCase() !== expected.verifyingContract.toLowerCase()) return false
57
+
58
+ // The contract types these as bytes32 and a 66-byte bytes. A request signed self-consistently
59
+ // over the wrong width verifies against itself but cannot be submitted.
60
+ const networkIdBytes = hexToBytes(request.networkId)
61
+ if (networkIdBytes.length !== NETWORK_ID_LENGTH) return false
62
+ if (hexToBytes(request.metaStealthAddress).length !== META_STEALTH_ADDRESS_LENGTH) return false
63
+
64
+ // Width alone admits 66 bytes the contract would store but no one could ever pay to.
65
+ if (!validateMetaStealthAddress(request.metaStealthAddress)) return false
66
+
67
+ const signatureBytes = hexToBytes(request.signature)
68
+ if (signatureBytes.length !== SIGNATURE_LENGTH) return false
69
+
70
+ const rs = signatureBytes.slice(0, 64)
71
+ const recovery = signatureBytes[64] - 27
72
+ if (recovery !== 0 && recovery !== 1) return false
73
+
74
+ const digest = registerIdentityDigest(expected, request.networkId, request.metaStealthAddress)
75
+ const signature = secp256k1.Signature.fromCompact(rs).addRecoveryBit(recovery)
76
+
77
+ // Signing uses `lowS` (EIP-2), so verification rejects high-S to agree with it.
78
+ if (signature.hasHighS()) return false
79
+
80
+ const recoveredPublicKey = signature.recoverPublicKey(hexToBytes(digest)).toBytes(false)
81
+
82
+ // computeNetworkId is keccak256(X || Y); compare all 32 bytes. An address-style check over the
83
+ // low 20 would leave the top 12 unauthenticated and let one key claim 2^96 ids. The contract
84
+ // itself checks no signer at all.
85
+ return computeNetworkId(recoveredPublicKey).toLowerCase() === request.networkId.toLowerCase()
86
+ } catch {
87
+ return false
88
+ }
89
+ }
package/src/types.ts ADDED
@@ -0,0 +1,71 @@
1
+ import { DerivedKeys } from "@coordination-network/identity"
2
+
3
+ export type Hex = `0x${string}`
4
+
5
+ /** The registry deployment the consent is bound to. */
6
+ export interface ChainDomain {
7
+ chainId: number
8
+ verifyingContract: Hex
9
+ }
10
+
11
+ export interface RegistrationRequest {
12
+ version: "v1"
13
+ networkId: Hex
14
+ schemeId: 1
15
+ metaStealthAddress: Hex
16
+ chainId: number
17
+ verifyingContract: Hex
18
+ /** EIP-712 consent over the request, checked by the member server. */
19
+ signature: Hex
20
+ }
21
+
22
+ export interface SubmissionReceipt {
23
+ /** Null when the submitter queued the registration rather than broadcasting it itself. */
24
+ txHash: Hex | null
25
+ }
26
+
27
+ export interface RegistryReader {
28
+ /**
29
+ * The 66-byte entry, or null when the networkId is unregistered. IdentityRegistry stores the
30
+ * mapping as `bytes`, so a chain-backed reader must map its empty-bytes answer to null.
31
+ */
32
+ getIdentity(networkId: Hex): Promise<Hex | null>
33
+ }
34
+
35
+ /** Hands the signed request to the network member's server, which submits it on chain. */
36
+ export interface RegistrationSubmitter {
37
+ submit(request: RegistrationRequest): Promise<SubmissionReceipt>
38
+ }
39
+
40
+ export interface Onboarding {
41
+ keys: DerivedKeys
42
+ networkId: Hex
43
+ metaStealthAddress: Hex
44
+ displayMetaStealthAddress: string
45
+ }
46
+
47
+ export interface RegisterParams {
48
+ deviceSecret: Uint8Array | Hex
49
+ registry: RegistryReader
50
+ submitter: RegistrationSubmitter
51
+ domain: ChainDomain
52
+ }
53
+
54
+ export type RegisterResult =
55
+ | {
56
+ /** Submitted, not confirmed: `register` returns as soon as the submitter accepts. */
57
+ status: "registered"
58
+ networkId: Hex
59
+ metaStealthAddress: Hex
60
+ request: RegistrationRequest
61
+ receipt: SubmissionReceipt
62
+ }
63
+ | {
64
+ status: "alreadyRegistered"
65
+ networkId: Hex
66
+ /** What the registry currently resolves to; a network member can change it via updateIdentity/removeIdentity. */
67
+ metaStealthAddress: Hex
68
+ /** What this device secret derives today. */
69
+ derivedMetaStealthAddress: Hex
70
+ matchesDerived: boolean
71
+ }
@@ -0,0 +1,185 @@
1
+ import { DerivedKeys } from '@coordination-network/identity';
2
+ export { DerivedKeys, InvalidMetaStealthAddressError, metaStealthAddressUri as displayMetaStealthAddress } from '@coordination-network/identity';
3
+
4
+ type UserClientErrorCode = "INVALID_INPUT" | "SUBMISSION_FAILED" | "REGISTRY_READ_FAILED";
5
+ /** Base error class for all errors thrown by this package, discriminated by `code`. */
6
+ declare class UserClientError extends Error {
7
+ readonly code: UserClientErrorCode;
8
+ constructor(code: UserClientErrorCode, message: string);
9
+ }
10
+ declare class SubmissionFailedError extends UserClientError {
11
+ readonly cause?: unknown;
12
+ constructor(message: string, cause?: unknown);
13
+ }
14
+ declare class RegistryReadFailedError extends UserClientError {
15
+ readonly cause?: unknown;
16
+ constructor(message: string, cause?: unknown);
17
+ }
18
+
19
+ type Hex = `0x${string}`;
20
+ /** The registry deployment the consent is bound to. */
21
+ interface ChainDomain {
22
+ chainId: number;
23
+ verifyingContract: Hex;
24
+ }
25
+ interface RegistrationRequest {
26
+ version: "v1";
27
+ networkId: Hex;
28
+ schemeId: 1;
29
+ metaStealthAddress: Hex;
30
+ chainId: number;
31
+ verifyingContract: Hex;
32
+ /** EIP-712 consent over the request, checked by the member server. */
33
+ signature: Hex;
34
+ }
35
+ interface SubmissionReceipt {
36
+ /** Null when the submitter queued the registration rather than broadcasting it itself. */
37
+ txHash: Hex | null;
38
+ }
39
+ interface RegistryReader {
40
+ /**
41
+ * The 66-byte entry, or null when the networkId is unregistered. IdentityRegistry stores the
42
+ * mapping as `bytes`, so a chain-backed reader must map its empty-bytes answer to null.
43
+ */
44
+ getIdentity(networkId: Hex): Promise<Hex | null>;
45
+ }
46
+ /** Hands the signed request to the network member's server, which submits it on chain. */
47
+ interface RegistrationSubmitter {
48
+ submit(request: RegistrationRequest): Promise<SubmissionReceipt>;
49
+ }
50
+ interface Onboarding {
51
+ keys: DerivedKeys;
52
+ networkId: Hex;
53
+ metaStealthAddress: Hex;
54
+ displayMetaStealthAddress: string;
55
+ }
56
+ interface RegisterParams {
57
+ deviceSecret: Uint8Array | Hex;
58
+ registry: RegistryReader;
59
+ submitter: RegistrationSubmitter;
60
+ domain: ChainDomain;
61
+ }
62
+ type RegisterResult = {
63
+ /** Submitted, not confirmed: `register` returns as soon as the submitter accepts. */
64
+ status: "registered";
65
+ networkId: Hex;
66
+ metaStealthAddress: Hex;
67
+ request: RegistrationRequest;
68
+ receipt: SubmissionReceipt;
69
+ } | {
70
+ status: "alreadyRegistered";
71
+ networkId: Hex;
72
+ /** What the registry currently resolves to; a network member can change it via updateIdentity/removeIdentity. */
73
+ metaStealthAddress: Hex;
74
+ /** What this device secret derives today. */
75
+ derivedMetaStealthAddress: Hex;
76
+ matchesDerived: boolean;
77
+ };
78
+
79
+ /** ERC-6538 scheme 1 (secp256k1 stealth addresses), the schemeId field of the consent struct. */
80
+ declare const SCHEME_ID: 1;
81
+ /** networkId is a bytes32 on chain, and abi.encode splices it in as one word. */
82
+ declare const NETWORK_ID_LENGTH = 32;
83
+ /** r || s || v, the 65-byte Ethereum signature layout. */
84
+ declare const SIGNATURE_LENGTH = 65;
85
+ /** keccak256 output, the only thing this package will sign. */
86
+ declare const DIGEST_LENGTH = 32;
87
+ /** Ethereum transaction hash, as HttpSubmissionError's 201 branch expects it. */
88
+ declare const TX_HASH_LENGTH = 32;
89
+ /** Wire-format version of RegistrationRequest. */
90
+ declare const REQUEST_VERSION: "v1";
91
+ declare const IDENTITY_REGISTRY_DOMAIN_NAME = "IdentityRegistry";
92
+ declare const IDENTITY_REGISTRY_DOMAIN_VERSION = "1";
93
+ /** Signed by the user as consent to register; the member server verifies it, the contract never reads it. */
94
+ declare const REGISTER_IDENTITY_TYPE = "RegisterIdentity(bytes32 networkId,uint256 schemeId,bytes metaStealthAddress)";
95
+ declare const EIP712_DOMAIN_TYPE = "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)";
96
+
97
+ /** Encodes a device's spending and viewing keys as its ERC-6538 scheme-1 meta-stealth address. */
98
+ declare function metaStealthAddressFromKeys(keys: DerivedKeys): Hex;
99
+
100
+ declare function identityRegistryDomainSeparator(domain: ChainDomain): Hex;
101
+ declare function registerIdentityStructHash(networkId: Hex, metaStealthAddress: Hex): Hex;
102
+ /**
103
+ * The digest the user signs as consent to register this meta-stealth address at this registry on
104
+ * this chain. The member server verifies it before submitting; the contract never reads it.
105
+ */
106
+ declare function registerIdentityDigest(domain: ChainDomain, networkId: Hex, metaStealthAddress: Hex): Hex;
107
+
108
+ /** Returns a 65-byte `r || s || v` signature, with `v = 27 + recovery id` as the contract expects. */
109
+ declare function signRegisterIdentity(accountPrivateKey: Hex, digest: Hex): Hex;
110
+ declare function buildRegistrationRequest(keys: DerivedKeys, domain: ChainDomain): RegistrationRequest;
111
+ /**
112
+ * Whether a member server pinned to `expected` should accept the request: field widths, a
113
+ * well-formed meta-stealth address, the domain, and a signer matching the full `networkId`.
114
+ *
115
+ * `expected` is required because the domain inside a request is attacker-controlled — verifying
116
+ * against it would only prove the request is self-consistent, not that it targets this registry.
117
+ *
118
+ * Returns false rather than throwing, so an untrusted request can be passed in as-is.
119
+ */
120
+ declare function verifyRegistrationRequest(request: RegistrationRequest, expected: ChainDomain): boolean;
121
+
122
+ /**
123
+ * In-memory stand-in for the registry's read model and the two reverts on its register path,
124
+ * length then duplicate; updateIdentity/removeIdentity are not modelled. `tests/integration`
125
+ * asserts both reverts against the real contract and this fake side by side.
126
+ */
127
+ declare class InMemoryIdentityRegistry implements RegistryReader {
128
+ private readonly identities;
129
+ registerIdentity(networkId: Hex, metaStealthAddress: Hex): void;
130
+ getIdentity(networkId: Hex): Promise<Hex | null>;
131
+ }
132
+
133
+ /**
134
+ * Stands in for an approved network member's server: checks the user's consent the way a real
135
+ * server must before writing, then writes. The Semaphore proof a real server builds before
136
+ * submitting on chain is implied by construction, not modeled here.
137
+ */
138
+ declare class InMemoryMemberServer implements RegistrationSubmitter {
139
+ private readonly registry;
140
+ private readonly domain;
141
+ constructor(registry: InMemoryIdentityRegistry, domain: ChainDomain);
142
+ submit(request: RegistrationRequest): Promise<SubmissionReceipt>;
143
+ }
144
+
145
+ declare function onboard(deviceSecret: Uint8Array | Hex): Onboarding;
146
+ declare function register(params: RegisterParams): Promise<RegisterResult>;
147
+
148
+ type HttpSubmissionReason = "alreadyRegistered" | "rejected" | "unavailable" | "network" | "invalidResponse";
149
+ interface HttpSubmissionErrorInit {
150
+ status?: number;
151
+ reason: HttpSubmissionReason;
152
+ /** The server's own error code, where the response body carried one. */
153
+ serverCode?: string;
154
+ /** The registry's current entry, on an `alreadyRegistered` response. */
155
+ metaStealthAddress?: Hex;
156
+ matchesRequest?: boolean;
157
+ cause?: unknown;
158
+ }
159
+ /**
160
+ * Thrown by {@link createHttpRegistrationSubmitter}. `alreadyRegistered`
161
+ * carries the registry's current entry so a caller can re-run `register()`
162
+ * and land in its own `alreadyRegistered` branch once that's visible to
163
+ * `params.registry.getIdentity`.
164
+ */
165
+ declare class HttpSubmissionError extends SubmissionFailedError {
166
+ readonly status?: number;
167
+ readonly reason: HttpSubmissionReason;
168
+ readonly serverCode?: string;
169
+ readonly metaStealthAddress?: Hex;
170
+ readonly matchesRequest?: boolean;
171
+ constructor(message: string, init: HttpSubmissionErrorInit);
172
+ }
173
+ interface HttpRegistrationSubmitterOptions {
174
+ url: string;
175
+ fetch?: typeof fetch;
176
+ }
177
+ /**
178
+ * HTTP `RegistrationSubmitter` for `apps/issuer-service`'s `POST /v1/registrations`.
179
+ * Never logs or throws the request body or its signature — only the response
180
+ * status and, where sent, the server's own error code.
181
+ */
182
+ declare function createHttpRegistrationSubmitter({ url, fetch: fetchImpl }: HttpRegistrationSubmitterOptions): RegistrationSubmitter;
183
+
184
+ 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 };
185
+ export type { ChainDomain, Hex, HttpRegistrationSubmitterOptions, HttpSubmissionErrorInit, HttpSubmissionReason, Onboarding, RegisterParams, RegisterResult, RegistrationRequest, RegistrationSubmitter, RegistryReader, SubmissionReceipt, UserClientErrorCode };