@atcn/subledger 1.4.1 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/rails.js ADDED
@@ -0,0 +1,242 @@
1
+ import { secp256k1 } from "@noble/curves/secp256k1";
2
+ import { sha256 } from "@noble/hashes/sha2";
3
+ import { keccak_256 } from "@noble/hashes/sha3";
4
+ import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from "@noble/hashes/utils";
5
+ const refuse = (code, detail) => ({ ok: false, code, detail });
6
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
7
+ const isHex64 = (value) => typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
8
+ const isCurrency = (value) => typeof value === "string" && /^[A-Z]{3}$/.test(value);
9
+ const isAmount = (value) => Number.isSafeInteger(value) && value >= 0;
10
+ // ---------- A2A-SE escrow attestations ----------
11
+ export const A2A_SE_RELEASE_SCHEME = "urn:a2a-se:escrow-release-attestation:v1";
12
+ export const A2A_SE_REFUND_SCHEME = "urn:a2a-se:escrow-refund-attestation:v1";
13
+ /** Python's json.dumps(value, sort_keys=True, separators=(",", ":")), which A2A-SE hashes: keys by code point, non-ASCII escaped. */
14
+ export function pythonCanonicalJson(value) {
15
+ if (value === null || typeof value === "boolean")
16
+ return String(value);
17
+ if (typeof value === "number") {
18
+ if (!Number.isSafeInteger(value))
19
+ throw new Error(`number ${value} is not an integer; A2A-SE records carry integers only`);
20
+ return String(value);
21
+ }
22
+ if (typeof value === "string")
23
+ return asciiJsonString(value);
24
+ if (Array.isArray(value))
25
+ return `[${value.map(pythonCanonicalJson).join(",")}]`;
26
+ if (isObject(value)) {
27
+ const keys = Object.keys(value).sort(byCodePoint);
28
+ return `{${keys.map((key) => `${asciiJsonString(key)}:${pythonCanonicalJson(value[key])}`).join(",")}}`;
29
+ }
30
+ throw new Error(`${typeof value} cannot be serialized`);
31
+ }
32
+ function asciiJsonString(value) {
33
+ return JSON.stringify(value).replace(/[\u007f-\uffff]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
34
+ }
35
+ function byCodePoint(a, b) {
36
+ const left = Array.from(a, (c) => c.codePointAt(0));
37
+ const right = Array.from(b, (c) => c.codePointAt(0));
38
+ for (let i = 0; i < Math.min(left.length, right.length); i++)
39
+ if (left[i] !== right[i])
40
+ return left[i] - right[i];
41
+ return left.length - right.length;
42
+ }
43
+ const leafHash = (canonical) => bytesToHex(sha256(concatBytes(new Uint8Array([0]), utf8ToBytes(canonical))));
44
+ const nodeHash = (left, right) => bytesToHex(sha256(concatBytes(new Uint8Array([1]), hexToBytes(left), hexToBytes(right))));
45
+ /**
46
+ * A record from GET /v1/exchange/escrow/{escrow_id}/attestations: { leaf_index, data_hash, merkle_root, proof, schema_id,
47
+ * payload }. The payload's leaf hash must be data_hash, and folding the proof must give merkle_root. A2A-SE attestations
48
+ * are not signed; the anchor is the Merkle root of the exchange's append-only log, which the reader should compare with
49
+ * the root the exchange publishes.
50
+ */
51
+ export const a2aSeImporter = {
52
+ rail: "a2a-se",
53
+ schemes: [A2A_SE_RELEASE_SCHEME, A2A_SE_REFUND_SCHEME],
54
+ verify({ scheme, record }) {
55
+ const { payload, data_hash, merkle_root, proof, schema_id } = record;
56
+ if (schema_id !== scheme)
57
+ return refuse("malformed", `schema_id ${String(schema_id)} is not the declared scheme ${scheme}`);
58
+ if (!isObject(payload) || !isObject(payload.header) || payload.header.schema_id !== scheme)
59
+ return refuse("malformed", "payload.header.schema_id must be the declared scheme");
60
+ if (!isHex64(data_hash) || !isHex64(merkle_root) || !Array.isArray(proof))
61
+ return refuse("malformed", "data_hash, merkle_root and proof are required");
62
+ let canonical;
63
+ try {
64
+ canonical = pythonCanonicalJson(payload);
65
+ }
66
+ catch (error) {
67
+ return refuse("malformed", error.message);
68
+ }
69
+ if (leafHash(canonical) !== data_hash)
70
+ return refuse("data_hash_mismatch", "the payload does not hash to data_hash");
71
+ let computed = data_hash;
72
+ for (const step of proof) {
73
+ if (!isObject(step) || !isHex64(step.sibling_hash) || (step.side !== "left" && step.side !== "right"))
74
+ return refuse("malformed", "each proof step needs sibling_hash and side");
75
+ computed = step.side === "left" ? nodeHash(step.sibling_hash, computed) : nodeHash(computed, step.sibling_hash);
76
+ }
77
+ if (computed !== merkle_root)
78
+ return refuse("merkle_proof_invalid", "the proof does not lead from data_hash to merkle_root");
79
+ const settlement = payload.settlement;
80
+ const amount = scheme === A2A_SE_RELEASE_SCHEME ? payload.amount_paid : payload.amount_returned;
81
+ if (!isObject(settlement) || typeof settlement.escrow_id !== "string" || !isCurrency(settlement.currency) || !isAmount(amount)) {
82
+ return refuse("malformed", "settlement.escrow_id, settlement.currency and the amount are required");
83
+ }
84
+ return {
85
+ ok: true,
86
+ rail: "a2a-se",
87
+ anchor: `A2A-SE log leaf ${String(record.leaf_index)} under Merkle root ${merkle_root} (unsigned; compare the root with the one the exchange publishes)`,
88
+ facts: {
89
+ type: scheme === A2A_SE_RELEASE_SCHEME ? "payment_reported" : "refund",
90
+ amount_minor: amount,
91
+ currency: settlement.currency,
92
+ rail_ref: settlement.escrow_id,
93
+ job_ref: typeof settlement.task_id === "string" ? settlement.task_id : null,
94
+ occurred_at: typeof settlement.occurred_at === "string" ? settlement.occurred_at : null,
95
+ },
96
+ };
97
+ },
98
+ };
99
+ // ---------- x402 exact (EVM) payments ----------
100
+ export const X402_EXACT_EVM_SCHEME = "x402:exact-evm:v2";
101
+ /** USD stablecoins, by CAIP-2 network and token address (lowercase). ATCN records them as USD cents at 1:1. */
102
+ export const X402_USD_ASSETS = {
103
+ "eip155:84532:0x036cbd53842c5426634e7929541ec2318f3dcf7e": { name: "USDC", decimals: 6 },
104
+ "eip155:8453:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913": { name: "USDC", decimals: 6 },
105
+ };
106
+ const keccakText = (text) => keccak_256(utf8ToBytes(text));
107
+ const word = (value) => hexToBytes(value.toString(16).padStart(64, "0"));
108
+ const addressWord = (address) => word(BigInt(address));
109
+ const TRANSFER_TYPEHASH = keccakText("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)");
110
+ const DOMAIN_TYPEHASH = keccakText("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
111
+ /** The EIP-712 digest a payer signs for an EIP-3009 transferWithAuthorization (the x402 "exact" EVM scheme). */
112
+ export function eip3009Digest(authorization, domain) {
113
+ const domainSeparator = keccak_256(concatBytes(DOMAIN_TYPEHASH, keccakText(domain.name), keccakText(domain.version), word(domain.chainId), addressWord(domain.verifyingContract)));
114
+ const structHash = keccak_256(concatBytes(TRANSFER_TYPEHASH, addressWord(authorization.from), addressWord(authorization.to), word(BigInt(authorization.value)), word(BigInt(authorization.validAfter)), word(BigInt(authorization.validBefore)), hexToBytes(authorization.nonce.slice(2))));
115
+ return keccak_256(concatBytes(new Uint8Array([0x19, 0x01]), domainSeparator, structHash));
116
+ }
117
+ /** The address that made a 65-byte (r, s, v) secp256k1 signature over a digest. */
118
+ export function recoverAddress(digest, signature) {
119
+ const bytes = hexToBytes(signature.slice(2));
120
+ const v = bytes[64];
121
+ const point = secp256k1.Signature.fromCompact(bytes.slice(0, 64))
122
+ .addRecoveryBit(v >= 27 ? v - 27 : v)
123
+ .recoverPublicKey(digest);
124
+ return `0x${bytesToHex(keccak_256(point.toRawBytes(false).slice(1)).slice(-20))}`;
125
+ }
126
+ /**
127
+ * A settled x402 payment: { requirements, payload, settlement } as the client and facilitator exchanged them. The payer's
128
+ * EIP-3009 authorization signature must recover its `from` address and authorize exactly the required amount to payTo.
129
+ * The facilitator's settlement response supplies success and the transaction hash; on-chain inclusion is not checked
130
+ * offline.
131
+ */
132
+ export const x402Importer = {
133
+ rail: "x402",
134
+ schemes: [X402_EXACT_EVM_SCHEME],
135
+ verify({ record }) {
136
+ const { requirements, payload, settlement } = record;
137
+ if (!isObject(requirements) || !isObject(payload) || !isObject(settlement) || !isObject(payload.authorization) || typeof payload.signature !== "string") {
138
+ return refuse("malformed", "requirements, payload.authorization, payload.signature and settlement are required");
139
+ }
140
+ const authorization = payload.authorization;
141
+ const extra = isObject(requirements.extra) ? requirements.extra : {};
142
+ const network = String(requirements.network);
143
+ const chain = /^eip155:(\d+)$/.exec(network);
144
+ const hexFields = [authorization.from, authorization.to, String(requirements.asset), String(requirements.payTo)];
145
+ if (requirements.scheme !== "exact" || !chain || typeof extra.name !== "string" || typeof extra.version !== "string" || !hexFields.every((f) => /^0x[0-9a-fA-F]{40}$/.test(f))) {
146
+ return refuse("malformed", "requirements must be the exact scheme on an eip155 network, with extra.name, extra.version and EVM addresses");
147
+ }
148
+ if (!/^0x[0-9a-fA-F]{64}$/.test(authorization.nonce) || !/^0x[0-9a-fA-F]{130}$/.test(payload.signature))
149
+ return refuse("malformed", "nonce must be 32 bytes and signature 65 bytes, hex");
150
+ if (authorization.to.toLowerCase() !== String(requirements.payTo).toLowerCase() || authorization.value !== String(requirements.amount)) {
151
+ return refuse("malformed", "the authorization must pay exactly the required amount to payTo");
152
+ }
153
+ let signer;
154
+ try {
155
+ signer = recoverAddress(eip3009Digest(authorization, { name: extra.name, version: extra.version, chainId: BigInt(chain[1]), verifyingContract: String(requirements.asset) }), payload.signature);
156
+ }
157
+ catch {
158
+ return refuse("signature_invalid", "the authorization signature cannot be recovered");
159
+ }
160
+ if (signer !== authorization.from.toLowerCase())
161
+ return refuse("signature_invalid", `the authorization was signed by ${signer}, not the payer ${authorization.from}`);
162
+ if (settlement.success !== true || typeof settlement.transaction !== "string" || settlement.transaction === "" || settlement.network !== network) {
163
+ return refuse("settlement_not_successful", "the facilitator did not report a successful settlement on the required network");
164
+ }
165
+ const asset = X402_USD_ASSETS[`${network}:${String(requirements.asset)}`.toLowerCase()];
166
+ const perCent = asset ? 10n ** BigInt(asset.decimals - 2) : 0n;
167
+ if (!asset || BigInt(authorization.value) % perCent !== 0n)
168
+ return refuse("unsupported_asset", `no whole-cent USD conversion for ${String(requirements.asset)} on ${network}`);
169
+ return {
170
+ ok: true,
171
+ rail: "x402",
172
+ anchor: `payer ${authorization.from} signed the EIP-3009 authorization; transaction ${settlement.transaction} as reported by the facilitator (on-chain inclusion not checked offline)`,
173
+ facts: { type: "payment_reported", amount_minor: Number(BigInt(authorization.value) / perCent), currency: "USD", rail_ref: settlement.transaction, job_ref: authorization.nonce, occurred_at: null },
174
+ };
175
+ },
176
+ };
177
+ // ---------- Registry, consistency and the closure report ----------
178
+ export const RAIL_IMPORTERS = [a2aSeImporter, x402Importer];
179
+ export function verifyRailAttestation(attestation, importers = RAIL_IMPORTERS) {
180
+ const importer = importers.find((i) => i.schemes.includes(attestation.scheme));
181
+ return importer ? importer.verify(attestation) : refuse("unsupported_scheme", `no importer for scheme ${attestation.scheme}`);
182
+ }
183
+ /** Why a financial event's rail attestation does not support it, or null when it verifies and agrees with the event. */
184
+ export function railAttestationProblem(event) {
185
+ if (!event.rail_attestation)
186
+ return null;
187
+ const result = verifyRailAttestation(event.rail_attestation);
188
+ if (!result.ok)
189
+ return { code: result.code, detail: result.detail };
190
+ const { facts } = result;
191
+ const mismatches = [
192
+ facts.type !== event.type ? `type ${event.type} (the rail recorded ${facts.type})` : null,
193
+ facts.amount_minor !== event.amount_minor ? `amount ${event.amount_minor} (the rail recorded ${facts.amount_minor})` : null,
194
+ facts.currency !== event.currency ? `currency ${event.currency} (the rail recorded ${facts.currency})` : null,
195
+ facts.rail_ref !== event.provider_reference ? `provider_reference ${String(event.provider_reference)} (the rail recorded ${facts.rail_ref})` : null,
196
+ ].filter((m) => m !== null);
197
+ return mismatches.length > 0 ? { code: "attestation_mismatch", detail: `the event does not match its rail attestation: ${mismatches.join("; ")}` } : null;
198
+ }
199
+ /** The closure's rail_attestations: one entry per event whose embedded attestation verifies and agrees with it; null when none carry one. */
200
+ export function buildRailAttestationReport(events) {
201
+ const attested = events.filter((e) => e.record.rail_attestation);
202
+ if (attested.length === 0)
203
+ return null;
204
+ return attested.flatMap(({ record }) => {
205
+ const result = verifyRailAttestation(record.rail_attestation);
206
+ if (!result.ok || railAttestationProblem(record) !== null)
207
+ return [];
208
+ return [{ financial_event_id: record.financial_event_id, scheme: record.rail_attestation.scheme, rail: result.rail, rail_ref: result.facts.rail_ref, anchor: result.anchor, assurance: ["rail_attested"] }];
209
+ });
210
+ }
211
+ /**
212
+ * Buyer side: the financial event body for a rail record, after verifying it. Throws RailAttestationError with the
213
+ * refusal code when it does not verify. Matching uses the job reference the rail recorded unless `match` is given.
214
+ */
215
+ export function financialEventFromRailAttestation(attestation, options) {
216
+ const result = verifyRailAttestation(attestation);
217
+ if (!result.ok)
218
+ throw new RailAttestationError(result.code, result.detail);
219
+ const { facts } = result;
220
+ const eventDate = facts.occurred_at ?? options.eventDate;
221
+ if (!eventDate)
222
+ throw new RailAttestationError("malformed", "the rail recorded no time; pass eventDate");
223
+ return {
224
+ type: facts.type,
225
+ source: options.source,
226
+ source_event_id: `${attestation.scheme}:${facts.rail_ref}`,
227
+ provider_reference: facts.rail_ref,
228
+ amount_minor: facts.amount_minor,
229
+ currency: facts.currency,
230
+ event_date: eventDate,
231
+ normalized_status: facts.type === "refund" ? "refunded" : "reported_paid",
232
+ match: options.match ?? (facts.job_ref ? { provider_job_ref: facts.job_ref } : {}),
233
+ rail_attestation: attestation,
234
+ };
235
+ }
236
+ export class RailAttestationError extends Error {
237
+ code;
238
+ constructor(code, detail) {
239
+ super(`${code}: ${detail}`);
240
+ this.code = code;
241
+ }
242
+ }
@@ -18,6 +18,8 @@ export interface StatementInput {
18
18
  issued_at?: string;
19
19
  expires_at?: string;
20
20
  refs?: AttestationRef[];
21
+ /** Schema 1.5: "witness" when an independent witness signs that it observed the run. */
22
+ role?: "witness";
21
23
  }
22
24
  /** Builds the statement a provider responds with. Field order is irrelevant: the signature covers canonical JSON. */
23
25
  export declare function buildResponseStatement(input: StatementInput): ResponseStatement;
package/dist/response.js CHANGED
@@ -17,6 +17,7 @@ export function buildResponseStatement(input) {
17
17
  ...(input.issued_at !== undefined ? { issued_at: input.issued_at } : {}),
18
18
  ...(input.expires_at !== undefined ? { expires_at: input.expires_at } : {}),
19
19
  ...(input.refs !== undefined ? { refs: input.refs } : {}),
20
+ ...(input.role !== undefined ? { role: input.role } : {}),
20
21
  };
21
22
  }
22
23
  export function statementDigest(statement) {
package/dist/rollup.d.ts CHANGED
@@ -47,7 +47,7 @@ export interface Rollup {
47
47
  fx_rates: string[];
48
48
  };
49
49
  }
50
- /** +1 adds to buyer cost, -1 reduces it, 0 carries no cost (quotes, payment reports, reversals, rates). */
50
+ /** +1 adds to buyer cost, -1 reduces it, 0 carries no cost (quotes, estimates, holds, payment reports, reversals, rates). */
51
51
  export declare function costSign(type: FinancialEventType): 1 | -1 | 0;
52
52
  export declare function emptyTotals(): Totals;
53
53
  /** True when an event counts as the buyer's own expense rather than reported downstream cost (acceptance 25). */
package/dist/rollup.js CHANGED
@@ -1,4 +1,4 @@
1
- /** +1 adds to buyer cost, -1 reduces it, 0 carries no cost (quotes, payment reports, reversals, rates). */
1
+ /** +1 adds to buyer cost, -1 reduces it, 0 carries no cost (quotes, estimates, holds, payment reports, reversals, rates). */
2
2
  export function costSign(type) {
3
3
  if (type === "invoice" || type === "charge" || type === "fee" || type === "adjustment")
4
4
  return 1;
@@ -71,6 +71,8 @@ export function computeRollup(input) {
71
71
  }
72
72
  continue;
73
73
  }
74
+ if (event.type === "estimate" || event.type === "hold")
75
+ continue;
74
76
  if (event.type === "payment_reported") {
75
77
  t.reported_paid += event.amount_minor;
76
78
  continue;
@@ -117,11 +119,14 @@ export function computeRollup(input) {
117
119
  }
118
120
  }
119
121
  // Children before parents: deepest first, so each subtree total is final when added to its parent.
122
+ // A parent outside the task or a parent cycle ends the walk; the verifier's lineage check reports both.
120
123
  const depth = (id) => {
121
124
  let d = 0;
122
125
  let current = nodes.get(id);
123
- while (current.parent_id) {
126
+ const seen = new Set([id]);
127
+ while (current.parent_id && nodes.has(current.parent_id) && !seen.has(current.parent_id)) {
124
128
  d += 1;
129
+ seen.add(current.parent_id);
125
130
  current = nodes.get(current.parent_id);
126
131
  }
127
132
  return d;
@@ -131,8 +136,9 @@ export function computeRollup(input) {
131
136
  const node = nodes.get(id);
132
137
  addInto(node.total, node.direct);
133
138
  addInto(node.total, node.descendant);
134
- if (node.parent_id)
135
- addInto(nodes.get(node.parent_id).descendant, node.total);
139
+ const parent = node.parent_id ? nodes.get(node.parent_id) : undefined;
140
+ if (parent)
141
+ addInto(parent.descendant, node.total);
136
142
  }
137
143
  return {
138
144
  root_task_id: input.root.task_id,