@atcn/subledger 1.4.0 → 1.5.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/README.md +11 -6
- package/dist/documents.d.ts +1067 -26
- package/dist/documents.js +121 -7
- package/dist/exceptions.d.ts +47 -2
- package/dist/exceptions.js +173 -3
- package/dist/expectations.d.ts +96 -0
- package/dist/expectations.js +236 -0
- package/dist/importing.d.ts +53 -0
- package/dist/importing.js +192 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/outcome.d.ts +36 -0
- package/dist/outcome.js +64 -0
- package/dist/projection.d.ts +28 -3
- package/dist/projection.js +69 -4
- package/dist/rails.d.ts +108 -0
- package/dist/rails.js +242 -0
- package/dist/response.d.ts +2 -0
- package/dist/response.js +1 -0
- package/dist/rollup.d.ts +1 -1
- package/dist/rollup.js +10 -4
- package/dist/types.d.ts +279 -7
- package/dist/types.js +179 -6
- package/dist/usage.d.ts +41 -0
- package/dist/usage.js +86 -0
- package/dist/verify.d.ts +8 -1
- package/dist/verify.js +385 -7
- package/package.json +6 -4
- package/test-vectors/reconciliation-results.json +151 -0
- package/test-vectors/reconciliation.json +107 -0
- package/test-vectors/vectors.json +25666 -1
package/dist/projection.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { type AttestationItem } from "@atcn/schema";
|
|
1
|
+
import { type AttestationConflict, type AttestationItem } from "@atcn/schema";
|
|
2
2
|
import { type Rollup } from "./rollup.js";
|
|
3
|
-
import { type AllocationRecord, type CaptureGap, type ClosureDelegation, type ClosurePayload, type DeliveryClaim, type Disclosure, type ExceptionRecord, type FieldState, type Issuer, type KeyBindingRecord, type ObligationLink, type ReceiptPayload, type ReceiptTotals, type ResponseRecord } from "./documents.js";
|
|
3
|
+
import { type AllocationRecord, type CaptureGap, type ClosureDelegation, type ClosurePayload, type DeliveryClaim, type Disclosure, type ExceptionRecord, type FieldState, type Issuer, type KeyBindingRecord, type ObligationLink, type ReceiptPayload, type ReceiptTotals, type ResolvedException, type ResponseRecord } from "./documents.js";
|
|
4
4
|
import { type AttestableField, type FinancialEventRecord } from "./types.js";
|
|
5
5
|
/**
|
|
6
6
|
* Pure builders for the two projections of one root task (PRD §16): the private closure snapshot
|
|
@@ -17,6 +17,8 @@ export interface TaskRecord {
|
|
|
17
17
|
scope_ref: string | null;
|
|
18
18
|
retrospective: boolean;
|
|
19
19
|
created_at: string;
|
|
20
|
+
/** Schema 1.5, present only when set. */
|
|
21
|
+
estimate_tolerance_bps?: number;
|
|
20
22
|
}
|
|
21
23
|
export type DelegationRecord = ClosureDelegation & {
|
|
22
24
|
root_task_id: string;
|
|
@@ -26,6 +28,12 @@ export type DelegationRecord = ClosureDelegation & {
|
|
|
26
28
|
export declare function labelClaims(claims: DeliveryClaim[]): DeliveryClaim[];
|
|
27
29
|
/** A response as an attestation. Only a key-signed statement has a signer, so only it can revoke or be revoked. */
|
|
28
30
|
export declare function responseAttestation(response: ResponseRecord): AttestationItem;
|
|
31
|
+
/**
|
|
32
|
+
* Conflicts among key-signed statements (provider or witness) in effect at `at`. A signed_attestation confirms its
|
|
33
|
+
* fields and a propose_correction contests the corrected ones, per receipt revision: one key doing both is
|
|
34
|
+
* equivocation, two keys doing different things is disagreement, and a `disputes` ref is a dispute.
|
|
35
|
+
*/
|
|
36
|
+
export declare function statementConflicts(responses: ResponseRecord[], at: string): AttestationConflict[];
|
|
29
37
|
/** Recomputes the "expired" and "revoked" labels as of `at`. Revoked and expired responses stay visible. */
|
|
30
38
|
export declare function labelResponses(responses: ResponseRecord[], at: string): ResponseRecord[];
|
|
31
39
|
/** Delivery status is the latest non-superseded status claim. It is a recorded claim, not an adjudicated truth. */
|
|
@@ -58,7 +66,16 @@ export interface ReceiptInput {
|
|
|
58
66
|
/** Responses to earlier revisions of this receipt chain. */
|
|
59
67
|
prior_responses: ResponseRecord[];
|
|
60
68
|
capture_gaps: CaptureGap[];
|
|
69
|
+
/** Every key binding known to the issuer; the receipt lists only those its signed claims and estimates name. */
|
|
70
|
+
key_bindings: KeyBindingRecord[];
|
|
61
71
|
}
|
|
72
|
+
/** The key bindings named by the signers of these claims and events, each once, in binding_id order. */
|
|
73
|
+
export declare function signerKeyBindings(claims: {
|
|
74
|
+
signer?: {
|
|
75
|
+
binding_id: string;
|
|
76
|
+
key_id: string;
|
|
77
|
+
};
|
|
78
|
+
}[], events: Pick<FinancialEventRecord, "expectation">[], keyBindings: KeyBindingRecord[]): KeyBindingRecord[];
|
|
62
79
|
/**
|
|
63
80
|
* Provider receipt: one delegation only. Omits sibling delegations, root customer/project/cost-center data,
|
|
64
81
|
* allocation lines, liability and economic-event IDs, and private event hashes (acceptance 8, 19).
|
|
@@ -98,7 +115,15 @@ export interface ClosureInput {
|
|
|
98
115
|
key_bindings: KeyBindingRecord[];
|
|
99
116
|
capture_gaps: CaptureGap[];
|
|
100
117
|
obligation_links?: ObligationLink[];
|
|
118
|
+
/** Derived exceptions a person resolved while their condition still holds (resolutionsInForce). */
|
|
119
|
+
resolved_exceptions?: ResolvedException[];
|
|
101
120
|
}
|
|
102
|
-
|
|
121
|
+
/**
|
|
122
|
+
* What the closure discloses as missing, unverified, contested, provider-reported and retrospective. With
|
|
123
|
+
* `generated_at` (schema 1.5), fields with conflicting signed statements at that time are contested too.
|
|
124
|
+
*/
|
|
125
|
+
export declare function closureDisclosure(input: Pick<ClosureInput, "task" | "delegations" | "claims" | "events" | "responses" | "receipts"> & {
|
|
126
|
+
generated_at?: string;
|
|
127
|
+
}): Disclosure;
|
|
103
128
|
/** Private closure snapshot of the full root task: lineage, event digests, all allocation versions, roll-up, and open exceptions. */
|
|
104
129
|
export declare function buildClosurePayload(input: ClosureInput): ClosurePayload;
|
package/dist/projection.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { digestOf, resolveAttestations } from "@atcn/schema";
|
|
1
|
+
import { digestOf, disputesInEffect, findConflicts, inEffect, resolveAttestations, } from "@atcn/schema";
|
|
2
2
|
import { computeRollup, costSign } from "./rollup.js";
|
|
3
3
|
import { CLOSURE_DOCUMENT_TYPE, RECEIPT_DOCUMENT_TYPE, SUBLEDGER_SCHEMA_VERSION, } from "./documents.js";
|
|
4
|
+
import { buildExpectationReport } from "./expectations.js";
|
|
5
|
+
import { buildRailAttestationReport } from "./rails.js";
|
|
4
6
|
import { ATTESTABLE_FIELDS } from "./types.js";
|
|
7
|
+
import { usageChecksFor } from "./usage.js";
|
|
5
8
|
/** Claims superseded by a later correction stay visible and gain the "superseded" label. */
|
|
6
9
|
export function labelClaims(claims) {
|
|
7
10
|
const superseded = new Set(claims.map((c) => c.supersedes_event_id).filter((id) => id !== null));
|
|
@@ -18,6 +21,34 @@ export function responseAttestation(response) {
|
|
|
18
21
|
refs: s.refs,
|
|
19
22
|
};
|
|
20
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Conflicts among key-signed statements (provider or witness) in effect at `at`. A signed_attestation confirms its
|
|
26
|
+
* fields and a propose_correction contests the corrected ones, per receipt revision: one key doing both is
|
|
27
|
+
* equivocation, two keys doing different things is disagreement, and a `disputes` ref is a dispute.
|
|
28
|
+
*/
|
|
29
|
+
export function statementConflicts(responses, at) {
|
|
30
|
+
const signed = responses.filter((r) => r.assurance.includes("provider_key_signed"));
|
|
31
|
+
const items = signed.map(responseAttestation);
|
|
32
|
+
const resolution = resolveAttestations(items, at);
|
|
33
|
+
const claims = signed
|
|
34
|
+
.filter((r) => inEffect(resolution, r.statement_digest))
|
|
35
|
+
.flatMap((r) => {
|
|
36
|
+
const s = r.statement;
|
|
37
|
+
const claim = (field, status) => ({
|
|
38
|
+
digest: r.statement_digest,
|
|
39
|
+
signer: r.provider_id,
|
|
40
|
+
subject: `receipt:${r.receipt_id}@${r.receipt_revision}.${field}`,
|
|
41
|
+
status,
|
|
42
|
+
...(s.execution ? { execution_digest: s.execution.execution_digest } : {}),
|
|
43
|
+
});
|
|
44
|
+
if (s.response_type === "signed_attestation")
|
|
45
|
+
return s.fields.map((f) => claim(f, "confirmed"));
|
|
46
|
+
if (s.response_type === "propose_correction")
|
|
47
|
+
return s.corrections.map((c) => claim(c.field, "corrected"));
|
|
48
|
+
return [];
|
|
49
|
+
});
|
|
50
|
+
return findConflicts(claims, disputesInEffect(items, resolution));
|
|
51
|
+
}
|
|
21
52
|
const TIME_LABELS = ["expired", "revoked"];
|
|
22
53
|
/** Recomputes the "expired" and "revoked" labels as of `at`. Revoked and expired responses stay visible. */
|
|
23
54
|
export function labelResponses(responses, at) {
|
|
@@ -92,11 +123,19 @@ export function receiptFieldStatus(delegation, claims, events, priorResponses) {
|
|
|
92
123
|
"scope.terms_digest": delegation.terms_digest ? "buyer_asserted" : "missing",
|
|
93
124
|
"financial.amounts": events.some((e) => costSign(e.type) !== 0 || e.type === "quote") ? "imported" : "missing",
|
|
94
125
|
"financial.status": events.some((e) => e.normalized_status !== "unknown") ? "imported" : "missing",
|
|
126
|
+
"delivery.usage": stateOf(active.filter((c) => c.usage !== undefined).at(-1)),
|
|
95
127
|
};
|
|
96
128
|
for (const field of openCorrectionFields(priorResponses))
|
|
97
129
|
status[field] = "contested";
|
|
98
130
|
return status;
|
|
99
131
|
}
|
|
132
|
+
/** The key bindings named by the signers of these claims and events, each once, in binding_id order. */
|
|
133
|
+
export function signerKeyBindings(claims, events, keyBindings) {
|
|
134
|
+
const signers = [...claims.map((c) => c.signer), ...events.map((e) => e.expectation?.signer)].filter((s) => s !== undefined);
|
|
135
|
+
return keyBindings
|
|
136
|
+
.filter((b) => signers.some((s) => s.binding_id === b.binding_id && s.key_id === b.key_id))
|
|
137
|
+
.sort((a, b) => (a.binding_id < b.binding_id ? -1 : a.binding_id > b.binding_id ? 1 : 0));
|
|
138
|
+
}
|
|
100
139
|
/**
|
|
101
140
|
* Provider receipt: one delegation only. Omits sibling delegations, root customer/project/cost-center data,
|
|
102
141
|
* allocation lines, liability and economic-event IDs, and private event hashes (acceptance 8, 19).
|
|
@@ -105,6 +144,7 @@ export function buildReceiptPayload(input) {
|
|
|
105
144
|
const d = input.delegation;
|
|
106
145
|
const claims = labelClaims(input.claims);
|
|
107
146
|
const fieldStatus = receiptFieldStatus(d, claims, input.events, input.prior_responses);
|
|
147
|
+
const keyBindings = signerKeyBindings(input.claims, input.events, input.key_bindings);
|
|
108
148
|
return {
|
|
109
149
|
document_type: RECEIPT_DOCUMENT_TYPE,
|
|
110
150
|
schema_version: SUBLEDGER_SCHEMA_VERSION,
|
|
@@ -130,6 +170,9 @@ export function buildReceiptPayload(input) {
|
|
|
130
170
|
retrospective: d.retrospective,
|
|
131
171
|
downstream_visibility: d.downstream_visibility,
|
|
132
172
|
...(d.execution ? { execution: d.execution } : {}),
|
|
173
|
+
...(d.pricing ? { pricing: d.pricing } : {}),
|
|
174
|
+
...(d.refund_terms ? { refund_terms: d.refund_terms } : {}),
|
|
175
|
+
...(d.witness_policy ? { witness_policy: d.witness_policy } : {}),
|
|
133
176
|
},
|
|
134
177
|
provider: {
|
|
135
178
|
provider_id: input.provider?.provider_id ?? null,
|
|
@@ -149,6 +192,7 @@ export function buildReceiptPayload(input) {
|
|
|
149
192
|
.filter((r) => r.statement.response_type === "propose_correction")
|
|
150
193
|
.map((r) => ({ response_id: r.response_id, receipt_revision: r.receipt_revision, fields: r.statement.fields, decision: r.decision?.status ?? "open" })),
|
|
151
194
|
lineage: { complete: input.capture_gaps.length === 0, capture_gaps: input.capture_gaps.map(({ delegation_id: _delegationId, ...gap }) => gap) },
|
|
195
|
+
...(keyBindings.length > 0 ? { key_bindings: keyBindings } : {}),
|
|
152
196
|
};
|
|
153
197
|
}
|
|
154
198
|
/** Latest allocation lines per financial event, the input the roll-up uses. */
|
|
@@ -169,6 +213,10 @@ export function rollupFor(task, delegations, events, allocations) {
|
|
|
169
213
|
allocations: Object.fromEntries(Object.entries(latest).map(([id, a]) => [id, a.lines])),
|
|
170
214
|
});
|
|
171
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* What the closure discloses as missing, unverified, contested, provider-reported and retrospective. With
|
|
218
|
+
* `generated_at` (schema 1.5), fields with conflicting signed statements at that time are contested too.
|
|
219
|
+
*/
|
|
172
220
|
export function closureDisclosure(input) {
|
|
173
221
|
const missing = [];
|
|
174
222
|
const unverified = [];
|
|
@@ -211,10 +259,23 @@ export function closureDisclosure(input) {
|
|
|
211
259
|
contested.push(`receipt:${r.receipt_id}.${c.field}`);
|
|
212
260
|
}
|
|
213
261
|
}
|
|
262
|
+
if (input.generated_at !== undefined) {
|
|
263
|
+
for (const conflict of statementConflicts(input.responses, input.generated_at)) {
|
|
264
|
+
const field = conflict.subject.replace(/@\d+\./, ".");
|
|
265
|
+
if (!contested.includes(field))
|
|
266
|
+
contested.push(field);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
214
269
|
return { missing, unverified, contested, provider_reported: providerReported, retrospective };
|
|
215
270
|
}
|
|
216
271
|
/** Private closure snapshot of the full root task: lineage, event digests, all allocation versions, roll-up, and open exceptions. */
|
|
217
272
|
export function buildClosurePayload(input) {
|
|
273
|
+
const claims = labelClaims(input.claims);
|
|
274
|
+
const rollup = rollupFor(input.task, input.delegations, input.events, input.allocations);
|
|
275
|
+
const responses = labelResponses(input.responses, input.generated_at);
|
|
276
|
+
const usageChecks = usageChecksFor(input.delegations, claims, rollup, responses, input.receipts);
|
|
277
|
+
const expectationReport = buildExpectationReport({ task: input.task, delegations: input.delegations, claims, events: input.events, rollup, key_bindings: input.key_bindings });
|
|
278
|
+
const railAttestations = buildRailAttestationReport(input.events);
|
|
218
279
|
return {
|
|
219
280
|
document_type: CLOSURE_DOCUMENT_TYPE,
|
|
220
281
|
schema_version: SUBLEDGER_SCHEMA_VERSION,
|
|
@@ -226,13 +287,13 @@ export function buildClosurePayload(input) {
|
|
|
226
287
|
issuer: input.issuer,
|
|
227
288
|
task: input.task,
|
|
228
289
|
delegations: input.delegations,
|
|
229
|
-
delivery_claims:
|
|
290
|
+
delivery_claims: claims,
|
|
230
291
|
financial_events: input.events.map((e) => ({ record: e.record, event_digest: digestOf(e.record), attributed_to: e.attributed_to })),
|
|
231
292
|
allocations: input.allocations,
|
|
232
|
-
rollup
|
|
293
|
+
rollup,
|
|
233
294
|
open_exceptions: input.open_exceptions,
|
|
234
295
|
receipts: input.receipts,
|
|
235
|
-
responses
|
|
296
|
+
responses,
|
|
236
297
|
key_bindings: input.key_bindings,
|
|
237
298
|
lineage: {
|
|
238
299
|
complete: input.capture_gaps.length === 0,
|
|
@@ -241,5 +302,9 @@ export function buildClosurePayload(input) {
|
|
|
241
302
|
},
|
|
242
303
|
disclosure: closureDisclosure(input),
|
|
243
304
|
...(input.obligation_links && input.obligation_links.length > 0 ? { obligation_links: input.obligation_links } : {}),
|
|
305
|
+
...(usageChecks.length > 0 ? { usage_checks: usageChecks } : {}),
|
|
306
|
+
...(expectationReport ? { expectation_report: expectationReport } : {}),
|
|
307
|
+
...(railAttestations ? { rail_attestations: railAttestations } : {}),
|
|
308
|
+
...(input.resolved_exceptions && input.resolved_exceptions.length > 0 ? { resolved_exceptions: input.resolved_exceptions } : {}),
|
|
244
309
|
};
|
|
245
310
|
}
|
package/dist/rails.d.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { FinancialEventRecord, RailAttestation } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Rail attestations (schema 1.5): a payment rail's own record that a payment or refund happened, embedded in the
|
|
4
|
+
* financial event so the closure re-verifies it offline, without contacting the rail. ATCN never moves money; it checks
|
|
5
|
+
* what the rail recorded and labels the event rail_attested, separately from operator-reported payment status.
|
|
6
|
+
*/
|
|
7
|
+
/** What a verified rail record proves. */
|
|
8
|
+
export interface RailFacts {
|
|
9
|
+
type: "payment_reported" | "refund";
|
|
10
|
+
amount_minor: number;
|
|
11
|
+
currency: string;
|
|
12
|
+
/** The rail's reference for the payment: an escrow id or a transaction hash. Recorded as the event's provider_reference. */
|
|
13
|
+
rail_ref: string;
|
|
14
|
+
/** The buyer's job reference the rail recorded (an A2A task id, an authorization nonce), when it records one. */
|
|
15
|
+
job_ref: string | null;
|
|
16
|
+
occurred_at: string | null;
|
|
17
|
+
}
|
|
18
|
+
export type RailVerification = {
|
|
19
|
+
ok: true;
|
|
20
|
+
rail: string;
|
|
21
|
+
facts: RailFacts;
|
|
22
|
+
anchor: string;
|
|
23
|
+
} | {
|
|
24
|
+
ok: false;
|
|
25
|
+
code: RailRefusalCode;
|
|
26
|
+
detail: string;
|
|
27
|
+
};
|
|
28
|
+
export type RailRefusalCode = "unsupported_scheme" | "malformed" | "data_hash_mismatch" | "merkle_proof_invalid" | "signature_invalid" | "settlement_not_successful" | "unsupported_asset";
|
|
29
|
+
/** Verifies one rail's records offline. Add a rail by implementing this and listing it in RAIL_IMPORTERS. */
|
|
30
|
+
export interface RailAttestationImporter {
|
|
31
|
+
rail: string;
|
|
32
|
+
schemes: readonly string[];
|
|
33
|
+
verify(attestation: RailAttestation): RailVerification;
|
|
34
|
+
}
|
|
35
|
+
export declare const A2A_SE_RELEASE_SCHEME = "urn:a2a-se:escrow-release-attestation:v1";
|
|
36
|
+
export declare const A2A_SE_REFUND_SCHEME = "urn:a2a-se:escrow-refund-attestation:v1";
|
|
37
|
+
/** Python's json.dumps(value, sort_keys=True, separators=(",", ":")), which A2A-SE hashes: keys by code point, non-ASCII escaped. */
|
|
38
|
+
export declare function pythonCanonicalJson(value: unknown): string;
|
|
39
|
+
/**
|
|
40
|
+
* A record from GET /v1/exchange/escrow/{escrow_id}/attestations: { leaf_index, data_hash, merkle_root, proof, schema_id,
|
|
41
|
+
* payload }. The payload's leaf hash must be data_hash, and folding the proof must give merkle_root. A2A-SE attestations
|
|
42
|
+
* are not signed; the anchor is the Merkle root of the exchange's append-only log, which the reader should compare with
|
|
43
|
+
* the root the exchange publishes.
|
|
44
|
+
*/
|
|
45
|
+
export declare const a2aSeImporter: RailAttestationImporter;
|
|
46
|
+
export declare const X402_EXACT_EVM_SCHEME = "x402:exact-evm:v2";
|
|
47
|
+
/** USD stablecoins, by CAIP-2 network and token address (lowercase). ATCN records them as USD cents at 1:1. */
|
|
48
|
+
export declare const X402_USD_ASSETS: Record<string, {
|
|
49
|
+
name: string;
|
|
50
|
+
decimals: number;
|
|
51
|
+
}>;
|
|
52
|
+
interface X402Authorization {
|
|
53
|
+
from: string;
|
|
54
|
+
to: string;
|
|
55
|
+
value: string;
|
|
56
|
+
validAfter: string;
|
|
57
|
+
validBefore: string;
|
|
58
|
+
nonce: string;
|
|
59
|
+
}
|
|
60
|
+
/** The EIP-712 digest a payer signs for an EIP-3009 transferWithAuthorization (the x402 "exact" EVM scheme). */
|
|
61
|
+
export declare function eip3009Digest(authorization: X402Authorization, domain: {
|
|
62
|
+
name: string;
|
|
63
|
+
version: string;
|
|
64
|
+
chainId: bigint;
|
|
65
|
+
verifyingContract: string;
|
|
66
|
+
}): Uint8Array;
|
|
67
|
+
/** The address that made a 65-byte (r, s, v) secp256k1 signature over a digest. */
|
|
68
|
+
export declare function recoverAddress(digest: Uint8Array, signature: string): string;
|
|
69
|
+
/**
|
|
70
|
+
* A settled x402 payment: { requirements, payload, settlement } as the client and facilitator exchanged them. The payer's
|
|
71
|
+
* EIP-3009 authorization signature must recover its `from` address and authorize exactly the required amount to payTo.
|
|
72
|
+
* The facilitator's settlement response supplies success and the transaction hash; on-chain inclusion is not checked
|
|
73
|
+
* offline.
|
|
74
|
+
*/
|
|
75
|
+
export declare const x402Importer: RailAttestationImporter;
|
|
76
|
+
export declare const RAIL_IMPORTERS: readonly RailAttestationImporter[];
|
|
77
|
+
export declare function verifyRailAttestation(attestation: RailAttestation, importers?: readonly RailAttestationImporter[]): RailVerification;
|
|
78
|
+
/** Why a financial event's rail attestation does not support it, or null when it verifies and agrees with the event. */
|
|
79
|
+
export declare function railAttestationProblem(event: Pick<FinancialEventRecord, "type" | "amount_minor" | "currency" | "provider_reference" | "rail_attestation">): {
|
|
80
|
+
code: string;
|
|
81
|
+
detail: string;
|
|
82
|
+
} | null;
|
|
83
|
+
export interface RailAttestationEntry {
|
|
84
|
+
financial_event_id: string;
|
|
85
|
+
scheme: string;
|
|
86
|
+
rail: string;
|
|
87
|
+
rail_ref: string;
|
|
88
|
+
anchor: string;
|
|
89
|
+
assurance: ["rail_attested"];
|
|
90
|
+
}
|
|
91
|
+
/** The closure's rail_attestations: one entry per event whose embedded attestation verifies and agrees with it; null when none carry one. */
|
|
92
|
+
export declare function buildRailAttestationReport(events: {
|
|
93
|
+
record: FinancialEventRecord;
|
|
94
|
+
}[]): RailAttestationEntry[] | null;
|
|
95
|
+
/**
|
|
96
|
+
* Buyer side: the financial event body for a rail record, after verifying it. Throws RailAttestationError with the
|
|
97
|
+
* refusal code when it does not verify. Matching uses the job reference the rail recorded unless `match` is given.
|
|
98
|
+
*/
|
|
99
|
+
export declare function financialEventFromRailAttestation(attestation: RailAttestation, options: {
|
|
100
|
+
source: string;
|
|
101
|
+
match?: Record<string, string>;
|
|
102
|
+
eventDate?: string;
|
|
103
|
+
}): Record<string, unknown>;
|
|
104
|
+
export declare class RailAttestationError extends Error {
|
|
105
|
+
readonly code: string;
|
|
106
|
+
constructor(code: string, detail: string);
|
|
107
|
+
}
|
|
108
|
+
export {};
|
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
|
+
}
|
package/dist/response.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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,
|