tersign 0.4.11 → 0.6.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.
@@ -1,5 +1,6 @@
1
1
  import type { Account } from 'viem/accounts';
2
2
  import type { Assure } from '../assure.js';
3
+ import { type SignerStatus } from '../receipt/binding.js';
3
4
  import type { DisputeReason, EvidenceArtifactRef } from '../dispute/types.js';
4
5
  import type { LedgerClient } from '../ledgerClient.js';
5
6
  import type { ComplianceRecordV1, SignedComplianceRecord, SignedReceipt } from '../types.js';
@@ -31,10 +32,27 @@ export interface IssueReceiptArgs {
31
32
  principal?: string | undefined;
32
33
  }
33
34
  export declare function issueReceiptTool(deps: McpDeps, args: IssueReceiptArgs): Promise<import("../assure.js").IssuedReceipt>;
34
- export declare function verifyReceiptTool(artifact: SignedReceipt, expectedSigner?: string): Promise<import("../index.js").VerifyResult>;
35
- export declare function verifyRecordTool(record: ComplianceRecordV1, attestation: SignedComplianceRecord['attestation'], expectedSigner?: string): Promise<import("../compliance/types.js").VerifyLike>;
35
+ /** What the MCP verify tools return: the library result led by a one-sentence `verdict` and an
36
+ * explicit `signerStatus`, so an agent that reads only the first field is told what was NOT
37
+ * checked. Same shape as `python3 -m tersign verify`'s JSON (its verdict says PASS/FAIL where
38
+ * this says VALID/INVALID, the npm CLI's words). */
39
+ export interface VerifyToolResult {
40
+ verdict: string;
41
+ valid: boolean;
42
+ signer?: `0x${string}`;
43
+ signerStatus?: SignerStatus;
44
+ signerBound: boolean;
45
+ expectedSigner?: string;
46
+ testKey?: string;
47
+ digest?: `0x${string}`;
48
+ unsignedFields?: string[];
49
+ reason?: string;
50
+ }
51
+ export declare function verifyReceiptTool(artifact: SignedReceipt, expectedSigner?: string): Promise<VerifyToolResult>;
52
+ export declare function verifyRecordTool(record: ComplianceRecordV1, attestation: SignedComplianceRecord['attestation'], expectedSigner?: string): Promise<VerifyToolResult>;
36
53
  export declare function recordRefundTool(deps: McpDeps, originalDigest: `0x${string}`, amount: string, reason: string): Promise<{
37
54
  id: string;
55
+ status: string;
38
56
  }>;
