@uuaid/pillar 0.2.0-alpha.11 → 0.2.0-alpha.12
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/bin/pillar-escrow.mjs +674 -0
- package/package.json +13 -3
- package/src/escrow/box.mjs +43 -0
- package/src/escrow/escrow-service.mjs +538 -0
- package/src/escrow/rails/evm-rail.mjs +427 -0
- package/src/escrow/rails/index.mjs +41 -0
- package/src/escrow/rails/zilligon-rail.mjs +73 -0
- package/src/escrow/terms.mjs +368 -0
- package/src/escrow/token-registry.mjs +231 -0
- package/src/escrow/verify.mjs +119 -0
- package/src/escrow/zilligon.mjs +266 -0
- package/src/mailbox/mailbox.mjs +10 -0
- package/src/net/carrier-client.mjs +4 -2
- package/src/observability.mjs +141 -0
- package/src/pillar.mjs +56 -10
- package/src/vda/core.mjs +290 -0
- package/src/vda/directory.mjs +87 -0
- package/src/vda/instruments.mjs +246 -0
- package/src/vda/shims.mjs +77 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VDA-1 instruments (spec §6) — the observation side. One small function
|
|
3
|
+
* per domain; every path returns a full signed envelope via buildAttestation
|
|
4
|
+
* so verdict/coverage/reason discipline is enforced in one place.
|
|
5
|
+
*
|
|
6
|
+
* transfer -> delegated to TransferVerifier (rails; Phase 0)
|
|
7
|
+
* data-delivery -> recipient-signed delivery-ack (§6.2)
|
|
8
|
+
* message-delivery -> pillar mailbox chain-hashed logs (§6.3)
|
|
9
|
+
* media-availability -> fetch-and-hash probe (§6.4)
|
|
10
|
+
* fiat-settlement -> issuer artifact wrap, `issuer-attested` at best (§6.5)
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { jcs } from "../identity/jcs.mjs";
|
|
14
|
+
import { Keychain } from "../identity/keychain.mjs";
|
|
15
|
+
import { normalizeClaim, buildAttestation } from "./core.mjs";
|
|
16
|
+
|
|
17
|
+
export const ACK_KIND = "delivery-ack";
|
|
18
|
+
|
|
19
|
+
/** sha256 hex of raw bytes (the contentHash every data/media claim carries). */
|
|
20
|
+
export function contentHashOf(bytes) {
|
|
21
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Recipient side: produce a signed delivery acknowledgment for received
|
|
26
|
+
* bytes. The ack is the INSTRUMENT for data-delivery claims — hold it with
|
|
27
|
+
* the content, hand it to whoever needs to attest delivery.
|
|
28
|
+
*/
|
|
29
|
+
export function makeDeliveryAck({ keychain, contentHash, from, to, envelopeId = null, now = null }) {
|
|
30
|
+
if (!/^[0-9a-f]{64}$/.test(String(contentHash ?? ""))) throw new Error("delivery-ack: contentHash must be sha256 hex");
|
|
31
|
+
const ack = { v: 1, kind: ACK_KIND, contentHash, from, to, envelopeId, at: now ?? new Date().toISOString() };
|
|
32
|
+
const ackSig = keychain.sign(Buffer.from(jcs(ack), "utf-8")).toString("hex");
|
|
33
|
+
return { ack, ackSig, recipientPubKey: keychain._identity.publicKeyHex };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function refuse(claimRaw, reason, keychain) {
|
|
37
|
+
// Refusals still normalize what they can so the id is stable when the
|
|
38
|
+
// claim is well-formed but the instrument declines; a malformed claim is
|
|
39
|
+
// replaced by the conformant `malformed` stub (spec §4).
|
|
40
|
+
const n = normalizeClaim(claimRaw ?? {});
|
|
41
|
+
const claim = n.ok ? n.claim : normalizeClaim({ kind: "malformed", originalKind: String(claimRaw?.kind ?? "unknown") }).claim;
|
|
42
|
+
return buildAttestation({
|
|
43
|
+
claim, verdict: "unsupported", reason,
|
|
44
|
+
coverage: "Verifier refused before consulting any instrument; this attestation observes nothing about the event.",
|
|
45
|
+
keychain,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Attest a data-delivery claim from a delivery-ack (spec §6.2).
|
|
51
|
+
* Key binding honesty: recipientPubKey in the CLAIM means "the claimant
|
|
52
|
+
* asserts this key is the recipient's"; `directory` (optional) upgrades the
|
|
53
|
+
* binding by resolving `to` at ack time. Coverage names which binding was
|
|
54
|
+
* used — a claim-supplied key proves possession of that key, not identity.
|
|
55
|
+
*/
|
|
56
|
+
export function attestDataDelivery({ claim: claimRaw, ack, ackSig, recipientPubKey = null, directory = null, keychain = null, binds = null }) {
|
|
57
|
+
const n = normalizeClaim({ ...claimRaw, kind: "data-delivery" });
|
|
58
|
+
if (!n.ok) return refuse(claimRaw, n.reason, keychain);
|
|
59
|
+
const claim = n.claim;
|
|
60
|
+
|
|
61
|
+
const directoryKey = directory ? directory.resolve(claim.to, ack?.at ?? null) : null;
|
|
62
|
+
const key = directoryKey ?? claim.recipientPubKey ?? recipientPubKey;
|
|
63
|
+
const binding = directoryKey ? "directory-resolved" : "claim-supplied";
|
|
64
|
+
const coverage =
|
|
65
|
+
`Instrument: recipient-signed delivery-ack under a ${binding} Ed25519 key; proves the holder of that key ` +
|
|
66
|
+
`acknowledged bytes with this contentHash — not retention, use, or (for claim-supplied keys) recipient identity.`;
|
|
67
|
+
|
|
68
|
+
if (!key) {
|
|
69
|
+
return buildAttestation({ claim, verdict: "unsupported", reason: "no-recipient-key", coverage, keychain });
|
|
70
|
+
}
|
|
71
|
+
if (ack?.kind !== ACK_KIND || ack?.v !== 1) {
|
|
72
|
+
return buildAttestation({ claim, verdict: "unsupported", reason: "malformed-ack", coverage, keychain });
|
|
73
|
+
}
|
|
74
|
+
let sigOk = false;
|
|
75
|
+
try {
|
|
76
|
+
sigOk = Keychain.verifyDetached(key, Buffer.from(jcs(ack), "utf-8"), Buffer.from(String(ackSig ?? ""), "hex"));
|
|
77
|
+
} catch (_e) { sigOk = false; }
|
|
78
|
+
if (!sigOk) {
|
|
79
|
+
return buildAttestation({ claim, verdict: "failed", reason: "ack-signature-invalid", coverage, keychain });
|
|
80
|
+
}
|
|
81
|
+
const mismatch =
|
|
82
|
+
ack.contentHash !== claim.contentHash ? "content-hash-mismatch" :
|
|
83
|
+
ack.from !== claim.from ? "from-mismatch" :
|
|
84
|
+
ack.to !== claim.to ? "to-mismatch" :
|
|
85
|
+
claim.envelopeId != null && ack.envelopeId !== claim.envelopeId ? "envelope-id-mismatch" : null;
|
|
86
|
+
if (mismatch) {
|
|
87
|
+
return buildAttestation({ claim, verdict: "failed", reason: mismatch, coverage, keychain });
|
|
88
|
+
}
|
|
89
|
+
return buildAttestation({
|
|
90
|
+
claim, verdict: "verified",
|
|
91
|
+
evidence: { ack, ackSig, recipientPubKey: key, binding },
|
|
92
|
+
coverage, keychain, binds,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Attest a message-delivery claim from a pillar Mailbox (spec §6.3).
|
|
98
|
+
* `source` picks the instrument: "inbox" (recipient's own mailbox — proves
|
|
99
|
+
* the envelope was recorded as received) or "outbox-ack" (sender's mailbox
|
|
100
|
+
* — proves the sender HOLDS a delivery acknowledgment). Different facts;
|
|
101
|
+
* coverage names which. The mailbox passed in must belong to the named
|
|
102
|
+
* party or the instrument is blind.
|
|
103
|
+
*/
|
|
104
|
+
export function attestMessageDelivery({ claim: claimRaw, mailbox, mailboxOwner, source = "inbox", keychain = null }) {
|
|
105
|
+
const n = normalizeClaim({ ...claimRaw, kind: "message-delivery" });
|
|
106
|
+
if (!n.ok) return refuse(claimRaw, n.reason, keychain);
|
|
107
|
+
const claim = n.claim;
|
|
108
|
+
|
|
109
|
+
const coverageInbox =
|
|
110
|
+
"Instrument: the RECIPIENT's chain-hashed inbox log; sees only envelopes this mailbox recorded as received. " +
|
|
111
|
+
"Proves recording at the recipient, not that the sender's copy matches.";
|
|
112
|
+
const coverageOutbox =
|
|
113
|
+
"Instrument: the SENDER's chain-hashed outbox log with delivery ack; proves the sender holds an acknowledgment, " +
|
|
114
|
+
"not what the recipient's mailbox contains.";
|
|
115
|
+
|
|
116
|
+
if (source === "inbox") {
|
|
117
|
+
if (mailboxOwner !== claim.to) {
|
|
118
|
+
return buildAttestation({
|
|
119
|
+
claim, verdict: "instrument-blind", reason: "mailbox-not-recipients",
|
|
120
|
+
coverage: coverageInbox + " This mailbox does not belong to the claimed recipient, so it is structurally blind to the claim.",
|
|
121
|
+
keychain,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
// Full sight within the recipient's own inbox: absent = failed (§10.6 satisfied).
|
|
125
|
+
const row = mailbox.getInbound(claim.envelopeId);
|
|
126
|
+
if (!row) {
|
|
127
|
+
return buildAttestation({ claim, verdict: "failed", reason: "not-in-recipient-inbox", coverage: coverageInbox, keychain });
|
|
128
|
+
}
|
|
129
|
+
if (row.sender !== claim.from) {
|
|
130
|
+
return buildAttestation({ claim, verdict: "failed", reason: "sender-mismatch", coverage: coverageInbox, keychain });
|
|
131
|
+
}
|
|
132
|
+
if (claim.envelopeSha256 != null && row.envelope_sha256 !== claim.envelopeSha256) {
|
|
133
|
+
return buildAttestation({ claim, verdict: "failed", reason: "envelope-hash-mismatch", coverage: coverageInbox, keychain });
|
|
134
|
+
}
|
|
135
|
+
return buildAttestation({
|
|
136
|
+
claim, verdict: "verified",
|
|
137
|
+
evidence: { source: "inbox", seq: row.seq, envelopeSha256: row.envelope_sha256, chainHash: row.chain_hash, receivedAt: row.received_at },
|
|
138
|
+
coverage: coverageInbox, keychain,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (source === "outbox-ack") {
|
|
143
|
+
if (mailboxOwner !== claim.from) {
|
|
144
|
+
return buildAttestation({
|
|
145
|
+
claim, verdict: "instrument-blind", reason: "mailbox-not-senders",
|
|
146
|
+
coverage: coverageOutbox + " This mailbox does not belong to the claimed sender, so it is structurally blind to the claim.",
|
|
147
|
+
keychain,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
const row = mailbox.getOutbound(claim.envelopeId);
|
|
151
|
+
if (!row) {
|
|
152
|
+
return buildAttestation({ claim, verdict: "failed", reason: "not-in-sender-outbox", coverage: coverageOutbox, keychain });
|
|
153
|
+
}
|
|
154
|
+
if (row.recipient !== claim.to) {
|
|
155
|
+
return buildAttestation({ claim, verdict: "failed", reason: "recipient-mismatch", coverage: coverageOutbox, keychain });
|
|
156
|
+
}
|
|
157
|
+
if (claim.envelopeSha256 != null && row.envelope_sha256 !== claim.envelopeSha256) {
|
|
158
|
+
return buildAttestation({ claim, verdict: "failed", reason: "envelope-hash-mismatch", coverage: coverageOutbox, keychain });
|
|
159
|
+
}
|
|
160
|
+
if (row.status !== "delivered" || !row.ack_sha256) {
|
|
161
|
+
return buildAttestation({ claim, verdict: "pending", reason: "no-delivery-ack-yet", coverage: coverageOutbox, keychain });
|
|
162
|
+
}
|
|
163
|
+
return buildAttestation({
|
|
164
|
+
claim, verdict: "verified",
|
|
165
|
+
evidence: { source: "outbox-ack", seq: row.seq, envelopeSha256: row.envelope_sha256, chainHash: row.chain_hash, ackSha256: row.ack_sha256, deliveredAt: row.delivered_at },
|
|
166
|
+
coverage: coverageOutbox, keychain,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return refuse(claimRaw, `unknown-source:${String(source)}`, keychain);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Attest media availability by fetch-and-hash (spec §6.4). Point-in-time by
|
|
175
|
+
* construction; unreachable is `pending`, only a hash mismatch of fetched
|
|
176
|
+
* bytes is `failed`. `fetcher(locator) -> Buffer` is injectable for tests;
|
|
177
|
+
* default uses global fetch.
|
|
178
|
+
*/
|
|
179
|
+
export async function attestMediaAvailability({ claim: claimRaw, fetcher = null, keychain = null, maxBytes = 64 * 1024 * 1024 }) {
|
|
180
|
+
const n = normalizeClaim({ ...claimRaw, kind: "media-availability" });
|
|
181
|
+
if (!n.ok) return refuse(claimRaw, n.reason, keychain);
|
|
182
|
+
const claim = n.claim;
|
|
183
|
+
const coverage =
|
|
184
|
+
"Instrument: point-in-time fetch-and-hash of the locator; proves what the locator served AT verifiedAt to this " +
|
|
185
|
+
"verifier's vantage — not availability at any other time, place, or path.";
|
|
186
|
+
|
|
187
|
+
let bytes;
|
|
188
|
+
try {
|
|
189
|
+
if (fetcher) {
|
|
190
|
+
bytes = await fetcher(claim.locator);
|
|
191
|
+
} else {
|
|
192
|
+
const res = await fetch(claim.locator, { redirect: "follow" });
|
|
193
|
+
if (!res.ok) return buildAttestation({ claim, verdict: "pending", reason: `locator-unreachable:http-${res.status}`, coverage, keychain });
|
|
194
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
195
|
+
bytes = buf;
|
|
196
|
+
}
|
|
197
|
+
} catch (e) {
|
|
198
|
+
return buildAttestation({ claim, verdict: "pending", reason: `locator-unreachable:${String(e?.message ?? e).slice(0, 120)}`, coverage, keychain });
|
|
199
|
+
}
|
|
200
|
+
if (!Buffer.isBuffer(bytes)) bytes = Buffer.from(bytes);
|
|
201
|
+
if (bytes.length > maxBytes) {
|
|
202
|
+
return buildAttestation({ claim, verdict: "unsupported", reason: "content-exceeds-max-bytes", coverage, keychain });
|
|
203
|
+
}
|
|
204
|
+
const observedHash = contentHashOf(bytes);
|
|
205
|
+
if (observedHash !== claim.contentHash) {
|
|
206
|
+
return buildAttestation({
|
|
207
|
+
claim, verdict: "failed", reason: "content-hash-mismatch",
|
|
208
|
+
evidence: { fetchedAt: new Date().toISOString(), byteLength: bytes.length, observedHash },
|
|
209
|
+
coverage, keychain,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return buildAttestation({
|
|
213
|
+
claim, verdict: "verified",
|
|
214
|
+
evidence: { fetchedAt: new Date().toISOString(), byteLength: bytes.length, observedHash },
|
|
215
|
+
coverage, keychain,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Wrap a fiat issuer artifact (spec §6.5). `issuer-attested` at BEST, never
|
|
221
|
+
* `verified` — the verifier vouches for the artifact's presence and hash,
|
|
222
|
+
* not for settlement. artifactBytes is hashed here so the envelope pins the
|
|
223
|
+
* exact artifact the verdict rests on.
|
|
224
|
+
*/
|
|
225
|
+
export function attestFiatSettlement({ claim: claimRaw, artifactBytes, artifactKind, issuer, artifactExcerpt = null, keychain = null }) {
|
|
226
|
+
const n = normalizeClaim({ ...claimRaw, kind: "fiat-settlement" });
|
|
227
|
+
if (!n.ok) return refuse(claimRaw, n.reason, keychain);
|
|
228
|
+
const claim = n.claim;
|
|
229
|
+
const coverage =
|
|
230
|
+
"Instrument: an issuer-mediated artifact; the verifier attests the artifact's presence and hash only. " +
|
|
231
|
+
"Settlement itself is asserted by the issuer, not observed by this verifier — hence issuer-attested, never verified.";
|
|
232
|
+
if (!Buffer.isBuffer(artifactBytes) || artifactBytes.length === 0) {
|
|
233
|
+
return buildAttestation({ claim, verdict: "unsupported", reason: "no-issuer-artifact", coverage, keychain });
|
|
234
|
+
}
|
|
235
|
+
if (typeof artifactKind !== "string" || !artifactKind || typeof issuer !== "string" || !issuer) {
|
|
236
|
+
return buildAttestation({ claim, verdict: "unsupported", reason: "artifact-provenance-missing", coverage, keychain });
|
|
237
|
+
}
|
|
238
|
+
return buildAttestation({
|
|
239
|
+
claim, verdict: "issuer-attested",
|
|
240
|
+
evidence: {
|
|
241
|
+
artifactSha256: contentHashOf(artifactBytes), artifactKind, issuer,
|
|
242
|
+
...(artifactExcerpt ? { artifactExcerpt: String(artifactExcerpt).slice(0, 500) } : {}),
|
|
243
|
+
},
|
|
244
|
+
coverage, keychain,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VDA-1 adoption shims (plan Phase 5) — wrap other protocols' settlement
|
|
3
|
+
* artifacts into VDA envelopes and emit VDA ids in their native shapes.
|
|
4
|
+
* The shims TRANSLATE; they never upgrade trust: an x402 settlement wraps
|
|
5
|
+
* into a `transfer` claim verified against the CHAIN (same instrument as
|
|
6
|
+
* any transfer), and an A2A artifact / MCP tool result carries the
|
|
7
|
+
* attestation, not a re-statement of it.
|
|
8
|
+
*/
|
|
9
|
+
import { normalizeClaim } from "./core.mjs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Map an x402 payment-settlement (HTTP 402 flow) onto a VDA transfer claim.
|
|
13
|
+
* x402 settles on-chain; its receipt fields name network, tx hash, payer,
|
|
14
|
+
* payee, asset, amount. The output is a CLAIM — verification still runs
|
|
15
|
+
* through TransferVerifier against the chain, so the shim adds zero trust.
|
|
16
|
+
*
|
|
17
|
+
* settlement: { network: "eip155:<chainId>"|<chainId>, txHash, payer?,
|
|
18
|
+
* payee, amount, asset? ("native" | erc20 address) }
|
|
19
|
+
*/
|
|
20
|
+
export function claimFromX402(settlement) {
|
|
21
|
+
const s = settlement ?? {};
|
|
22
|
+
let chainId = null;
|
|
23
|
+
if (typeof s.network === "string" && s.network.startsWith("eip155:")) chainId = Number(s.network.slice(7));
|
|
24
|
+
else if (s.network != null && !Number.isNaN(Number(s.network))) chainId = Number(s.network);
|
|
25
|
+
const asset = s.asset == null || s.asset === "native" ? "native"
|
|
26
|
+
: String(s.asset).startsWith("erc20:") ? String(s.asset) : `erc20:${String(s.asset)}`;
|
|
27
|
+
const n = normalizeClaim({
|
|
28
|
+
kind: "transfer", rail: "evm", chainId,
|
|
29
|
+
txId: s.txHash ?? s.txId ?? null,
|
|
30
|
+
from: s.payer ?? null, to: s.payee ?? null,
|
|
31
|
+
amount: s.amount != null ? String(s.amount) : null,
|
|
32
|
+
asset,
|
|
33
|
+
});
|
|
34
|
+
if (!n.ok) return { ok: false, reason: `x402-unmappable:${n.reason}` };
|
|
35
|
+
return { ok: true, claim: n.claim };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Emit an attestation as an A2A task artifact (A2A artifacts are named parts
|
|
40
|
+
* with data payloads). The artifact carries the FULL envelope so any A2A
|
|
41
|
+
* client can verify offline; the name carries the content-addressed id.
|
|
42
|
+
*/
|
|
43
|
+
export function a2aArtifactOfAttestation(att) {
|
|
44
|
+
if (att?.kind !== "vda-attestation") throw new Error("a2a shim: not a vda-attestation");
|
|
45
|
+
return {
|
|
46
|
+
artifactId: `vda-${att.attestationId}`,
|
|
47
|
+
name: `vda-attestation:${att.attestationId}`,
|
|
48
|
+
description: `VDA-1 ${att.claim?.kind} attestation, verdict=${att.verdict}, verifier=${att.verifier ?? "unsigned"}`,
|
|
49
|
+
parts: [{ kind: "data", data: att }],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* MCP tool descriptor for a server exposing VDA verification. The handler
|
|
55
|
+
* wiring is host-specific; this is the portable contract (name, schema,
|
|
56
|
+
* annotations) so any MCP host can demand receipts the same way.
|
|
57
|
+
*/
|
|
58
|
+
export const MCP_VERIFY_TOOL = {
|
|
59
|
+
name: "vda_verify_transfer",
|
|
60
|
+
description:
|
|
61
|
+
"Verify that a transfer (native or ERC-20) actually settled on-chain, from the chain's own record. " +
|
|
62
|
+
"Returns a signed, content-addressed VDA-1 attestation verifiable offline. " +
|
|
63
|
+
"Verdicts: verified | failed | pending | instrument-blind | unsupported (fail-closed).",
|
|
64
|
+
inputSchema: {
|
|
65
|
+
type: "object",
|
|
66
|
+
required: ["chainId", "txId", "to", "amount"],
|
|
67
|
+
properties: {
|
|
68
|
+
chainId: { type: "integer", description: "EVM chain id (e.g. 137)" },
|
|
69
|
+
txId: { type: "string", description: "transaction hash" },
|
|
70
|
+
from: { type: "string", description: "expected sender (optional)" },
|
|
71
|
+
to: { type: "string", description: "expected recipient" },
|
|
72
|
+
amount: { type: "string", description: "decimal base units (wei) — string, never a number" },
|
|
73
|
+
asset: { type: "string", description: '"native" or "erc20:<tokenAddress>"' },
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
|
|
77
|
+
};
|