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.
package/dist/assure.js CHANGED
@@ -1,10 +1,13 @@
1
1
  import { signReceipt } from './receipt/eip712.js';
2
+ import { toCaip2Network } from './receipt/network.js';
2
3
  import { buildMinimalRecord, signComplianceRecord } from './compliance/record.js';
3
4
  import { LedgerClient } from './ledgerClient.js';
4
- /** The core primitive: after a settled x402 payment, issue the signed base receipt
5
- * (merged offer-receipt extension, EIP-712) plus the Tersign compliance record bound to it,
6
- * and counter-sign into the hosted ledger when configured. Attach the result to the
7
- * SettlementResponse via `attachToExtensions`. */
5
+ /** The core primitive: after a settled x402 payment, issue the seller-signed receipt
6
+ * (offer-receipt extension, EIP-712) and the Tersign compliance record. With a ledger configured,
7
+ * the ledger counter-signs the receipt into the seller's hash chain; the compliance-fields record is not
8
+ * counter-signed, and is bound to its receipt only by the seller's own signature, which covers the
9
+ * receipt's digest. Merge the result into the x402 SettlementResponse (the `PAYMENT-RESPONSE`
10
+ * header) with `attachToSettlementResponse`; `withAssure` does this for you. */
8
11
  export class Assure {
9
12
  cfg;
10
13
  ledger;
@@ -16,7 +19,7 @@ export class Assure {
16
19
  async issueFor(ctx) {
17
20
  const payload = {
18
21
  version: 1,
19
- network: ctx.network,
22
+ network: toCaip2Network(ctx.network),
20
23
  resourceUrl: ctx.resourceUrl,
21
24
  payer: ctx.payer,
22
25
  issuedAt: ctx.settledAt,
@@ -45,8 +48,71 @@ export class Assure {
45
48
  return { receipt, compliance, ledger };
46
49
  }
47
50
  }
48
- /** Decorate an x402 SettlementResponse body with the receipt at the spec-defined placement
49
- * (`extensions["offer-receipt"].info.receipt`) and the Tersign record alongside it. */
51
+ /** JSON Schema for `extensions["offer-receipt"]` on a SettlementResponse — the same shape the
52
+ * upstream reference server attaches (x402-foundation/x402 typescript/packages/extensions/src/
53
+ * offer-receipt/server.ts RECEIPT_SCHEMA, main 5eee1e3c35, read 2026-09-29). */
54
+ export const OFFER_RECEIPT_RESPONSE_SCHEMA = {
55
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
56
+ type: 'object',
57
+ properties: {
58
+ receipt: {
59
+ type: 'object',
60
+ properties: {
61
+ format: { type: 'string' },
62
+ payload: {
63
+ type: 'object',
64
+ properties: {
65
+ version: { type: 'integer' },
66
+ network: { type: 'string' },
67
+ resourceUrl: { type: 'string' },
68
+ payer: { type: 'string' },
69
+ issuedAt: { type: 'integer' },
70
+ transaction: { type: 'string' },
71
+ },
72
+ required: ['version', 'network', 'resourceUrl', 'payer', 'issuedAt'],
73
+ },
74
+ signature: { type: 'string' },
75
+ },
76
+ required: ['format', 'signature'],
77
+ },
78
+ },
79
+ required: ['receipt'],
80
+ };
81
+ function complianceInfo(issued) {
82
+ return {
83
+ record: issued.compliance.record,
84
+ attestation: issued.compliance.attestation,
85
+ ...(issued.ledger
86
+ ? { ledger: { seq: issued.ledger.seq, digest: issued.ledger.digest, countersignature: issued.ledger.countersignature } }
87
+ : {}),
88
+ };
89
+ }
90
+ /** Merge the receipt and the compliance record into an x402 `SettlementResponse` — the object
91
+ * the HTTP transport carries, base64-encoded, in the `PAYMENT-RESPONSE` header. The receipt goes
92
+ * to `extensions["offer-receipt"].info.receipt` (offer-receipt spec §5.1, same for v1 and v2)
93
+ * with its schema; the record and its attestation to `extensions["compliance-fields"].info`
94
+ * (placement proposed in x402-foundation/x402#2853, open). Every other member of the settlement
95
+ * response, and every other extension, is kept; an `offer-receipt` entry already present is
96
+ * replaced, because the record binds to THIS receipt's digest. */
97
+ export function attachToSettlementResponse(settlement, issued) {
98
+ const prior = isPlainRecord(settlement.extensions) ? settlement.extensions : {};
99
+ return {
100
+ ...settlement,
101
+ extensions: {
102
+ ...prior,
103
+ 'offer-receipt': { info: { receipt: issued.receipt }, schema: OFFER_RECEIPT_RESPONSE_SCHEMA },
104
+ 'compliance-fields': { info: complianceInfo(issued) },
105
+ },
106
+ };
107
+ }
108
+ function isPlainRecord(v) {
109
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
110
+ }
111
+ /** LEGACY: decorate the resource's JSON response BODY with the same extension entries. The x402
112
+ * v2 HTTP transport carries protocol data in headers only ("Response bodies are a server
113
+ * implementation concern"), so a wallet reading `PAYMENT-RESPONSE` never sees a body placement.
114
+ * Kept for readers built against tersign ≤0.5; `withAssure` uses it only with
115
+ * `legacyBodyPlacement: true`. */
50
116
  export function attachToExtensions(responseBody, issued) {
51
117
  const prior = (responseBody.extensions ?? {});
52
118
  return {
@@ -54,15 +120,7 @@ export function attachToExtensions(responseBody, issued) {
54
120
  extensions: {
55
121
  ...prior,
56
122
  'offer-receipt': { info: { receipt: issued.receipt } },
57
- 'compliance-fields': {
58
- info: {
59
- record: issued.compliance.record,
60
- attestation: issued.compliance.attestation,
61
- ...(issued.ledger
62
- ? { ledger: { seq: issued.ledger.seq, digest: issued.ledger.digest, countersignature: issued.ledger.countersignature } }
63
- : {}),
64
- },
65
- },
123
+ 'compliance-fields': { info: complianceInfo(issued) },
66
124
  },
67
125
  };
68
126
  }
@@ -8,6 +8,33 @@ import { type Hex } from 'viem';
8
8
  * sort-then-stringify round-trip (JCS orders them "1","10","2"). Byte-identical to the old
9
9
  * serializer for every shape without integer-like or control-char keys — the genesis receipt
10
10
  * digest and all pinned wire vectors are unchanged. */
11
+ /** Nesting budget for untrusted inputs, byte-for-byte the ledger's MAX_CANONICAL_DEPTH and
12
+ * canonical.py's MAX_DEPTH. The SDK copy shipped without one: a 65-deep value digested here
13
+ * and was refused by every Python verifier, so the SDK could sign a record no independent
14
+ * checker could ever verify. */
15
+ export declare const MAX_CANONICAL_DEPTH = 64;
16
+ export declare class CanonicalDepthError extends Error {
17
+ constructor();
18
+ }
19
+ /** The digest domain is ints/strings/bools/null/objects/arrays — canonical.py has said so
20
+ * since it was written; this side simply never enforced it, and `JSON.stringify` is not a
21
+ * specification. Two consequences, both confirmed against the running code:
22
+ *
23
+ * digestOf({amount: 9007199254740993}) === digestOf({amount: 9007199254740992})
24
+ * digestOf({n: 1e400}) === digestOf({n: null})
25
+ *
26
+ * Distinct records, identical bytes, identical digest, identical signature. For a system whose
27
+ * whole claim is "this digest commits to this record", a non-injective canonical form is the
28
+ * deepest defect available, and it is reachable: 2^53-1 is about 9.007e15, below any
29
+ * 18-decimal token amount over 0.009 ETH and below any nanosecond timestamp.
30
+ *
31
+ * Floats are rejected for the reason canonical.py already states — the domain boundary is the
32
+ * number TOKEN, and a float that survives a parse round-trip is precisely the divergence class
33
+ * the conformance suite exists to kill. Number.isSafeInteger settles all four cases at once:
34
+ * non-integer, |n| >= 2^53, NaN and Infinity. */
35
+ export declare class CanonicalDomainError extends Error {
36
+ constructor(value: number);
37
+ }
11
38
  export declare function canonicalStringify(value: unknown): string;
12
39
  export declare function digestOf(value: unknown): `0x${string}`;
13
40
  /** Hash-chain link, byte-identical to the hosted ledger's recompute: the ledger
package/dist/canonical.js CHANGED
@@ -8,26 +8,67 @@ import { concatHex, keccak256, numberToHex, stringToHex, toBytes } from 'viem';
8
8
  * sort-then-stringify round-trip (JCS orders them "1","10","2"). Byte-identical to the old
9
9
  * serializer for every shape without integer-like or control-char keys — the genesis receipt
10
10
  * digest and all pinned wire vectors are unchanged. */
11
+ /** Nesting budget for untrusted inputs, byte-for-byte the ledger's MAX_CANONICAL_DEPTH and
12
+ * canonical.py's MAX_DEPTH. The SDK copy shipped without one: a 65-deep value digested here
13
+ * and was refused by every Python verifier, so the SDK could sign a record no independent
14
+ * checker could ever verify. */
15
+ export const MAX_CANONICAL_DEPTH = 64;
16
+ export class CanonicalDepthError extends Error {
17
+ constructor() {
18
+ super(`value nests deeper than ${MAX_CANONICAL_DEPTH} levels`);
19
+ }
20
+ }
21
+ /** The digest domain is ints/strings/bools/null/objects/arrays — canonical.py has said so
22
+ * since it was written; this side simply never enforced it, and `JSON.stringify` is not a
23
+ * specification. Two consequences, both confirmed against the running code:
24
+ *
25
+ * digestOf({amount: 9007199254740993}) === digestOf({amount: 9007199254740992})
26
+ * digestOf({n: 1e400}) === digestOf({n: null})
27
+ *
28
+ * Distinct records, identical bytes, identical digest, identical signature. For a system whose
29
+ * whole claim is "this digest commits to this record", a non-injective canonical form is the
30
+ * deepest defect available, and it is reachable: 2^53-1 is about 9.007e15, below any
31
+ * 18-decimal token amount over 0.009 ETH and below any nanosecond timestamp.
32
+ *
33
+ * Floats are rejected for the reason canonical.py already states — the domain boundary is the
34
+ * number TOKEN, and a float that survives a parse round-trip is precisely the divergence class
35
+ * the conformance suite exists to kill. Number.isSafeInteger settles all four cases at once:
36
+ * non-integer, |n| >= 2^53, NaN and Infinity. */
37
+ export class CanonicalDomainError extends Error {
38
+ constructor(value) {
39
+ super(`${Number.isFinite(value) ? value : String(value)} is outside the Tersign digest domain ` +
40
+ '(integers within +/-(2^53-1) only: no floats, NaN or Infinity)');
41
+ }
42
+ }
11
43
  export function canonicalStringify(value) {
12
- return serialize(value);
44
+ return serialize(value, 0);
13
45
  }
14
- function serialize(value) {
46
+ function serialize(value, depth) {
47
+ if (depth > MAX_CANONICAL_DEPTH)
48
+ throw new CanonicalDepthError();
15
49
  if (value === null)
16
50
  return 'null';
17
51
  if (Array.isArray(value)) {
18
- return '[' + Array.from(value, (v) => serialize(v) ?? 'null').join(',') + ']';
52
+ return '[' + Array.from(value, (v) => serialize(v, depth + 1) ?? 'null').join(',') + ']';
19
53
  }
20
54
  if (typeof value === 'object') {
21
55
  const obj = value;
22
56
  const parts = [];
23
57
  for (const key of Object.keys(obj).sort()) {
24
- const s = serialize(obj[key]);
58
+ const s = serialize(obj[key], depth + 1);
25
59
  if (s !== undefined)
26
60
  parts.push(JSON.stringify(key) + ':' + s);
27
61
  }
28
62
  return '{' + parts.join(',') + '}';
29
63
  }
30
- // string/number/boolean serialize per JCS; undefined/function/symbol yield undefined (dropped)
64
+ // Numbers carry the domain guard: JSON.stringify would silently round 2^53+1 down and fold
65
+ // Infinity to null, and both of those are collisions, not encodings.
66
+ if (typeof value === 'number') {
67
+ if (!Number.isSafeInteger(value))
68
+ throw new CanonicalDomainError(value);
69
+ return String(value);
70
+ }
71
+ // string/boolean serialize per JCS; undefined/function/symbol yield undefined (dropped)
31
72
  return JSON.stringify(value);
32
73
  }
33
74
  export function digestOf(value) {
package/dist/cli.js CHANGED
@@ -16,8 +16,10 @@ if (sub === undefined || sub === 'mcp') {
16
16
  console.error('tersign: this starts the MCP server, which speaks JSON-RPC over stdin — nothing to see\n' +
17
17
  'at a prompt. Wire it into your MCP client config:\n\n' +
18
18
  ' { "mcpServers": { "tersign": { "command": "npx", "args": ["tersign"] } } }\n\n' +
19
- 'No key needed: the first call self-provisions a signer-keyed account (OS keychain,\n' +
20
- 'else ~/.tersign/signer.key). Set TERSIGN_SELLER_KEY to use your own.\n\n' +
19
+ 'No key needed: a key is generated and kept in the OS keychain (else ~/.tersign/signer.key),\n' +
20
+ "and record_disclosure's first call self-provisions a signer-keyed account on the ledger,\n" +
21
+ 'unless that key is registered to an API-key account (409) or the daily provisioning caps\n' +
22
+ 'are reached (429). Set TERSIGN_SELLER_KEY to use your own key.\n\n' +
21
23
  "Just exploring? Try: tersign help · tersign verify <receipt.json | 0xdigest> [--ledger url]");
22
24
  process.exit(1);
23
25
  }
@@ -40,7 +42,9 @@ else if (sub === 'help' || sub === '--help' || sub === '-h') {
40
42
  ' tersign start the MCP server (stdio)\n' +
41
43
  ' tersign mcp same, explicit\n' +
42
44
  ' tersign verify <receipt.json | 0xdigest> [--signer 0xaddr] [--ledger url]\n' +
43
- ' verify a receipt: local signature recovery + public chain check\n' +
45
+ ' verify a receipt FILE locally: --signer binds it to the\n' +
46
+ " issuer's address (without it the signer is reported\n" +
47
+ ' UNAUTHENTICATED); or ask a ledger about a DIGEST\n' +
44
48
  ' tersign disclose "<text>" [--medium chat] [--agent-id id] [--url resourceUrl]\n' +
45
49
  ' counter-signed disclosure evidence — text digested locally,\n' +
46
50
  ' only the digest travels; key created on first use\n' +
@@ -1,7 +1,8 @@
1
1
  import type { Account } from 'viem/accounts';
2
2
  import type { Adjustment, ComplianceRecordV1, SignedComplianceRecord, SignedReceipt, VerifyLike } from './types.js';
3
- /** Canonical domain per the compliance-fields extension spec (x402-foundation/x402#2853).
4
- * Migrated from the vendor domain 'tersign compliance-record' on 2026-07-14 while ZERO
3
+ import { type SignerBinding } from '../receipt/binding.js';
4
+ /** EIP-712 domain named in the proposed compliance-fields extension (x402-foundation/x402#2853,
5
+ * open — proposed, not settled spec). Migrated from the vendor domain 'tersign compliance-record' on 2026-07-14 while ZERO
5
6
  * production compliance records existed — a free change then, a breaking wire change after. */
6
7
  export declare const COMPLIANCE_DOMAIN: {
7
8
  readonly name: "compliance-fields";
@@ -31,7 +32,8 @@ export interface IssuerConfig {
31
32
  name: string;
32
33
  jurisdiction: string;
33
34
  taxId?: string;
34
- /** default 7 — HK IRO s.51C floor, ≥ MiCA 5+2 */
35
+ /** default 7 where no longer period applies (HK IRO s.51C, MiCA 5+2) — set the longest period
36
+ * that applies to you (e.g. DE § 14b UStG: eight years); 7 is a default, not a sufficiency claim */
35
37
  retentionYears?: number;
36
38
  }
37
39
  export interface MinimalRecordInput {
@@ -44,9 +46,19 @@ export interface MinimalRecordInput {
44
46
  refundOf?: `0x${string}`;
45
47
  adjustment?: Adjustment;
46
48
  }
47
- /** MINIMAL tier ≈ EU VAT Art 226b simplified-invoice content — legally sufficient for
48
- * sub-€100 supplies EU-wide, and the default for machine-to-machine micro-receipts. */
49
+ /** Build a record carrying the MINIMAL members of the proposed compliance-fields extension —
50
+ * content isomorphic to the EU VAT Art 226b simplified invoice. Whether it suffices as an invoice
51
+ * is decided by the invoicing rules that apply to the supply (Art 219a): Art 220a(1)(a) makes
52
+ * every Member State allow simplified invoices up to EUR 100, a Member State may require further
53
+ * details on them (Art 226b), and some supplies can never use one (Art 220(1)(2)-(3), Art 220a(2),
54
+ * and national bars). MINIMAL needs `tax.amount` whenever `tax.scheme` is not `none`; that is the caller's
55
+ * input. `seq` is not set here: it is the issuer's to assign before attestation. */
49
56
  export declare function buildMinimalRecord(issuer: IssuerConfig, input: MinimalRecordInput): ComplianceRecordV1;
50
57
  export declare function recordDigest(record: ComplianceRecordV1): `0x${string}`;
51
58
  export declare function signComplianceRecord(record: ComplianceRecordV1, account: Account): Promise<SignedComplianceRecord>;
52
- export declare function verifyComplianceRecord(signed: SignedComplianceRecord, expectedSigner?: string): Promise<VerifyLike>;
59
+ /** VerifyLike plus the signer binding: signerBound is true only when expectedSigner was supplied
60
+ * and matched; testKey flags a published test key. Without a bound signer a PASS proves the
61
+ * record and attestation are internally consistent and nothing else — anyone can edit a record,
62
+ * recompute its digest and re-sign the attestation with their own key. */
63
+ export type RecordVerifyResult = VerifyLike & SignerBinding;
64
+ export declare function verifyComplianceRecord(signed: SignedComplianceRecord, expectedSigner?: string): Promise<RecordVerifyResult>;
@@ -1,7 +1,8 @@
1
1
  import { recoverTypedDataAddress } from 'viem';
2
2
  import { digestOf } from '../canonical.js';
3
- /** Canonical domain per the compliance-fields extension spec (x402-foundation/x402#2853).
4
- * Migrated from the vendor domain 'tersign compliance-record' on 2026-07-14 while ZERO
3
+ import { bindSigner, isPlainObject, parseExpectedSigner, signatureError } from '../receipt/binding.js';
4
+ /** EIP-712 domain named in the proposed compliance-fields extension (x402-foundation/x402#2853,
5
+ * open — proposed, not settled spec). Migrated from the vendor domain 'tersign compliance-record' on 2026-07-14 while ZERO
5
6
  * production compliance records existed — a free change then, a breaking wire change after. */
6
7
  export const COMPLIANCE_DOMAIN = { name: 'compliance-fields', version: '1', chainId: 1n };
7
8
  export const COMPLIANCE_TYPES = {
@@ -19,11 +20,17 @@ export const COMPLIANCE_WIRE_VECTOR = digestOf({
19
20
  domain: { ...COMPLIANCE_DOMAIN, chainId: 1 },
20
21
  types: COMPLIANCE_TYPES,
21
22
  });
22
- /** MINIMAL tier ≈ EU VAT Art 226b simplified-invoice content — legally sufficient for
23
- * sub-€100 supplies EU-wide, and the default for machine-to-machine micro-receipts. */
23
+ /** Build a record carrying the MINIMAL members of the proposed compliance-fields extension —
24
+ * content isomorphic to the EU VAT Art 226b simplified invoice. Whether it suffices as an invoice
25
+ * is decided by the invoicing rules that apply to the supply (Art 219a): Art 220a(1)(a) makes
26
+ * every Member State allow simplified invoices up to EUR 100, a Member State may require further
27
+ * details on them (Art 226b), and some supplies can never use one (Art 220(1)(2)-(3), Art 220a(2),
28
+ * and national bars). MINIMAL needs `tax.amount` whenever `tax.scheme` is not `none`; that is the caller's
29
+ * input. `seq` is not set here: it is the issuer's to assign before attestation. */
24
30
  export function buildMinimalRecord(issuer, input) {
25
31
  const record = {
26
32
  version: 1,
33
+ canonicalizationVersion: 1,
27
34
  receiptDigest: digestOf(input.receipt),
28
35
  issuedAt: input.issuedAt,
29
36
  issuer: {
@@ -71,17 +78,37 @@ export async function signComplianceRecord(record, account) {
71
78
  return { record, attestation: { format: 'eip712', payload, signature } };
72
79
  }
73
80
  export async function verifyComplianceRecord(signed, expectedSigner) {
81
+ const expected = parseExpectedSigner(expectedSigner);
82
+ if (!expected.ok)
83
+ return { valid: false, signerBound: false, reason: expected.reason };
84
+ if (!isPlainObject(signed) ||
85
+ !isPlainObject(signed.record) ||
86
+ !isPlainObject(signed.attestation) ||
87
+ !isPlainObject(signed.attestation.payload)) {
88
+ return { valid: false, signerBound: false, reason: 'not a signed action record: expected {record, attestation{format, payload, signature}}' };
89
+ }
74
90
  const { attestation, record } = signed;
75
91
  if (attestation.format !== 'eip712')
76
- return { valid: false, reason: 'jws not implemented in v0' };
77
- if (attestation.payload.recordDigest !== recordDigest(record)) {
78
- return { valid: false, reason: 'record digest mismatch — record was altered after signing' };
92
+ return { valid: false, signerBound: false, reason: 'jws not implemented in v0' };
93
+ let digest;
94
+ try {
95
+ digest = recordDigest(record);
96
+ }
97
+ catch (e) {
98
+ return { valid: false, signerBound: false, reason: `cannot compute the record digest: ${e instanceof Error ? e.message : String(e)}` };
99
+ }
100
+ if (attestation.payload.recordDigest !== digest) {
101
+ return { valid: false, signerBound: false, reason: 'record digest mismatch — record was altered after signing' };
79
102
  }
80
103
  if (attestation.payload.receiptDigest !== record.receiptDigest) {
81
- return { valid: false, reason: 'attestation/receipt digest mismatch' };
104
+ return { valid: false, signerBound: false, reason: 'attestation/receipt digest mismatch' };
82
105
  }
106
+ const badSig = signatureError(attestation.signature);
107
+ if (badSig)
108
+ return { valid: false, signerBound: false, reason: `attestation ${badSig}` };
109
+ let signer;
83
110
  try {
84
- const signer = await recoverTypedDataAddress({
111
+ signer = await recoverTypedDataAddress({
85
112
  domain: COMPLIANCE_DOMAIN,
86
113
  types: COMPLIANCE_TYPES,
87
114
  primaryType: 'ComplianceAttestation',
@@ -93,12 +120,9 @@ export async function verifyComplianceRecord(signed, expectedSigner) {
93
120
  },
94
121
  signature: attestation.signature,
95
122
  });
96
- if (expectedSigner && signer.toLowerCase() !== expectedSigner.toLowerCase()) {
97
- return { valid: false, signer, reason: 'unexpected signer' };
98
- }
99
- return { valid: true, signer };
100
123
  }
101
124
  catch (e) {
102
- return { valid: false, reason: e instanceof Error ? e.message : 'signature recovery failed' };
125
+ return { valid: false, signerBound: false, reason: e instanceof Error ? e.message : 'signature recovery failed' };
103
126
  }
127
+ return bindSigner(signer, expected.value, 'unexpected signer');
104
128
  }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  export * from './types.js';
2
2
  export { canonicalStringify, digestOf, GENESIS_DIGEST, chainLinkDigest, CHAIN_COMMITMENT_SCHEMA, ACC_GENESIS, chainAccumulatorStep, chainCommitment, commitmentDigest, foldAccumulator, verifyCommitment, ChainIntegrityError, type ChainCommitment, type ChainRecordLike, type CommitmentVerifyResult, } from './canonical.js';
3
- export { RECEIPT_DOMAIN, RECEIPT_TYPES, OFFER_DOMAIN, OFFER_TYPES, signReceipt, signOffer, verifyReceipt, type VerifyResult, } from './receipt/eip712.js';
4
- export { COMPLIANCE_DOMAIN, COMPLIANCE_TYPES, COMPLIANCE_WIRE_VECTOR, buildMinimalRecord, recordDigest, signComplianceRecord, verifyComplianceRecord, type IssuerConfig, type MinimalRecordInput, } from './compliance/record.js';
3
+ export { RECEIPT_DOMAIN, RECEIPT_TYPES, OFFER_DOMAIN, OFFER_TYPES, signReceipt, signOffer, verifyReceipt, SIGNED_RECEIPT_FIELDS, type VerifyResult, } from './receipt/eip712.js';
4
+ export { PUBLISHED_TEST_KEYS, publishedKeyLabel } from './receipt/known-keys.js';
5
+ export { signerStatus, type SignerBinding, type SignerStatus } from './receipt/binding.js';
6
+ export { toCaip2Network } from './receipt/network.js';
7
+ export { COMPLIANCE_DOMAIN, COMPLIANCE_TYPES, COMPLIANCE_WIRE_VECTOR, buildMinimalRecord, recordDigest, signComplianceRecord, verifyComplianceRecord, type RecordVerifyResult, type IssuerConfig, type MinimalRecordInput, } from './compliance/record.js';
5
8
  export { MemoryIdempotencyStore, checkIdempotency, extractPaymentId, fingerprint, REPLAY_HEADER, type IdempotencyStore, type IdempotencyOutcome, type IdempotencyOptions, type CachedResponse, type FingerprintParts, } from './idempotency/middleware.js';
6
9
  export { D1IdempotencyStore, D1_IDEMPOTENCY_DDL, type D1Like } from './idempotency/d1.js';
7
10
  export type { DisputeReason, DisputeVerdict, DisputeStatus, DisputePayloadV1, CriterionV1, AcceptanceCriteriaV1, EvidenceArtifactRef, EvidencePayloadV1, DisputeAttestationPayload, EvidenceAttestationPayload, CriteriaAttestationPayload, SignedDispute, SignedEvidence, SignedCriteria, } from './dispute/types.js';
@@ -9,7 +12,7 @@ export { DISPUTE_DOMAIN, EVIDENCE_DOMAIN, CRITERIA_DOMAIN, DISPUTE_TYPES, EVIDEN
9
12
  export { ACTION_DOMAIN, ACTION_TYPES, ACTION_WIRE_VECTOR, actionDigest, signActionRecord, verifyActionRecord, type ActionKind, type DisclosureKind, type GovernanceOutcome, type ActionRecordV1, type ActionAttestationPayload, type SignedActionRecord, } from './evidence/action.js';
10
13
  export { recordDisclosure, type RecordDisclosureOptions, type RecordDisclosureResult } from './evidence/disclose.js';
11
14
  export { LedgerClient, type LedgerConfig, type CountersignResult } from './ledgerClient.js';
12
- export { Assure, attachToExtensions, type AssureConfig, type SettlementContext, type IssuedReceipt } from './assure.js';
13
- export { withAssure, extractSettlement, extractPaymentPayload, type WithAssureConfig, type SettlementInfo } from './adapter/x402.js';
15
+ export { Assure, attachToExtensions, attachToSettlementResponse, OFFER_RECEIPT_RESPONSE_SCHEMA, type AssureConfig, type SettlementContext, type IssuedReceipt, } from './assure.js';
16
+ export { withAssure, extractSettlement, extractPaymentPayload, encodeX402Header, decodeX402Header, COMPLIANCE_FIELDS_ADVERTISEMENT_SCHEMA, type WithAssureConfig, type SettlementInfo, type ComplianceAdvertisement, } from './adapter/x402.js';
14
17
  export { ENVELOPE_STATEMENT_MAX_CHARS, VENUE_SUBMISSION_MAX_CHARS, ENVELOPE_VENUES, type EvidenceEnvelopeV1, type EnvelopeSubjectKind, type EnvelopeVenue, } from './envelope/types.js';
15
18
  export { assertEnvelopeCaps, toInternetCourtSubmission, toKlerosEvidence, toUMAClaim, type Kleros1497Evidence, } from './envelope/serialize.js';
package/dist/index.js CHANGED
@@ -1,6 +1,9 @@
1
1
  export * from './types.js';
2
2
  export { canonicalStringify, digestOf, GENESIS_DIGEST, chainLinkDigest, CHAIN_COMMITMENT_SCHEMA, ACC_GENESIS, chainAccumulatorStep, chainCommitment, commitmentDigest, foldAccumulator, verifyCommitment, ChainIntegrityError, } from './canonical.js';
3
- export { RECEIPT_DOMAIN, RECEIPT_TYPES, OFFER_DOMAIN, OFFER_TYPES, signReceipt, signOffer, verifyReceipt, } from './receipt/eip712.js';
3
+ export { RECEIPT_DOMAIN, RECEIPT_TYPES, OFFER_DOMAIN, OFFER_TYPES, signReceipt, signOffer, verifyReceipt, SIGNED_RECEIPT_FIELDS, } from './receipt/eip712.js';
4
+ export { PUBLISHED_TEST_KEYS, publishedKeyLabel } from './receipt/known-keys.js';
5
+ export { signerStatus } from './receipt/binding.js';
6
+ export { toCaip2Network } from './receipt/network.js';
4
7
  export { COMPLIANCE_DOMAIN, COMPLIANCE_TYPES, COMPLIANCE_WIRE_VECTOR, buildMinimalRecord, recordDigest, signComplianceRecord, verifyComplianceRecord, } from './compliance/record.js';
5
8
  export { MemoryIdempotencyStore, checkIdempotency, extractPaymentId, fingerprint, REPLAY_HEADER, } from './idempotency/middleware.js';
6
9
  export { D1IdempotencyStore, D1_IDEMPOTENCY_DDL } from './idempotency/d1.js';
@@ -8,7 +11,7 @@ export { DISPUTE_DOMAIN, EVIDENCE_DOMAIN, CRITERIA_DOMAIN, DISPUTE_TYPES, EVIDEN
8
11
  export { ACTION_DOMAIN, ACTION_TYPES, ACTION_WIRE_VECTOR, actionDigest, signActionRecord, verifyActionRecord, } from './evidence/action.js';
9
12
  export { recordDisclosure } from './evidence/disclose.js';
10
13
  export { LedgerClient } from './ledgerClient.js';
11
- export { Assure, attachToExtensions } from './assure.js';
12
- export { withAssure, extractSettlement, extractPaymentPayload } from './adapter/x402.js';
14
+ export { Assure, attachToExtensions, attachToSettlementResponse, OFFER_RECEIPT_RESPONSE_SCHEMA, } from './assure.js';
15
+ export { withAssure, extractSettlement, extractPaymentPayload, encodeX402Header, decodeX402Header, COMPLIANCE_FIELDS_ADVERTISEMENT_SCHEMA, } from './adapter/x402.js';
13
16
  export { ENVELOPE_STATEMENT_MAX_CHARS, VENUE_SUBMISSION_MAX_CHARS, ENVELOPE_VENUES, } from './envelope/types.js';
14
17
  export { assertEnvelopeCaps, toInternetCourtSubmission, toKlerosEvidence, toUMAClaim, } from './envelope/serialize.js';
@@ -22,8 +22,11 @@ export declare class LedgerClient {
22
22
  constructor(cfg: LedgerConfig);
23
23
  private get f();
24
24
  submitReceipt(artifact: SignedReceipt, compliance?: SignedComplianceRecord): Promise<CountersignResult>;
25
+ /** Log a refund against a receipt on this seller's chain. The ledger stores a PENDING refund
26
+ * entry; it is not counter-signed or chained, so the answer carries no digest or seq. */
25
27
  recordRefund(originalDigest: `0x${string}`, amount: string, reason: string): Promise<{
26
28
  id: string;
29
+ status: string;
27
30
  }>;
28
31
  /** Fetch the venue-neutral evidence envelope for a chained artifact (public endpoint — works
29
32
  * without an API key; the apiKey/sellerId in cfg are unused here). `statement` is an optional
@@ -23,6 +23,8 @@ export class LedgerClient {
23
23
  throw new Error(`ledger submit failed: ${res.status} ${await res.text()}`);
24
24
  return (await res.json());
25
25
  }
26
+ /** Log a refund against a receipt on this seller's chain. The ledger stores a PENDING refund
27
+ * entry; it is not counter-signed or chained, so the answer carries no digest or seq. */
26
28
  async recordRefund(originalDigest, amount, reason) {
27
29
  const res = await this.f(`${this.cfg.url}/v1/refunds`, {
28
30
  method: 'POST',
@@ -8,6 +8,6 @@ export declare function envDeps(env?: Record<string, string | undefined>): McpDe
8
8
  * every client; mcp.test.ts pins it against package.json so a release bump can't drift it. */
9
9
  export declare const MCP_SERVER_IDENTITY: {
10
10
  readonly name: "tersign";
11
- readonly version: "0.4.11";
11
+ readonly version: "0.6.0";
12
12
  };
13
13
  export declare function buildServer(deps: McpDeps): McpServer;
@@ -14,7 +14,11 @@ export function envDeps(env = process.env) {
14
14
  // that first call self-provisions a signer-keyed account; before this the MCP entry point threw
15
15
  // instead, so `npx tersign` died on first run for anyone who had not already exported a key —
16
16
  // and no directory or sandbox could introspect the server at all.
17
- const key = env.TERSIGN_SELLER_KEY ?? resolveSignerKey({ create: true }).key;
17
+ // `||`, not `??`: an EMPTY TERSIGN_SELLER_KEY means unset, exactly as the keystore reads it.
18
+ // The listing marks the key optional, and a client that fills a blank optional secret with ""
19
+ // (which clients do is unmeasured) got a crash: `??` handed "" to privateKeyToAccount
20
+ // (fixed 2026-09-27). test/listing.test.ts starts the registry command with the key set to "".
21
+ const key = env.TERSIGN_SELLER_KEY || resolveSignerKey({ create: true }).key;
18
22
  const account = privateKeyToAccount(key);
19
23
  const assure = new Assure({
20
24
  signer: account,
@@ -49,14 +53,14 @@ function json(value) {
49
53
  }
50
54
  /** MUST match package.json name/version — the MCP handshake self-reports this identity to
51
55
  * every client; mcp.test.ts pins it against package.json so a release bump can't drift it. */
52
- export const MCP_SERVER_IDENTITY = { name: 'tersign', version: '0.4.11' };
56
+ export const MCP_SERVER_IDENTITY = { name: 'tersign', version: '0.6.0' };
53
57
  export function buildServer(deps) {
54
58
  const server = new McpServer(MCP_SERVER_IDENTITY);
55
59
  server.registerTool('issue_receipt', {
56
60
  title: 'Issue signed receipt',
57
- description: 'Issue an x402 offer-receipt (EIP-712) plus a Tersign action record for a payment that has ALREADY settled, and counter-sign both into your hash chain when a ledger is configured. ' +
61
+ description: 'Issue an x402 offer-receipt (EIP-712) plus a Tersign compliance record (returned as `compliance`; verify_compliance_record checks it) for a payment that has ALREADY settled; when a ledger is configured, the ledger counter-signs the receipt into your hash chain, while the compliance-fields record is not counter-signed and is bound to its receipt only by your own signature, which covers the receipt\'s digest. ' +
58
62
  'Use this for money that moved; use record_disclosure for a non-payment agent action. ' +
59
- 'Side effects: signs with TERSIGN_SELLER_KEY, and performs ONE network write to the ledger when TERSIGN_LEDGER_URL/_API_KEY/_SELLER_ID are set (without them it signs locally and returns an unchained artifact). ' +
63
+ 'Side effects: signs with your signing key (TERSIGN_SELLER_KEY when set, else the one generated and kept locally on first run), and performs ONE network write to the ledger when TERSIGN_LEDGER_URL/_API_KEY/_SELLER_ID are set (without them it signs locally and returns an unchained artifact). ' +
60
64
  'Returns the signed receipt artifact, its keccak256 canonical digest, and — when chained — the ledger counter-signature and sequence number.',
61
65
  inputSchema: {
62
66
  network: z.string().describe('settlement network as CAIP-2, e.g. "eip155:8453" for Base mainnet'),
@@ -78,10 +82,11 @@ export function buildServer(deps) {
78
82
  }, async (args) => json(await issueReceiptTool(deps, args)));
79
83
  server.registerTool('verify_receipt', {
80
84
  title: 'Verify signed receipt',
81
- description: 'Verify an offer-receipt artifact: recover the EIP-712 signature and confirm the payload digest binds to it. ' +
82
- 'Fully OFFLINE — no network, no API key, no account; verifying someone else\'s receipt is the intended use. ' +
83
- 'Use this for a receipt (money); use verify_compliance_record for an action record (a non-payment action). ' +
84
- 'Returns { valid, signer, digest } and, when expectedSigner is supplied and does not match, valid:false with the recovered signer so you can see who actually signed.',
85
+ description: 'Verify an x402 offer-receipt artifact, fully OFFLINE (no network, no API key, no account; checks no ledger or chain): recover the address whose key produced its EIP-712 signature over the six signed payload fields (version, network, resourceUrl, payer, issuedAt, transaction), and compute its canonical digest (the content address a ledger records it under). ' +
86
+ 'Recovery yields SOME address for any payload, so without expectedSigner the signer is UNAUTHENTICATED: valid:true then proves neither who signed nor that the receipt is unmodified — an edited receipt recovers a different address and still returns valid:true. ' +
87
+ 'Pass expectedSigner, the issuer\'s address obtained out-of-band, to bind it: valid:true then means the signed fields were signed by that key; a mismatch returns valid:false with the recovered signer so you can see who actually signed. ' +
88
+ 'Use this for a receipt (money); use verify_compliance_record for the compliance record issue_receipt returns beside a receipt. ' +
89
+ 'Returns { verdict, valid, signer, signerStatus: BOUND | UNAUTHENTICATED | MISMATCH, signerBound, testKey? (the signer is a PUBLISHED test key — anyone can sign as it), digest, unsignedFields? (present in the artifact but not covered by the signature), reason? }. Read verdict first.',
85
90
  inputSchema: {
86
91
  artifact: z
87
92
  .record(z.unknown())
@@ -89,12 +94,12 @@ export function buildServer(deps) {
89
94
  expectedSigner: z
90
95
  .string()
91
96
  .optional()
92
- .describe('0x address the receipt MUST be signed by — obtain it out-of-band, never from the artifact. Omit to recover the signer without enforcing it'),
97
+ .describe('0x address the receipt MUST be signed by — obtain it out-of-band, never from the artifact. Omit to recover the signer without binding it (it is then reported UNAUTHENTICATED)'),
93
98
  },
94
99
  }, async ({ artifact, expectedSigner }) => json(await verifyReceiptTool(artifact, expectedSigner)));
95
100
  server.registerTool('record_disclosure', {
96
101
  title: 'Record counter-signed disclosure',
97
- description: 'One-call disclosure evidence (EU AI Act Art 50 dialect): digests the disclosure text LOCALLY, signs an action record with your key, and the public ledger counter-signs it into your per-signer hash chain. No API key needed — first call self-provisions a free signer-keyed account.',
102
+ description: 'One-call disclosure evidence (EU AI Act Art 50 dialect): digests the disclosure text LOCALLY, signs an action record with your key, and the public ledger counter-signs it into your per-signer hash chain. No API key needed — the first call self-provisions a free signer-keyed account, except when your key is already registered to an API-key account (409) or the daily provisioning caps are reached (429; https://tersign.ai/pricing).',
98
103
  inputSchema: {
99
104
  text: z.string().optional().describe('the disclosure text as presented — digested locally, never transmitted'),
100
105
  textDigest: z.string().regex(/^0x[0-9a-fA-F]{64}$/).optional().describe('pre-computed digest (wins over text)'),
@@ -109,30 +114,29 @@ export function buildServer(deps) {
109
114
  }, async (args) => json(await recordDisclosureTool(deps, args)));
110
115
  server.registerTool('verify_compliance_record', {
111
116
  title: 'Verify compliance record',
112
- description: 'Verify a Tersign action record against its attestation: recompute the record\'s canonical digest, confirm the attestation commits to that exact digest, and recover the signature. ' +
113
- 'Fully OFFLINE — no network, no API key, no account. ' +
114
- 'Use this for an action record (a disclosure or other non-payment agent action); use verify_receipt for a payment receipt. ' +
115
- 'PASS proves integrity and internal consistency only. Authorship needs an out-of-band signer address: pass expectedSigner, or the identity is whatever the artifact claims about itself. ' +
116
- 'Returns { valid, signer, digest }; on mismatch, valid:false plus the recovered signer and the recomputed digest.',
117
+ description: 'Verify ONE record type: the compliance record that issue_receipt returns beside a receipt (`compliance`: a ComplianceRecordV1 `record` plus its ComplianceAttestation `attestation`, EIP-712 domain "compliance-fields"), fully OFFLINE (no network, no API key, no account): recompute the record\'s canonical digest, check that the attestation\'s signed payload names that exact digest and the same receiptDigest as the record, and recover the address that signed the attestation. ' +
118
+ 'It does NOT verify the disclosure record record_disclosure returns: that is a different record type (an action record, EIP-712 domain "tersign action-record"), and passed here it fails with a digest mismatch that says nothing about tampering. Use verify_receipt for the payment receipt itself. ' +
119
+ 'Without expectedSigner the signer is UNAUTHENTICATED and valid:true proves internal consistency only — anyone can edit a record, recompute its digest and re-sign the attestation with their own key, and still get valid:true with a different signer. Pass expectedSigner, obtained out-of-band, to bind authorship. ' +
120
+ 'Returns { verdict, valid, signer, signerStatus: BOUND | UNAUTHENTICATED | MISMATCH, signerBound, testKey? (a PUBLISHED test key — anyone can sign as it), digest (the recomputed record digest), reason? }. Read verdict first.',
117
121
  inputSchema: {
118
122
  record: z
119
123
  .record(z.unknown())
120
- .describe('the action record object as issued (ComplianceRecordV1 shape). Pass the object, not a JSON string; any field edit changes the digest and fails verification — which is the point'),
124
+ .describe('the compliance record exactly as issue_receipt returned it (`compliance.record`, ComplianceRecordV1 shape). Pass the object, not a JSON string; any field edit changes the digest and fails verification — which is the point'),
121
125
  attestation: z
122
126
  .record(z.unknown())
123
- .describe('the attestation that accompanies the record: the signature over the record digest, as returned alongside it at issuance'),
127
+ .describe('the attestation returned with it (`compliance.attestation`): the seller\'s EIP-712 signature over the record digest'),
124
128
  expectedSigner: z
125
129
  .string()
126
130
  .optional()
127
- .describe('0x address the record MUST be signed by, obtained out-of-band (for the public ledger: https://tersign.ai/v1/ledger). Omit to recover the signer without enforcing it'),
131
+ .describe('0x address the record MUST be signed by: the SELLER\'s signing address (the key that signed the receipt and this record), obtained out-of-band — not the ledger\'s counter-signing key, which never signs compliance records. Omit to recover the signer without binding it (it is then reported UNAUTHENTICATED)'),
128
132
  },
129
133
  }, async ({ record, attestation, expectedSigner }) => json(await verifyRecordTool(record, attestation, expectedSigner)));
130
134
  server.registerTool('record_refund', {
131
135
  title: 'Record refund',
132
- description: 'Record a refund against an already-chained receipt, as the SELLER. The refund becomes its own counter-signed entry that references the original — nothing is edited or deleted, so the chain stays append-only and both the charge and the refund remain visible. ' +
136
+ description: 'Log a refund against a receipt already on your chain, as the SELLER. The ledger stores it as a PENDING refund entry that references the original receipt digest; the entry is NOT counter-signed or appended to the hash chain, and it has no digest or sequence number of its own. Nothing is edited or deleted: the original receipt and its chain position stay exactly as they were. ' +
133
137
  'Requires ledger configuration (TERSIGN_LEDGER_URL/_API_KEY/_SELLER_ID) and performs one network write; errors if the original digest is not on your chain. ' +
134
- 'This RECORDS a refund you have already made — it moves no money. ' +
135
- 'Returns the refund record, its digest, the ledger counter-signature and sequence number.',
138
+ 'This RECORDS a refund you have already made — it moves no money. For a signed refund RECORD, build a compliance record with refundOf set to the original record digest (buildMinimalRecord in the SDK). ' +
139
+ 'Returns { id, status: "pending" }.',
136
140
  inputSchema: {
137
141
  originalDigest: z
138
142
  .string()
@@ -186,9 +190,9 @@ export function buildServer(deps) {
186
190
  })));
187
191
  server.registerTool('adjudicate_dispute', {
188
192
  title: 'Adjudicate dispute',
189
- description: 'Trigger deterministic adjudication of an open dispute. The v0 rulebook is public and the verdict is recomputable by anyone from the chain — no discretion, no model in the loop. ' +
190
- 'Side effects: writes a verdict entry, and a refund verdict automatically creates the corresponding refund record. Adjudicating twice is not meaningful; the first verdict stands. ' +
191
- 'Returns the verdict, the rationale naming the rule applied, and the ledger signature over both.',
193
+ description: 'Trigger deterministic adjudication of an open dispute: the same inputs always produce the same verdict and rationale — no discretion, no model in the loop — and the verdict object embeds the inputs it was computed from. ' +
194
+ 'Side effects: writes the verdict to the dispute record; a refund verdict also logs a PENDING refund entry against the receipt (not counter-signed into the chain; no money moves). Adjudicating twice is not meaningful; the first verdict stands (a second call returns 409). ' +
195
+ 'Returns the verdict, the rationale naming the rule applied, the adjudication inputs, the verdict digest, and the ledger signature over that digest.',
192
196
  inputSchema: {
193
197
  disputeDigest: digestSchema.describe('0x-prefixed digest of the open dispute to adjudicate, as returned by open_dispute'),
194
198
  },