39
57
  export interface OpenDisputeArgs {
40
58
  receiptDigest: `0x${string}`;
@@ -51,8 +69,8 @@ export interface SubmitEvidenceArgs {
51
69
  artifacts: EvidenceArtifactRef[];
52
70
  }
53
71
  export declare function submitEvidenceTool(deps: McpDeps, args: SubmitEvidenceArgs): Promise<unknown>;
54
- /** Trigger deterministic adjudication (public — the rulebook is recomputable, so anyone
55
- * may pull the trigger once the route guard allows it). */
72
+ /** Trigger deterministic adjudication (no API key — the outcome is a deterministic function of
73
+ * the recorded inputs, so anyone may pull the trigger once the route guard allows it). */
56
74
  export declare function adjudicateDisputeTool(deps: McpDeps, disputeDigest: `0x${string}`): Promise<unknown>;
57
75
  export declare function getDisputeTool(deps: McpDeps, disputeDigest: `0x${string}`): Promise<unknown>;
58
76
  export interface RecordDisclosureArgs {
package/dist/mcp/tools.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { verifyReceipt } from '../receipt/eip712.js';
2
- import { verifyComplianceRecord } from '../compliance/record.js';
2
+ import { recordDigest, verifyComplianceRecord } from '../compliance/record.js';
3
+ import { digestOf } from '../canonical.js';
4
+ import { findLoneSurrogate, signerStatus } from '../receipt/binding.js';
5
+ import { verdictSentence } from '../verify-report.js';
3
6
  import { signDispute, signEvidence } from '../dispute/sign.js';
4
7
  async function ledgerFetch(base, path, init) {
5
8
  const url = `${base.replace(/\/$/, '')}${path}`;
@@ -30,11 +33,56 @@ export async function issueReceiptTool(deps, args) {
30
33
  };
31
34
  return deps.assure.issueFor(ctx);
32
35
  }
36
+ function report(r, expectedSigner, digest, what) {
37
+ const status = signerStatus(r, expectedSigner);
38
+ // Key order is the Python CLI's: verdict first, the signer and its status next to each other.
39
+ return {
40
+ verdict: verdictSentence(r, expectedSigner, 'expectedSigner', what),
41
+ valid: r.valid,
42
+ ...(r.signer ? { signer: r.signer } : {}),
43
+ ...(status ? { signerStatus: status } : {}),
44
+ signerBound: r.signerBound,
45
+ ...(expectedSigner ? { expectedSigner } : {}),
46
+ ...(r.testKey ? { testKey: r.testKey } : {}),
47
+ ...(digest ? { digest } : {}),
48
+ ...(r.unsignedFields?.length ? { unsignedFields: r.unsignedFields } : {}),
49
+ ...(r.reason ? { reason: r.reason } : {}),
50
+ };
51
+ }
52
+ /** A digest failure (a float, an out-of-range integer) is a FAIL: an artifact with no canonical
53
+ * content address cannot be the one a ledger recorded. The Python twin decides it the same way. */
54
+ function tryDigest(f) {
55
+ try {
56
+ return { digest: f() };
57
+ }
58
+ catch (e) {
59
+ return { error: e instanceof Error ? e.message : String(e) };
60
+ }
61
+ }
62
+ /** A lone UTF-16 surrogate anywhere in what is verified: UTF-8 cannot carry it, so the signed and
63
+ * digested text is U+FFFD, not what the object says. Package text only — no path, since key names
64
+ * are chosen by whoever wrote the object. */
65
+ const LONE_SURROGATE_REASON = 'a string in the object holds a lone UTF-16 surrogate (a \\uD800-\\uDFFF escape) that UTF-8 cannot carry: the signed and digested text would be U+FFFD, not this text';
33
66
  export async function verifyReceiptTool(artifact, expectedSigner) {
34
- return verifyReceipt(artifact, expectedSigner);
67
+ if (findLoneSurrogate(artifact) !== null) {
68
+ return report({ valid: false, signerBound: false, reason: LONE_SURROGATE_REASON }, expectedSigner, undefined, 'receipt');
69
+ }
70
+ const r = await verifyReceipt(artifact, expectedSigner);
71
+ if (!r.signer)
72
+ return report(r, expectedSigner, undefined, 'receipt');
73
+ const d = tryDigest(() => digestOf(artifact));
74
+ if (d.error !== undefined) {
75
+ return report({ valid: false, signerBound: false, reason: `cannot compute the canonical digest: ${d.error}` }, expectedSigner, undefined, 'receipt');
76
+ }
77
+ return report(r, expectedSigner, d.digest, 'receipt');
35
78
  }
36
79
  export async function verifyRecordTool(record, attestation, expectedSigner) {
37
- return verifyComplianceRecord({ record, attestation }, expectedSigner);
80
+ if (findLoneSurrogate({ record, attestation }) !== null) {
81
+ return report({ valid: false, signerBound: false, reason: LONE_SURROGATE_REASON }, expectedSigner, undefined, 'compliance record');
82
+ }
83
+ const r = await verifyComplianceRecord({ record, attestation }, expectedSigner);
84
+ const d = tryDigest(() => recordDigest(record));
85
+ return report(r, expectedSigner, d.digest, 'compliance record');
38
86
  }
39
87
  export async function recordRefundTool(deps, originalDigest, amount, reason) {
40
88
  if (!deps.ledger)
@@ -71,8 +119,8 @@ export async function submitEvidenceTool(deps, args) {
71
119
  ...(args.role === 'respondent' && deps.ledgerHttp.apiKey !== undefined ? { apiKey: deps.ledgerHttp.apiKey } : {}),
72
120
  });
73
121
  }
74
- /** Trigger deterministic adjudication (public — the rulebook is recomputable, so anyone
75
- * may pull the trigger once the route guard allows it). */
122
+ /** Trigger deterministic adjudication (no API key — the outcome is a deterministic function of
123
+ * the recorded inputs, so anyone may pull the trigger once the route guard allows it). */
76
124
  export async function adjudicateDisputeTool(deps, disputeDigest) {
77
125
  if (!deps.ledgerHttp)
78
126
  throw new Error('ledger URL not configured — set TERSIGN_LEDGER_URL');
@@ -0,0 +1,75 @@
1
+ export declare const ADDRESS_RE: RegExp;
2
+ export declare const MALFORMED_EXPECTED_SIGNER = "expectedSigner is not a 20-byte 0x-prefixed hex address";
3
+ /** undefined, null and '' mean "not supplied". Anything else must be a 20-byte hex address:
4
+ * a malformed value is a FAILURE, never a silently skipped comparison (0, false, [] and {}
5
+ * included — a caller who passed something meant to bind). */
6
+ export declare function parseExpectedSigner(v: unknown): {
7
+ ok: true;
8
+ value: string | undefined;
9
+ } | {
10
+ ok: false;
11
+ reason: string;
12
+ };
13
+ export type SignerStatus = 'BOUND' | 'UNAUTHENTICATED' | 'MISMATCH';
14
+ /** The fields every binding-aware verify result carries. */
15
+ export interface SignerBinding {
16
+ valid: boolean;
17
+ signer?: `0x${string}`;
18
+ /** true only when an expected signer was supplied AND the signature recovers to exactly it.
19
+ * false with valid:true means `signer` is whatever the artifact's own signature recovers to:
20
+ * that proves neither who signed nor that the artifact is unmodified. */
21
+ signerBound: boolean;
22
+ /** present when the recovered signer is a PUBLISHED test key (see known-keys.ts): anyone can
23
+ * produce that signature, so even a bound match says nothing about who issued it. */
24
+ testKey?: string;
25
+ reason?: string;
26
+ }
27
+ /** BOUND: matched the expected signer. MISMATCH: an expected signer was supplied and the
28
+ * signature recovers to a different address. UNAUTHENTICATED: none was supplied. Undefined when
29
+ * nothing was recovered. Decided from the inputs, never from the reason text. */
30
+ export declare function signerStatus(r: SignerBinding, expected: string | undefined): SignerStatus | undefined;
31
+ /** Attach signerBound + testKey to a recovered signer, comparing against `expected` when given. */
32
+ export declare function bindSigner(signer: `0x${string}`, expected: string | undefined, mismatchReason: string): SignerBinding;
33
+ /** Why an EIP-712 `signature` is not THE canonical encoding, or undefined. Checked BEFORE
34
+ * recovery, with the same rules as the Python twin's verify_receipt (sdk-py tersign/verify.py
35
+ * signature_error). Each signature has exactly one accepted string, so a re-encoding of it is
36
+ * refused. That is a property of one signature, not of a signer and message: the signer can
37
+ * produce other valid signatures over the same message, so key any deduplication on (signer,
38
+ * message), never on the signature bytes. The accepted string is:
39
+ *
40
+ * "0x" + 130 LOWER-CASE hex digits = r (32 bytes) || s (32 bytes) || v (1 byte), with
41
+ * 1 <= r, s < n; s <= n/2 (low-s); v = 27 or 28 (0x1b / 0x1c) — what viem's
42
+ * serializeSignature emits, so every signature this package and the ledger produce.
43
+ *
44
+ * Everything else is refused, each with its own reason: a non-string ({r,s,v} object, 65-number
45
+ * array), a missing 0x, any other length, an upper-case hex digit, the recovery-id twin (v = 0/1
46
+ * for 27/28), an out-of-range r/s, and the high-s twin. viem accepts every one of those and
47
+ * recovers the real signer from each, and each re-encoding gives the file a different content
48
+ * digest — so without this one issuance had several "also valid" byte-distinct copies, each
49
+ * printing a bound VALID for signature bytes the issuer never produced (high-s, object and array
50
+ * found 2026-09-27; v 0/1 and upper-case hex found by the release review the same day).
51
+ *
52
+ * Scope: verifyReceipt, verifyComplianceRecord and the CLI and MCP tools built on them. The
53
+ * action-record, dispute and evidence verifiers (evidence/action.ts, dispute/sign.ts) do NOT call
54
+ * this and still accept what viem accepts (PUNT-REGISTER R8). Neither does the ledger's ingest
55
+ * (SECURITY-AUDIT A8), so a receipt the ledger counter-signed with a non-canonical signature is
56
+ * refused here. The reasons are package text only: nothing from the file is echoed. */
57
+ export declare function signatureError(sig: unknown): string | undefined;
58
+ /** A UTF-16 surrogate with no partner. JSON's \uD800-\uDFFF escapes can put one in a parsed string;
59
+ * UTF-8 cannot carry it, so the EIP-712 encoder (viem's TextEncoder) signs U+FFFD in its place and
60
+ * the text the file holds is not the text that was signed. The Python verifier refuses such a file
61
+ * outright ('utf-8' codec can't encode). */
62
+ export declare const LONE_SURROGATE: RegExp;
63
+ /** The first string (key or value) anywhere in `v` holding a lone surrogate, as a path, or null. */
64
+ export declare function findLoneSurrogate(v: unknown, path?: string): string | null;
65
+ export declare function isPlainObject(v: unknown): v is Record<string, unknown>;
66
+ /** Code-point order — the order Python's sorted() gives, so both verifiers list fields alike. */
67
+ export declare function byCodePoint(a: string, b: string): number;
68
+ /** Render text that came from the artifact (a field NAME, a ledger's string) so it can never
69
+ * forge output: control, format and line/paragraph-separator characters become \u escapes.
70
+ * Without this a payload key such as "x\nVALID" would print a bare VALID line of its own. */
71
+ export declare function safeText(s: string, max?: number): string;
72
+ /** A field name as a JSON string literal — `"payload.amount"` — clipped, quotes and
73
+ * backslashes escaped by JSON.stringify, and the invisible characters it leaves raw
74
+ * (U+2028/U+2029, bidi and other format controls) escaped too. */
75
+ export declare function quoteField(name: string): string;
@@ -0,0 +1,161 @@
1
+ /** What an EIP-712 verify can and cannot establish about WHO signed — shared by verifyReceipt,
2
+ * verifyComplianceRecord, the `tersign verify` CLI and the MCP verify tools, so those four
3
+ * surfaces cannot drift apart on the one question a verify result is read for. verifyActionRecord,
4
+ * verifyDispute and verifyEvidence do NOT use it: they have no signer binding and no canonical
5
+ * signature check (PUNT-REGISTER R8).
6
+ *
7
+ * ECDSA recovery yields an address for ANY payload and ANY well-formed signature. An edited
8
+ * receipt therefore still "verifies" — it recovers a different address. A recovered signer is
9
+ * evidence of authorship only when it is compared against an address the caller obtained
10
+ * somewhere else; without that comparison it is UNAUTHENTICATED, and every result says so. */
11
+ import { publishedKeyLabel } from './known-keys.js';
12
+ export const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
13
+ export const MALFORMED_EXPECTED_SIGNER = 'expectedSigner is not a 20-byte 0x-prefixed hex address';
14
+ /** undefined, null and '' mean "not supplied". Anything else must be a 20-byte hex address:
15
+ * a malformed value is a FAILURE, never a silently skipped comparison (0, false, [] and {}
16
+ * included — a caller who passed something meant to bind). */
17
+ export function parseExpectedSigner(v) {
18
+ if (v === undefined || v === null || v === '')
19
+ return { ok: true, value: undefined };
20
+ if (typeof v !== 'string' || !ADDRESS_RE.test(v))
21
+ return { ok: false, reason: MALFORMED_EXPECTED_SIGNER };
22
+ return { ok: true, value: v };
23
+ }
24
+ /** BOUND: matched the expected signer. MISMATCH: an expected signer was supplied and the
25
+ * signature recovers to a different address. UNAUTHENTICATED: none was supplied. Undefined when
26
+ * nothing was recovered. Decided from the inputs, never from the reason text. */
27
+ export function signerStatus(r, expected) {
28
+ if (!r.signer)
29
+ return undefined;
30
+ if (r.signerBound)
31
+ return 'BOUND';
32
+ return expected ? 'MISMATCH' : 'UNAUTHENTICATED';
33
+ }
34
+ /** Attach signerBound + testKey to a recovered signer, comparing against `expected` when given. */
35
+ export function bindSigner(signer, expected, mismatchReason) {
36
+ const out = { valid: true, signer, signerBound: expected !== undefined };
37
+ const label = publishedKeyLabel(signer);
38
+ if (label)
39
+ out.testKey = label;
40
+ if (expected !== undefined && signer.toLowerCase() !== expected.toLowerCase()) {
41
+ out.valid = false;
42
+ out.signerBound = false;
43
+ out.reason = mismatchReason;
44
+ }
45
+ return out;
46
+ }
47
+ /** secp256k1 group order. A signature's s must be at most N/2 (Ethereum's canonical low-s form). */
48
+ const SECP256K1_N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
49
+ /** Why an EIP-712 `signature` is not THE canonical encoding, or undefined. Checked BEFORE
50
+ * recovery, with the same rules as the Python twin's verify_receipt (sdk-py tersign/verify.py
51
+ * signature_error). Each signature has exactly one accepted string, so a re-encoding of it is
52
+ * refused. That is a property of one signature, not of a signer and message: the signer can
53
+ * produce other valid signatures over the same message, so key any deduplication on (signer,
54
+ * message), never on the signature bytes. The accepted string is:
55
+ *
56
+ * "0x" + 130 LOWER-CASE hex digits = r (32 bytes) || s (32 bytes) || v (1 byte), with
57
+ * 1 <= r, s < n; s <= n/2 (low-s); v = 27 or 28 (0x1b / 0x1c) — what viem's
58
+ * serializeSignature emits, so every signature this package and the ledger produce.
59
+ *
60
+ * Everything else is refused, each with its own reason: a non-string ({r,s,v} object, 65-number
61
+ * array), a missing 0x, any other length, an upper-case hex digit, the recovery-id twin (v = 0/1
62
+ * for 27/28), an out-of-range r/s, and the high-s twin. viem accepts every one of those and
63
+ * recovers the real signer from each, and each re-encoding gives the file a different content
64
+ * digest — so without this one issuance had several "also valid" byte-distinct copies, each
65
+ * printing a bound VALID for signature bytes the issuer never produced (high-s, object and array
66
+ * found 2026-09-27; v 0/1 and upper-case hex found by the release review the same day).
67
+ *
68
+ * Scope: verifyReceipt, verifyComplianceRecord and the CLI and MCP tools built on them. The
69
+ * action-record, dispute and evidence verifiers (evidence/action.ts, dispute/sign.ts) do NOT call
70
+ * this and still accept what viem accepts (PUNT-REGISTER R8). Neither does the ledger's ingest
71
+ * (SECURITY-AUDIT A8), so a receipt the ledger counter-signed with a non-canonical signature is
72
+ * refused here. The reasons are package text only: nothing from the file is echoed. */
73
+ export function signatureError(sig) {
74
+ if (typeof sig !== 'string') {
75
+ const t = sig === null ? 'null' : Array.isArray(sig) ? 'an array' : typeof sig === 'undefined' ? 'missing' : typeof sig === 'object' ? 'an object' : `a ${typeof sig}`;
76
+ return `signature must be a 0x-prefixed hex string of 65 bytes (r||s||v), not ${t}`;
77
+ }
78
+ if (!/^0x[0-9a-fA-F]{130}$/.test(sig))
79
+ return 'signature must be a 0x-prefixed hex string of 65 bytes (r||s||v)';
80
+ if (/[A-F]/.test(sig))
81
+ return 'signature hex must be lower-case (non-canonical)';
82
+ const r = BigInt(`0x${sig.slice(2, 66)}`);
83
+ const s = BigInt(`0x${sig.slice(66, 130)}`);
84
+ const v = Number.parseInt(sig.slice(130, 132), 16);
85
+ if (v === 0 || v === 1)
86
+ return `recovery id ${v} rejected (non-canonical): v must be 27 or 28`;
87
+ if (v !== 27 && v !== 28)
88
+ return `unsupported recovery id ${v}`;
89
+ if (r < 1n || r >= SECP256K1_N || s < 1n || s >= SECP256K1_N)
90
+ return 'r/s out of range';
91
+ if (s > SECP256K1_N / 2n)
92
+ return 'high-s signature rejected (non-canonical)';
93
+ return undefined;
94
+ }
95
+ /** A UTF-16 surrogate with no partner. JSON's \uD800-\uDFFF escapes can put one in a parsed string;
96
+ * UTF-8 cannot carry it, so the EIP-712 encoder (viem's TextEncoder) signs U+FFFD in its place and
97
+ * the text the file holds is not the text that was signed. The Python verifier refuses such a file
98
+ * outright ('utf-8' codec can't encode). */
99
+ export const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
100
+ /** The first string (key or value) anywhere in `v` holding a lone surrogate, as a path, or null. */
101
+ export function findLoneSurrogate(v, path = '$') {
102
+ if (typeof v === 'string')
103
+ return LONE_SURROGATE.test(v) ? path : null;
104
+ if (Array.isArray(v)) {
105
+ for (let i = 0; i < v.length; i++) {
106
+ const hit = findLoneSurrogate(v[i], `${path}[${i}]`);
107
+ if (hit)
108
+ return hit;
109
+ }
110
+ return null;
111
+ }
112
+ if (isPlainObject(v)) {
113
+ for (const [k, x] of Object.entries(v)) {
114
+ if (LONE_SURROGATE.test(k))
115
+ return `${path} (a key)`;
116
+ const hit = findLoneSurrogate(x, `${path}.${k}`);
117
+ if (hit)
118
+ return hit;
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+ export function isPlainObject(v) {
124
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
125
+ }
126
+ /** Code-point order — the order Python's sorted() gives, so both verifiers list fields alike. */
127
+ export function byCodePoint(a, b) {
128
+ const x = a[Symbol.iterator]();
129
+ const y = b[Symbol.iterator]();
130
+ for (;;) {
131
+ const p = x.next();
132
+ const q = y.next();
133
+ if (p.done || q.done)
134
+ return (p.done ? 0 : 1) - (q.done ? 0 : 1);
135
+ const d = (p.value.codePointAt(0) ?? 0) - (q.value.codePointAt(0) ?? 0);
136
+ if (d !== 0)
137
+ return d;
138
+ }
139
+ }
140
+ /** Render text that came from the artifact (a field NAME, a ledger's string) so it can never
141
+ * forge output: control, format and line/paragraph-separator characters become \u escapes.
142
+ * Without this a payload key such as "x\nVALID" would print a bare VALID line of its own. */
143
+ export function safeText(s, max = 96) {
144
+ return escapeInvisible(clip(s, max));
145
+ }
146
+ /** A field name as a JSON string literal — `"payload.amount"` — clipped, quotes and
147
+ * backslashes escaped by JSON.stringify, and the invisible characters it leaves raw
148
+ * (U+2028/U+2029, bidi and other format controls) escaped too. */
149
+ export function quoteField(name) {
150
+ return escapeInvisible(JSON.stringify(clip(name, 64)));
151
+ }
152
+ function clip(s, max) {
153
+ const cps = [...s];
154
+ return cps.length > max ? cps.slice(0, max).join('') + '…' : s;
155
+ }
156
+ function escapeInvisible(s) {
157
+ return s.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, (ch) => {
158
+ const cp = ch.codePointAt(0) ?? 0;
159
+ return cp > 0xffff ? `\\u{${cp.toString(16)}}` : `\\u${cp.toString(16).padStart(4, '0')}`;
160
+ });
161
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Account } from 'viem/accounts';
2
2
  import type { OfferPayload, ReceiptPayload, SignedOffer, SignedReceipt } from '../types.js';
3
+ import { type SignerBinding } from './binding.js';
3
4
  /** Canonical EIP-712 material from the merged offer-receipt extension. Domain chainId is
4
5
  * hardcoded to 1 by spec (off-chain signing format; payment network lives in payload.network). */
5
6
  export declare const RECEIPT_DOMAIN: {
@@ -62,11 +63,28 @@ export declare const OFFER_TYPES: {
62
63
  };
63
64
  export declare function signReceipt(payload: ReceiptPayload, account: Account): Promise<SignedReceipt>;
64
65
  export declare function signOffer(payload: OfferPayload, account: Account, acceptIndex?: number): Promise<SignedOffer>;
65
- export interface VerifyResult {
66
- valid: boolean;
67
- signer?: `0x${string}`;
68
- reason?: string;
66
+ /** What the EIP-712 Receipt signature covers. Anything else in the artifact rides along
67
+ * unsigned: it changes the canonical digest (the ledger's content address) but not the
68
+ * recovered signer. */
69
+ export declare const SIGNED_RECEIPT_FIELDS: ("version" | "network" | "resourceUrl" | "payer" | "issuedAt" | "transaction")[];
70
+ /** Fields present in `artifact` that the signature does not cover, as paths relative to the
71
+ * artifact — `payload.<k>` first, then top-level keys — in code-point order. */
72
+ export declare function unsignedReceiptFields(artifact: Record<string, unknown>): string[];
73
+ /** valid the signature is well-formed and recovers to an address — and, when
74
+ * expectedSigner is given, to exactly that address.
75
+ * signerBound true only when expectedSigner was supplied and matched. When false, `signer`
76
+ * is whatever the receipt's own signature recovers to, and recovery yields an
77
+ * address for ANY payload: an edited receipt still returns valid:true, with a
78
+ * different signer. valid:true with signerBound:false proves neither who signed
79
+ * nor that the payload is unmodified.
80
+ * testKey the recovered signer is a PUBLISHED test key (known-keys.ts).
81
+ * unsignedFields fields present in the artifact that the signature does not cover.
82
+ * The same result shape as the Python twin's verify_receipt (minus its digest, which the
83
+ * callers here compute themselves). */
84
+ export interface VerifyResult extends SignerBinding {
85
+ unsignedFields?: string[];
69
86
  }
70
87
  /** Verify an EIP-712 receipt. `expectedSigner` implements the spec's payTo-key authorization
71
- * model; pass the seller's payTo address (or a registry-resolved key) to enforce it. */
88
+ * model; pass the seller's payTo address (or a registry-resolved key), obtained out-of-band,
89
+ * to enforce it. Without it the recovered signer is UNAUTHENTICATED (signerBound:false). */
72
90
  export declare function verifyReceipt(artifact: SignedReceipt, expectedSigner?: string): Promise<VerifyResult>;
@@ -1,4 +1,5 @@
1
1
  import { recoverTypedDataAddress } from 'viem';
2
+ import { LONE_SURROGATE, bindSigner, byCodePoint, isPlainObject, parseExpectedSigner, signatureError } from './binding.js';
2
3
  /** Canonical EIP-712 material from the merged offer-receipt extension. Domain chainId is
3
4
  * hardcoded to 1 by spec (off-chain signing format; payment network lives in payload.network). */
4
5
  export const RECEIPT_DOMAIN = { name: 'x402 receipt', version: '1', chainId: 1n };
@@ -71,25 +72,89 @@ export async function signOffer(payload, account, acceptIndex) {
71
72
  ? { format: 'eip712', payload, signature }
72
73
  : { format: 'eip712', payload, signature, acceptIndex };
73
74
  }
75
+ /** What the EIP-712 Receipt signature covers. Anything else in the artifact rides along
76
+ * unsigned: it changes the canonical digest (the ledger's content address) but not the
77
+ * recovered signer. */
78
+ export const SIGNED_RECEIPT_FIELDS = RECEIPT_TYPES.Receipt.map((f) => f.name);
79
+ const ENVELOPE_FIELDS = new Set(['format', 'payload', 'signature']);
80
+ const SIGNED = new Set(SIGNED_RECEIPT_FIELDS);
81
+ /** Fields present in `artifact` that the signature does not cover, as paths relative to the
82
+ * artifact — `payload.<k>` first, then top-level keys — in code-point order. */
83
+ export function unsignedReceiptFields(artifact) {
84
+ const payload = isPlainObject(artifact.payload) ? artifact.payload : {};
85
+ return [
86
+ ...Object.keys(payload).filter((k) => !SIGNED.has(k)).sort(byCodePoint).map((k) => `payload.${k}`),
87
+ ...Object.keys(artifact).filter((k) => !ENVELOPE_FIELDS.has(k)).sort(byCodePoint),
88
+ ];
89
+ }
90
+ function typeName(v) {
91
+ if (v === null)
92
+ return 'null';
93
+ if (Array.isArray(v))
94
+ return 'array';
95
+ if (typeof v === 'number' && !Number.isInteger(v))
96
+ return 'a non-integer number';
97
+ if (typeof v === 'number')
98
+ return v < 0 ? 'a negative integer' : 'an integer outside the safe range';
99
+ return typeof v;
100
+ }
101
+ /** The payload's signed fields, checked by JSON type before anything is recovered. BigInt()
102
+ * coerces true, "1", " 1783761710 " and "0x6a4…" to the signed number: each such edit recovered
103
+ * the real signer under a DIFFERENT digest, and nothing reported it. The ledger counter-signs
104
+ * only JSON safe integers here, and the Python verifier applies the same rule. */
105
+ function signedFieldError(p) {
106
+ for (const k of ['version', 'issuedAt']) {
107
+ const v = p[k];
108
+ const ok = (typeof v === 'number' && Number.isSafeInteger(v) && v >= 0) || (typeof v === 'bigint' && v >= 0n && v < 2n ** 256n);
109
+ if (!ok)
110
+ return `payload.${k} must be a JSON integer, not ${typeName(v)}: the signature covers the number, so another spelling of it is unsigned text`;
111
+ }
112
+ for (const k of ['network', 'resourceUrl', 'payer', 'transaction']) {
113
+ if (typeof p[k] !== 'string')
114
+ return `payload.${k} must be a string, not ${typeName(p[k])}`;
115
+ if (LONE_SURROGATE.test(p[k])) {
116
+ return `payload.${k} holds a lone UTF-16 surrogate (a \\uD800-\\uDFFF escape) that UTF-8 cannot carry: the signature covers U+FFFD in its place, not this text`;
117
+ }
118
+ }
119
+ return undefined;
120
+ }
74
121
  /** Verify an EIP-712 receipt. `expectedSigner` implements the spec's payTo-key authorization
75
- * model; pass the seller's payTo address (or a registry-resolved key) to enforce it. */
122
+ * model; pass the seller's payTo address (or a registry-resolved key), obtained out-of-band,
123
+ * to enforce it. Without it the recovered signer is UNAUTHENTICATED (signerBound:false). */
76
124
  export async function verifyReceipt(artifact, expectedSigner) {
77
- if (artifact.format !== 'eip712')
78
- return { valid: false, reason: 'jws verification not implemented in v0' };
125
+ const expected = parseExpectedSigner(expectedSigner);
126
+ if (!expected.ok)
127
+ return { valid: false, signerBound: false, reason: expected.reason };
128
+ const notReceipt = { valid: false, signerBound: false, reason: 'not a signed receipt: expected {format, payload{...}, signature}' };
129
+ if (!isPlainObject(artifact))
130
+ return notReceipt;
131
+ if (artifact.format !== 'eip712') {
132
+ return { valid: false, signerBound: false, reason: 'jws verification not implemented in v0' };
133
+ }
134
+ if (!isPlainObject(artifact.payload))
135
+ return notReceipt;
136
+ const badField = signedFieldError(artifact.payload);
137
+ if (badField)
138
+ return { valid: false, signerBound: false, reason: badField };
139
+ const badSig = signatureError(artifact.signature);
140
+ if (badSig)
141
+ return { valid: false, signerBound: false, reason: badSig };
142
+ let signer;
79
143
  try {
80
- const signer = await recoverTypedDataAddress({
144
+ signer = await recoverTypedDataAddress({
81
145
  domain: RECEIPT_DOMAIN,
82
146
  types: RECEIPT_TYPES,
83
147
  primaryType: 'Receipt',
84
148
  message: receiptMessage(artifact.payload),
85
149
  signature: artifact.signature,
86
150
  });
87
- if (expectedSigner && signer.toLowerCase() !== expectedSigner.toLowerCase()) {
88
- return { valid: false, signer, reason: 'signer does not match expected authorization key' };
89
- }
90
- return { valid: true, signer };
91
151
  }
92
152
  catch (e) {
93
- return { valid: false, reason: e instanceof Error ? e.message : 'signature recovery failed' };
153
+ return { valid: false, signerBound: false, reason: e instanceof Error ? e.message : 'signature recovery failed' };
94
154
  }
155
+ const out = bindSigner(signer, expected.value, 'signer does not match expected authorization key');
156
+ const unsigned = unsignedReceiptFields(artifact);
157
+ if (unsigned.length)
158
+ out.unsignedFields = unsigned;
159
+ return out;
95
160
  }
@@ -0,0 +1,23 @@
1
+ /** Signer addresses whose private keys are PUBLISHED — anyone can sign as them.
2
+ *
3
+ * A signature from one of these keys recovers and verifies like any other, so a verifier that
4
+ * stays silent about it hands a stranger a receipt that looks authored when anyone could have
5
+ * minted it. They are what worked examples and test suites sign with (ours included: the
6
+ * synthetic evidence bundle uses dev-mnemonic accounts #0 and #1), which is exactly why a
7
+ * real-looking artifact carrying one must be flagged rather than passed quietly.
8
+ *
9
+ * Twin of `sdk-py/tersign/known_keys.py`. Neither copy is transcribed from the other: each is
10
+ * DERIVED from the published secrets and pinned by its own suite — test/known-keys.test.ts
11
+ * re-derives every address here with viem, the Python suite re-derives its table with its own
12
+ * secp256k1 — and the same test compares the two tables entry by entry (address AND label)
13
+ * wherever the monorepo carries both. A key added to one side without the other fails there.
14
+ * Deliberately narrow: keys whose secret is published as a convention, not every key that has
15
+ * ever leaked. */
16
+ /** BIP-39 mnemonic shipped as the default dev account set by Hardhat (20 accounts) and Anvil
17
+ * (10), derived at m/44'/60'/0'/0/i. */
18
+ export declare const DEV_MNEMONIC = "test test test test test test test test test test test junk";
19
+ /** lowercase address → the published secret it comes from. A Map, not an object literal: a
20
+ * lookup of 'constructor' or '__proto__' on a plain object answers with a prototype member. */
21
+ export declare const PUBLISHED_TEST_KEYS: ReadonlyMap<string, string>;
22
+ /** The published-secret label for `address`, or undefined when it is not a known test key. */
23
+ export declare function publishedKeyLabel(address: unknown): string | undefined;
@@ -0,0 +1,59 @@
1
+ /** Signer addresses whose private keys are PUBLISHED — anyone can sign as them.
2
+ *
3
+ * A signature from one of these keys recovers and verifies like any other, so a verifier that
4
+ * stays silent about it hands a stranger a receipt that looks authored when anyone could have
5
+ * minted it. They are what worked examples and test suites sign with (ours included: the
6
+ * synthetic evidence bundle uses dev-mnemonic accounts #0 and #1), which is exactly why a
7
+ * real-looking artifact carrying one must be flagged rather than passed quietly.
8
+ *
9
+ * Twin of `sdk-py/tersign/known_keys.py`. Neither copy is transcribed from the other: each is
10
+ * DERIVED from the published secrets and pinned by its own suite — test/known-keys.test.ts
11
+ * re-derives every address here with viem, the Python suite re-derives its table with its own
12
+ * secp256k1 — and the same test compares the two tables entry by entry (address AND label)
13
+ * wherever the monorepo carries both. A key added to one side without the other fails there.
14
+ * Deliberately narrow: keys whose secret is published as a convention, not every key that has
15
+ * ever leaked. */
16
+ /** BIP-39 mnemonic shipped as the default dev account set by Hardhat (20 accounts) and Anvil
17
+ * (10), derived at m/44'/60'/0'/0/i. */
18
+ export const DEV_MNEMONIC = 'test test test test test test test test test test test junk';
19
+ const DEV_MNEMONIC_ADDRESSES = [
20
+ '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266',
21
+ '0x70997970c51812dc3a010c7d01b50e0d17dc79c8',
22
+ '0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc',
23
+ '0x90f79bf6eb2c4f870365e785982e1f101e93b906',
24
+ '0x15d34aaf54267db7d7c367839aaf71a00a2c6a65',
25
+ '0x9965507d1a55bcc2695c58ba16fb37d819b0a4dc',
26
+ '0x976ea74026e726554db657fa54763abd0c3a0aa9',
27
+ '0x14dc79964da2c08b23698b3d3cc7ca32193d9955',
28
+ '0x23618e81e3f5cdf7f54c3d65f7fbc0abf5b21e8f',
29
+ '0xa0ee7a142d267c1f36714e4a8f75612f20a79720',
30
+ '0xbcd4042de499d14e55001ccbb24a551f3b954096',
31
+ '0x71be63f3384f5fb98995898a86b02fb2426c5788',
32
+ '0xfabb0ac9d68b0b445fb7357272ff202c5651694a',
33
+ '0x1cbd3b2770909d4e10f157cabc84c7264073c9ec',
34
+ '0xdf3e18d64bc6a983f673ab319ccae4f1a57c7097',
35
+ '0xcd3b766ccdd6ae721141f452c550ca635964ce71',
36
+ '0x2546bcd3c84621e976d8185a91a922ae77ecec30',
37
+ '0xbda5747bfd65f08deb54cb465eb87d40e51b197e',
38
+ '0xdd2fd4581271e230360230f9337d5c0430bf44c0',
39
+ '0x8626f6940e2eb28930efb4cef49b2d1f2c9c1199',
40
+ ];
41
+ /** Private keys 1, 2 and 3 — the other convention for "obviously a test key". Only these
42
+ * three: a receipt signed with private key 4 is NOT flagged. */
43
+ const SMALL_SCALAR_ADDRESSES = [
44
+ '0x7e5f4552091a69125d5dfcb7b8c2659029395bdf',
45
+ '0x2b5ad5c4795c026514f8317c7a215e218dccd6cf',
46
+ '0x6813eb9362372eef6200f3b1dbc3f819671cba69',
47
+ ];
48
+ /** lowercase address → the published secret it comes from. A Map, not an object literal: a
49
+ * lookup of 'constructor' or '__proto__' on a plain object answers with a prototype member. */
50
+ export const PUBLISHED_TEST_KEYS = new Map([
51
+ ...DEV_MNEMONIC_ADDRESSES.map((a, i) => [a, `Hardhat/Anvil default dev mnemonic, account #${i}`]),
52
+ ...SMALL_SCALAR_ADDRESSES.map((a, i) => [a, `private key 0x${(i + 1).toString(16)} (a small scalar)`]),
53
+ ]);
54
+ /** The published-secret label for `address`, or undefined when it is not a known test key. */
55
+ export function publishedKeyLabel(address) {
56
+ if (typeof address !== 'string')
57
+ return undefined;
58
+ return PUBLISHED_TEST_KEYS.get(address.toLowerCase());
59
+ }
@@ -0,0 +1,31 @@
1
+ /** x402 v1 network names → CAIP-2, for the receipt payload.
2
+ *
3
+ * The offer-receipt extension requires CAIP-2 in the signed payload whatever the protocol version:
4
+ * "Servers MUST convert v1 network identifiers (e.g., "base-sepolia") to CAIP-2 format (e.g.,
5
+ * "eip155:84532") in the receipt payload" (x402-foundation/x402 specs/extensions/
6
+ * extension-offer-and-receipt.md, receipt section; present at main 6b6ee91fee02, read 2026-09-29).
7
+ * A v1 settlement response carries the v1 name (`"base"`), so a receipt built from it without this
8
+ * step signs a payload that fails the spec.
9
+ *
10
+ * The EVM table is upstream `@x402/evm`'s `EVM_NETWORK_CHAIN_ID_MAP`, copied as written
11
+ * (typescript/packages/mechanisms/evm/src/constants.ts, blob ab7db42cc1c0: main 6b6ee91fee02, last
12
+ * changed a9955ae5538e, read 2026-09-29). Those 24 names are the ones upstream's v1 EVM facilitator
13
+ * settles and reports back as `network`, so each can reach a receipt. The offer-receipt reference's
14
+ * own table (typescript/packages/extensions/src/offer-receipt/signing.ts `V1_EVM_NETWORK_CHAIN_IDS`,
15
+ * same commit) holds 17 of them, with the same ids. The Solana table is signing.ts
16
+ * `V1_SOLANA_NETWORKS`, identical to `@x402/svm`'s `V1_TO_V2_NETWORK_MAP`
17
+ * (mechanisms/svm/src/constants.ts, same commit). The rules are signing.ts
18
+ * `convertNetworkStringToCAIP2`'s: a string containing `:` passes through unchanged, a known v1 name
19
+ * is looked up case-insensitively, anything else throws. One difference: the lookup reads own keys
20
+ * only, so an inherited object key such as `constructor` throws instead of resolving. Re-check
21
+ * against both upstream files when a v1 network is added there; test/network.test.ts pins both
22
+ * tables by exact equality.
23
+ *
24
+ * The tables are exported for that test only; the package entry re-exports `toCaip2Network`, not
25
+ * them. */
26
+ export declare const V1_EVM_NETWORK_CHAIN_IDS: Readonly<Record<string, number>>;
27
+ export declare const V1_SOLANA_NETWORKS: Readonly<Record<string, string>>;
28
+ /** A CAIP-2 identifier passes through; a known x402 v1 name (`"base"`, `"base-sepolia"`,
29
+ * `"solana"`, …) becomes its CAIP-2 form; anything else throws, because a receipt cannot carry
30
+ * it. */
31
+ export declare function toCaip2Network(network: string): string;