@uuaid/pillar 0.2.0-alpha.11 → 0.2.0-alpha.13
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/INTERACTION-CONVENTION.md +24 -0
- 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/identity/keychain.mjs +27 -10
- package/src/index.mjs +1 -1
- 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,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TransferVerifier — the VDA-1 `transfer` instrument (spec: docs/VDA-1-SPEC.md).
|
|
3
|
+
* "Did transfer X (from, to, amount, asset) really settle on rail R?" is
|
|
4
|
+
* answered from the RAIL'S OWN record (chain receipt + logs at depth), never
|
|
5
|
+
* from the claimant's story, and emitted as a signed, content-addressed
|
|
6
|
+
* VDA-1 envelope.
|
|
7
|
+
*
|
|
8
|
+
* Design carried from the uuaid/iaaso governance stack: content-addressed
|
|
9
|
+
* ids (sha256(jcs(claim))[:16]); Ed25519 over JCS (offline verification);
|
|
10
|
+
* fail-closed token registry; instrument-blind honesty (fleet SOP §4.12) —
|
|
11
|
+
* a rail structurally blind to a claim answers "instrument-blind", never a
|
|
12
|
+
* vacuous negative. Every envelope carries a mandatory instrument-coverage
|
|
13
|
+
* statement (spec §5.1).
|
|
14
|
+
*
|
|
15
|
+
* Envelopes emitted before VDA-1 (kind "transfer-attestation", claims
|
|
16
|
+
* without `kind`) remain verifiable via verifyAttestationSig/verifyEnvelope —
|
|
17
|
+
* accepted read-only, never emitted (spec §4 legacy note).
|
|
18
|
+
*/
|
|
19
|
+
import { jcs } from "../identity/jcs.mjs";
|
|
20
|
+
import { Keychain } from "../identity/keychain.mjs";
|
|
21
|
+
import { railKeyOfTerms } from "./rails/index.mjs";
|
|
22
|
+
import { normalizeClaim, buildAttestation } from "../vda/core.mjs";
|
|
23
|
+
|
|
24
|
+
export const ATTESTATION_V = 1;
|
|
25
|
+
|
|
26
|
+
// NOTE: the old 16-hex `claimId` export was deleted (RedTeam round-2 rec 5)
|
|
27
|
+
// so a truncated join key cannot be resurrected. Legacy envelope ids are
|
|
28
|
+
// handled inside vda/core.mjs (legacyClaimId, read-only branch).
|
|
29
|
+
|
|
30
|
+
/** Offline signature check for VDA-1 AND legacy envelopes. */
|
|
31
|
+
export function verifyAttestationSig(publicKeyHex, attestation) {
|
|
32
|
+
try {
|
|
33
|
+
const { sig, ...rest } = attestation;
|
|
34
|
+
if (attestation.v !== ATTESTATION_V || typeof sig !== "string") return false;
|
|
35
|
+
return Keychain.verifyDetached(publicKeyHex, Buffer.from(jcs(rest), "utf-8"), Buffer.from(sig, "hex"));
|
|
36
|
+
} catch (_e) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class TransferVerifier {
|
|
42
|
+
/**
|
|
43
|
+
* @param rails RailSet (rails may implement verifyTransfer; those
|
|
44
|
+
* that don't are instrument-blind for standalone claims)
|
|
45
|
+
* @param keychain loaded Keychain; attestations are signed by it.
|
|
46
|
+
* Omit for unsigned (local-only) verification.
|
|
47
|
+
* @param tokenRegistry optional fail-closed allowlist (chains + tokens)
|
|
48
|
+
*/
|
|
49
|
+
constructor({ rails, keychain = null, tokenRegistry = null }) {
|
|
50
|
+
if (!rails) throw new Error("TransferVerifier: rails required");
|
|
51
|
+
this.rails = rails;
|
|
52
|
+
this.keychain = keychain;
|
|
53
|
+
this.tokenRegistry = tokenRegistry;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Verify one transfer claim and return a (signed) VDA-1 attestation.
|
|
58
|
+
* claim: { rail: "evm"|"zilligon", chainId?, txId, from?, to, amount, asset? }
|
|
59
|
+
* Amounts are decimal base-unit strings (wei / Gons) — spec §2.
|
|
60
|
+
*/
|
|
61
|
+
async verify(claimRaw) {
|
|
62
|
+
const n = normalizeClaim({ ...claimRaw, kind: "transfer" });
|
|
63
|
+
const coverageRefused =
|
|
64
|
+
"Verifier refused before consulting any instrument; this attestation observes nothing about the event.";
|
|
65
|
+
if (!n.ok) {
|
|
66
|
+
const claim = normalizeClaim({ kind: "malformed", originalKind: "transfer" }).claim;
|
|
67
|
+
return buildAttestation({ claim, verdict: "unsupported", reason: n.reason, coverage: coverageRefused, keychain: this.keychain });
|
|
68
|
+
}
|
|
69
|
+
const claim = n.claim;
|
|
70
|
+
const finish = (verdict, extra = {}) => buildAttestation({ claim, verdict, keychain: this.keychain, ...extra });
|
|
71
|
+
|
|
72
|
+
const railKey = railKeyOfTerms({ v: 2, rail: claim.rail, chainId: claim.chainId });
|
|
73
|
+
const rail = railKey ? this.rails.get(railKey) : null;
|
|
74
|
+
if (!rail) return finish("unsupported", { reason: "rail-unsupported", coverage: coverageRefused });
|
|
75
|
+
|
|
76
|
+
// Fail-closed allowlist, same gate the escrow applies at the door.
|
|
77
|
+
if (this.tokenRegistry && claim.rail === "evm") {
|
|
78
|
+
if (!this.tokenRegistry.isChainApproved(claim.chainId)) {
|
|
79
|
+
return finish("unsupported", { reason: "chain-not-registered", coverage: coverageRefused });
|
|
80
|
+
}
|
|
81
|
+
if (claim.asset !== "native" && !this.tokenRegistry.isAssetApproved(claim.chainId, claim.asset)) {
|
|
82
|
+
return finish("unsupported", { reason: "token-not-registered", coverage: coverageRefused });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (typeof rail.verifyTransfer !== "function") {
|
|
87
|
+
return finish("instrument-blind", {
|
|
88
|
+
reason: "rail-cannot-attest-standalone-claims",
|
|
89
|
+
coverage:
|
|
90
|
+
"Instrument: this rail sees only its own wallet's rows and cannot observe arbitrary third-party transfers; " +
|
|
91
|
+
"this verdict is explicitly NOT evidence the transfer did not settle.",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const coverage =
|
|
96
|
+
`Instrument: the ${claim.rail} rail's own record (receipt and event logs at confirmation depth) via a single ` +
|
|
97
|
+
"RPC endpoint; full sight of the transaction hash space on that chain, subject to that endpoint's honesty — " +
|
|
98
|
+
"use a quorum of independent verifiers to bound endpoint trust.";
|
|
99
|
+
|
|
100
|
+
let res;
|
|
101
|
+
try {
|
|
102
|
+
res = await rail.verifyTransfer({ txId: claim.txId, from: claim.from, to: claim.to, amount: claim.amount, asset: claim.asset });
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return finish("unsupported", { reason: `instrument-error: ${String(e.message ?? e)}`, coverage });
|
|
105
|
+
}
|
|
106
|
+
if (res?.verified) {
|
|
107
|
+
return finish("verified", {
|
|
108
|
+
coverage,
|
|
109
|
+
// blockNumber as a string: spec §3 — quantities that can exceed 2^53
|
|
110
|
+
evidence: { blockNumber: String(res.blockNumber), depth: res.depth, from: res.from, to: res.to, amount: res.amount, asset: res.asset },
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (res?.reason === "unconfirmed") return finish("pending", { reason: "below-confirmation-depth", coverage });
|
|
114
|
+
if (res?.reason === "instrument-blind") {
|
|
115
|
+
return finish("instrument-blind", { reason: res.detail ?? "instrument-blind", coverage });
|
|
116
|
+
}
|
|
117
|
+
return finish("failed", { reason: res?.reason ?? "not-found", coverage });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zilligon ZGN rail client — the ONLY money surface of the blind escrow.
|
|
3
|
+
*
|
|
4
|
+
* VERIFIED live against zilligon.com (2026-08-21, read-only / OPTIONS /
|
|
5
|
+
* invalid-body probes — no money has ever moved):
|
|
6
|
+
* POST /api/v1/auth/token {apiKey, grantType:"api_key"}
|
|
7
|
+
* -> {success, data:{accessToken, expiresIn:900, scopes}}
|
|
8
|
+
* POST /api/v1/wallet/transfer {recipientHandle, amount} (JWT Bearer)
|
|
9
|
+
* The route EXISTS (OPTIONS -> 204 Allow: OPTIONS, POST) though it
|
|
10
|
+
* appears in NO docs. Validation, elicited with invalid bodies:
|
|
11
|
+
* recipientHandle required string; amount must be a positive number
|
|
12
|
+
* (strings rejected, non-integers NOT rejected at validation);
|
|
13
|
+
* unknown handle -> 404 RECIPIENT_NOT_FOUND; self-transfer -> 400
|
|
14
|
+
* SELF_TRANSFER; extra fields tolerated.
|
|
15
|
+
* LIVE-VERIFIED end-to-end 2026-08-21: the rewards-service sidecar is
|
|
16
|
+
* now DEPLOYED on the prod cluster (REWARDS_SERVICE_URL +
|
|
17
|
+
* INTERNAL_SERVICE_SECRET wired on the live task-def) and an
|
|
18
|
+
* owner-authorized 10-Gon transfer returned HTTP 200 with
|
|
19
|
+
* {transactionId, newBalance, recipient, amount}. transactionId is
|
|
20
|
+
* the Tip row's id, which equals BOTH wallet rows' sourceId — the
|
|
21
|
+
* response's txId is the cross-side link (verified on the live rows).
|
|
22
|
+
* Also verified live: recipient must be status "ACTIVE" or the route
|
|
23
|
+
* returns 404 RECIPIENT_NOT_FOUND — 46,162 of the agents are DORMANT
|
|
24
|
+
* and only 37 platform-wide can receive transfers at all.
|
|
25
|
+
* GET /api/token/wallet?handle=<h> public by handle, NO auth — but a
|
|
26
|
+
* browser User-Agent is REQUIRED (403 "Automated access not
|
|
27
|
+
* permitted" without one, 200 with). Last ~20 tx rows:
|
|
28
|
+
* -> {handle, reputation, wallet:{zgnBalance, zgnLocked, zgnStaked,
|
|
29
|
+
* transactions:[{type, amount, currency, status, source, date}]}}
|
|
30
|
+
*
|
|
31
|
+
* VERIFIED tx vocabulary (20 rows sampled across 3 wallets, 2026-08-21):
|
|
32
|
+
* every observed row is {type:"EARNING", currency:"GONS",
|
|
33
|
+
* status:"CONFIRMED", source:"CODE_FORGE", date:ISO}.
|
|
34
|
+
*
|
|
35
|
+
* TRANSFER ROW CONTRACT (verified LIVE in-container 2026-08-21 on the prod
|
|
36
|
+
* DB — matches services/rewards-service internal.ts:290-348): one Tip + two
|
|
37
|
+
* WalletTransaction rows in a single prisma.$transaction:
|
|
38
|
+
* sender row: type "TIP_SENT", amount NEGATIVE, fee set,
|
|
39
|
+
* receiver row: type "TIP_RECEIVED", amount net
|
|
40
|
+
* (amount - floor(amount*0.005)), fee unset -> 0,
|
|
41
|
+
* both rows: currency "GONS", sourceType "transfer" (public API projects
|
|
42
|
+
* `source: "transfer"`), sourceId = tip.id (the ONLY link between the two
|
|
43
|
+
* sides — and the id the transfer API returns as transactionId), status
|
|
44
|
+
* "CONFIRMED", balanceAfter set. A row's own Prisma cuid `id` is NOT the
|
|
45
|
+
* transactionId.
|
|
46
|
+
* The public wallet API (/api/token/wallet) historically projected ONLY
|
|
47
|
+
* {type, amount, currency, status, source, date}; an additive projection fix
|
|
48
|
+
* (Zilligon-Lead, 2026-08-21) adds {id, sourceId, fee, balanceAfter}.
|
|
49
|
+
* Consequence for the matchers:
|
|
50
|
+
* - "source" on a transfer row is the literal string "transfer", NOT a
|
|
51
|
+
* handle — source-binding is only enforced when the field actually
|
|
52
|
+
* looks like a handle ("@..."), otherwise the row stays unbound.
|
|
53
|
+
* - TIP_SENT rows carry NEGATIVE amounts — the payout matcher compares
|
|
54
|
+
* magnitudes.
|
|
55
|
+
* - A buyer's claimed txId (the transfer response's transactionId) equals
|
|
56
|
+
* row.sourceId, NOT row.id — the matchers accept a claimed txId against
|
|
57
|
+
* EITHER, bind on an explicit sourceId claim, and HARD-REJECT a row
|
|
58
|
+
* whose sourceId disagrees with the claim (no silent amount+window
|
|
59
|
+
* fallthrough). Pre-fix rows (no id/sourceId in the projection) still
|
|
60
|
+
* degrade to amount+window attestation — the documented caveat for the
|
|
61
|
+
* IAASO ratification doc.
|
|
62
|
+
*
|
|
63
|
+
* Fee (verified in source: config.ts:52 tipFeePercent 0.005 + internal.ts:288
|
|
64
|
+
* fee = floor(amount * 0.005)): every transfer below 200 Gons is FEE-FREE;
|
|
65
|
+
* the receiver nets exactly `amount`. 200 Gons is the first amount that
|
|
66
|
+
* pays 1. The escrow fee math in terms.mjs mirrors this exactly.
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
const BROWSER_UA = "Mozilla/5.0"; // the exact UA verified to pass the wallet-view gate
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Verified background-noise tx types — never a payment/payout signal.
|
|
73
|
+
* TIP_SENT / TIP_RECEIVED (the transfer-created rows) are NOT noise — they
|
|
74
|
+
* are the exchange rows the matchers hunt for.
|
|
75
|
+
*/
|
|
76
|
+
export const TX_NOISE_TYPES = ["earning"];
|
|
77
|
+
|
|
78
|
+
export class ZilligonClient {
|
|
79
|
+
constructor({ baseUrl = "https://zilligon.com", apiKey, handle }) {
|
|
80
|
+
if (!apiKey) throw new Error("ZilligonClient: apiKey required");
|
|
81
|
+
if (!handle) throw new Error("ZilligonClient: handle required (escrow's own @handle)");
|
|
82
|
+
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
83
|
+
this.apiKey = apiKey;
|
|
84
|
+
this.handle = handle;
|
|
85
|
+
this._token = null; // { accessToken, expiresAt }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async getToken() {
|
|
89
|
+
if (this._token && this._token.expiresAt > Date.now() + 30_000) return this._token.accessToken;
|
|
90
|
+
const res = await fetch(`${this.baseUrl}/api/v1/auth/token`, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: { "Content-Type": "application/json" },
|
|
93
|
+
body: JSON.stringify({ apiKey: this.apiKey, grantType: "api_key" }),
|
|
94
|
+
});
|
|
95
|
+
if (!res.ok) throw new Error(`zilligon auth/token: HTTP ${res.status}`);
|
|
96
|
+
const json = await res.json();
|
|
97
|
+
if (json.success !== true || !json.data?.accessToken) {
|
|
98
|
+
throw new Error(`zilligon auth/token rejected: ${json.error || json.message || "unknown"}`);
|
|
99
|
+
}
|
|
100
|
+
this._token = {
|
|
101
|
+
accessToken: json.data.accessToken,
|
|
102
|
+
expiresAt: Date.now() + (json.data.expiresIn ?? 900) * 1000,
|
|
103
|
+
};
|
|
104
|
+
return this._token.accessToken;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async _authed(method, path, body) {
|
|
108
|
+
const token = await this.getToken();
|
|
109
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
110
|
+
method,
|
|
111
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
112
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
113
|
+
});
|
|
114
|
+
const json = await res.json().catch(() => ({}));
|
|
115
|
+
if (!res.ok || json.success === false) {
|
|
116
|
+
throw new Error(`zilligon ${path}: HTTP ${res.status} ${json.error || json.message || ""}`.trim());
|
|
117
|
+
}
|
|
118
|
+
return json;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Transfer Gons to a handle. The body carries ONLY the verified fields
|
|
123
|
+
* {recipientHandle, amount}. Local guards mirror the rail's verified
|
|
124
|
+
* rejection set (self-transfer) plus an integer guard stricter than the
|
|
125
|
+
* rail's validation (which accepts any positive number — Gons are whole).
|
|
126
|
+
*/
|
|
127
|
+
async transfer({ recipientHandle, amount }) {
|
|
128
|
+
if (typeof amount !== "number" || !Number.isInteger(amount) || amount < 1) {
|
|
129
|
+
throw new Error(`transfer: amount must be an integer >= 1 Gons, got ${amount}`);
|
|
130
|
+
}
|
|
131
|
+
if (recipientHandle === this.handle) throw new Error("transfer: self-transfer rejected by rail");
|
|
132
|
+
return this._authed("POST", "/api/v1/wallet/transfer", { recipientHandle, amount });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Public wallet view for a handle — needs a browser User-Agent (verified). */
|
|
136
|
+
async getWallet(handle = this.handle) {
|
|
137
|
+
const res = await fetch(`${this.baseUrl}/api/token/wallet?handle=${encodeURIComponent(handle)}`, {
|
|
138
|
+
headers: { "User-Agent": BROWSER_UA },
|
|
139
|
+
});
|
|
140
|
+
if (!res.ok) throw new Error(`zilligon wallet?handle=: HTTP ${res.status}`);
|
|
141
|
+
return res.json();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Attest the buyer's payment from THIS client's wallet: poll until a
|
|
146
|
+
* matching non-noise row appears. Returns the row, or null once attempts
|
|
147
|
+
* are exhausted.
|
|
148
|
+
*/
|
|
149
|
+
async findPayment({ buyerHandle, expectedAmount, txId, sourceId, since, attempts = 6, delayMs = 5000 }) {
|
|
150
|
+
for (let i = 0; i < attempts; i++) {
|
|
151
|
+
let view;
|
|
152
|
+
try {
|
|
153
|
+
view = await this.getWallet(this.handle);
|
|
154
|
+
} catch (_e) {
|
|
155
|
+
view = null; // transient rail hiccup — retry
|
|
156
|
+
}
|
|
157
|
+
const hit = view ? findPaymentInWallet(view, { buyerHandle, expectedAmount, txId, sourceId, since }) : null;
|
|
158
|
+
if (hit) return hit;
|
|
159
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Extract a transaction id from any shape the rail might return. */
|
|
166
|
+
export function txIdOf(tx) {
|
|
167
|
+
return tx?.txId ?? tx?.id ?? tx?.transactionId ?? tx?._id ?? null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const asNum = (v) => {
|
|
171
|
+
const n = Number(v);
|
|
172
|
+
return Number.isFinite(n) ? n : null;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Handle-like source/destination fields ("@handle") bind a row to a
|
|
177
|
+
* counterparty. Transfer rows carry the literal type string ("transfer") in
|
|
178
|
+
* `source` — NOT a handle — so binding must only fire on handle-shaped values.
|
|
179
|
+
*/
|
|
180
|
+
const looksLikeHandle = (s) => typeof s === "string" && s.startsWith("@");
|
|
181
|
+
|
|
182
|
+
/** A row is a candidate payment/payout iff its type is not verified noise. */
|
|
183
|
+
function isExchangeRow(tx) {
|
|
184
|
+
const t = String(tx?.type ?? "").toLowerCase();
|
|
185
|
+
if (!t) return true; // unknown vocabulary — can't classify as noise
|
|
186
|
+
return !TX_NOISE_TYPES.includes(t);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** ISO-string window check; rows that can't be compared are NOT filtered. */
|
|
190
|
+
function afterSince(dateStr, sinceStr) {
|
|
191
|
+
const d = Date.parse(dateStr);
|
|
192
|
+
const s = Date.parse(sinceStr);
|
|
193
|
+
if (Number.isNaN(d) || Number.isNaN(s)) return true;
|
|
194
|
+
return d >= s;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Pure matcher — shared by the live client and the integration-test mock so
|
|
199
|
+
* the mock exercises the SAME attestation logic. A payment hit needs:
|
|
200
|
+
* 1. a non-noise row (only EARNING rows are proven noise; transfer rows
|
|
201
|
+
* are TIP_SENT/TIP_RECEIVED — non-noise, see header),
|
|
202
|
+
* 2. amount === expectedAmount Gons exactly,
|
|
203
|
+
* 3. date >= since, when a window is given,
|
|
204
|
+
* 4. counterparty binding, strongest first (verified live 2026-08-21: the
|
|
205
|
+
* transfer response's transactionId equals BOTH rows' sourceId, NOT a
|
|
206
|
+
* row's own id — so a claimed txId matches against EITHER):
|
|
207
|
+
* a. claimed txId === row.id OR row.sourceId -> hit;
|
|
208
|
+
* b. explicit sourceId claim === row.sourceId -> hit;
|
|
209
|
+
* c. row.sourceId present but DISAGREEING with a claim -> the row is
|
|
210
|
+
* someone else's transfer: hard-skip, never amount+window
|
|
211
|
+
* fallthrough;
|
|
212
|
+
* 5. and — when the row carries a handle-SHAPED source field ("@...") and
|
|
213
|
+
* buyerHandle is known — that source equals the buyer. A non-handle
|
|
214
|
+
* source (the literal "transfer" on rail rows) with no id/sourceId
|
|
215
|
+
* (pre-projection-fix rows) leaves the row UNBOUND: it attests by
|
|
216
|
+
* amount+window alone.
|
|
217
|
+
*/
|
|
218
|
+
export function findPaymentInWallet(walletView, { buyerHandle, expectedAmount, txId, sourceId, since }) {
|
|
219
|
+
const wallet = walletView?.wallet ?? walletView ?? {};
|
|
220
|
+
const txs = Array.isArray(wallet.transactions) ? wallet.transactions : [];
|
|
221
|
+
for (const tx of txs) {
|
|
222
|
+
if (typeof tx !== "object" || tx === null) continue;
|
|
223
|
+
if (!isExchangeRow(tx)) continue;
|
|
224
|
+
if (asNum(tx.amount) !== expectedAmount) continue;
|
|
225
|
+
if (since && !afterSince(tx.date, since)) continue;
|
|
226
|
+
const source = tx.source ?? tx.from ?? tx.senderHandle ?? null;
|
|
227
|
+
const id = txIdOf(tx);
|
|
228
|
+
const rowSourceId = tx.sourceId ?? null;
|
|
229
|
+
// (a) claimed txId matches row id or row sourceId — the strongest link
|
|
230
|
+
// (the transfer API's transactionId IS the rows' sourceId).
|
|
231
|
+
if (txId && ((id && String(id) === String(txId)) || (rowSourceId && String(rowSourceId) === String(txId)))) return tx;
|
|
232
|
+
// (b) explicit sourceId claim matches the row's.
|
|
233
|
+
if (sourceId && rowSourceId && String(rowSourceId) === String(sourceId)) return tx;
|
|
234
|
+
// (c) the row carries a sourceId that conflicts with our claims — it is
|
|
235
|
+
// someone else's transfer: hard-skip.
|
|
236
|
+
if (rowSourceId && ((txId && String(rowSourceId) !== String(txId)) || (sourceId && String(rowSourceId) !== String(sourceId)))) continue;
|
|
237
|
+
if (buyerHandle && looksLikeHandle(source) && source !== buyerHandle) continue; // bound and mismatched
|
|
238
|
+
return tx;
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Matcher for the escrow's own payout: a non-noise row of the exact amount
|
|
245
|
+
* (magnitude — the escrow's sender-side rows are TIP_SENT with NEGATIVE
|
|
246
|
+
* amounts, see header). dest-binding only fires on handle-shaped fields;
|
|
247
|
+
* an explicit sourceId claim binds hard (match -> hit, disagreement -> skip).
|
|
248
|
+
*/
|
|
249
|
+
export function findPayoutInWallet(walletView, { sellerHandle, amount, sourceId, since }) {
|
|
250
|
+
const wallet = walletView?.wallet ?? walletView ?? {};
|
|
251
|
+
const txs = Array.isArray(wallet.transactions) ? wallet.transactions : [];
|
|
252
|
+
for (const tx of txs) {
|
|
253
|
+
if (typeof tx !== "object" || tx === null) continue;
|
|
254
|
+
if (!isExchangeRow(tx)) continue;
|
|
255
|
+
const mag = asNum(tx.amount);
|
|
256
|
+
if (mag === null || Math.abs(mag) !== amount) continue; // TIP_SENT rows are negative (see header)
|
|
257
|
+
if (since && !afterSince(tx.date, since)) continue;
|
|
258
|
+
const rowSourceId = tx.sourceId ?? null;
|
|
259
|
+
if (sourceId && rowSourceId && String(rowSourceId) === String(sourceId)) return tx;
|
|
260
|
+
if (sourceId && rowSourceId && String(rowSourceId) !== String(sourceId)) continue; // hard disagreement — someone else's transfer
|
|
261
|
+
const dest = tx.destination ?? tx.to ?? tx.recipientHandle ?? null;
|
|
262
|
+
if (sellerHandle && looksLikeHandle(dest) && dest !== sellerHandle) continue; // bound and mismatched
|
|
263
|
+
return tx;
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
@@ -15,6 +15,22 @@ import { randomBytes, createHash, generateKeyPairSync, sign, verify, createPubli
|
|
|
15
15
|
|
|
16
16
|
const KEYCHAIN_VERSION = 1;
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Derive a stable, publishable local id from a raw Ed25519 public key. This
|
|
20
|
+
* makes the UUAID self-authenticating: given the UUAID, anyone can
|
|
21
|
+
* independently check that the public key hashes to the expected local-id.
|
|
22
|
+
*
|
|
23
|
+
* Public because consumers verifying a UUAID against a public key need it
|
|
24
|
+
* without reaching into `Keychain._localIdFromKey`.
|
|
25
|
+
*/
|
|
26
|
+
export function localIdFromKey(rawPub) {
|
|
27
|
+
const h = createHash("sha256").update(rawPub).digest();
|
|
28
|
+
// First 16 bytes -> UUID-style dash format for readability.
|
|
29
|
+
const b = h.slice(0, 16);
|
|
30
|
+
const hex = Buffer.from(b).toString("hex");
|
|
31
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
export class Keychain {
|
|
19
35
|
constructor(path) {
|
|
20
36
|
this.path = path;
|
|
@@ -48,16 +64,17 @@ export class Keychain {
|
|
|
48
64
|
};
|
|
49
65
|
}
|
|
50
66
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Derive the local-id from a raw Ed25519 public key.
|
|
69
|
+
*
|
|
70
|
+
* Kept on the class for the call sites that already reach for it; the
|
|
71
|
+
* implementation is the module-level `localIdFromKey`, which is the public
|
|
72
|
+
* export.
|
|
73
|
+
*/
|
|
74
|
+
static localIdFromKey = localIdFromKey;
|
|
75
|
+
|
|
76
|
+
/** @deprecated Alias kept for compatibility — use `localIdFromKey`. */
|
|
77
|
+
static _localIdFromKey = localIdFromKey;
|
|
61
78
|
|
|
62
79
|
/**
|
|
63
80
|
* Load an identity from disk.
|
package/src/index.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Entry point. Not intended for import in browsers today — browser build
|
|
8
8
|
* lives at @uuaid/pillar-browser (later).
|
|
9
9
|
*/
|
|
10
|
-
export { Keychain } from "./identity/keychain.mjs";
|
|
10
|
+
export { Keychain, localIdFromKey } from "./identity/keychain.mjs";
|
|
11
11
|
export { jcs } from "./identity/jcs.mjs";
|
|
12
12
|
export { seal, open, decrypt, envelopeSha, keychainSeed, ENVELOPE_VERSION, E2E_ALG } from "./net/envelope.mjs";
|
|
13
13
|
export { sealPayload, openPayload, edPubToX25519, edSeedToX25519 } from "./crypto/e2e.mjs";
|
package/src/mailbox/mailbox.mjs
CHANGED
|
@@ -151,6 +151,16 @@ export class Mailbox {
|
|
|
151
151
|
return !!this.db.prepare("select 1 from outbox where envelope_id = ?").get(envelopeId);
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/** Full outbox row by envelope id (VDA message-delivery instrument). */
|
|
155
|
+
getOutbound(envelopeId) {
|
|
156
|
+
return this.db.prepare("select * from outbox where envelope_id = ?").get(envelopeId) ?? null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Full inbox row by envelope id (VDA message-delivery instrument). */
|
|
160
|
+
getInbound(envelopeId) {
|
|
161
|
+
return this.db.prepare("select * from inbox where envelope_id = ?").get(envelopeId) ?? null;
|
|
162
|
+
}
|
|
163
|
+
|
|
154
164
|
markOutboundDelivered(envelopeId, ackSha256) {
|
|
155
165
|
this.db.prepare("update outbox set status = 'delivered', delivered_at = ?, ack_sha256 = ? where envelope_id = ?")
|
|
156
166
|
.run(new Date().toISOString(), ackSha256 ?? null, envelopeId);
|
|
@@ -97,6 +97,8 @@ export class CarrierClient {
|
|
|
97
97
|
/**
|
|
98
98
|
* Long-poll every carrier in parallel forever, invoking onEnvelope for each
|
|
99
99
|
* new envelope. Tracks per-carrier cursor. Returns a stop() function.
|
|
100
|
+
* onError(error, { carrier }) — the carrier context is passed so callers
|
|
101
|
+
* can attribute failures per carrier in metrics.
|
|
100
102
|
*/
|
|
101
103
|
startPolling({ onEnvelope, cursors = {}, waitS = 25, onError }) {
|
|
102
104
|
this._stopPolling = false;
|
|
@@ -108,10 +110,10 @@ export class CarrierClient {
|
|
|
108
110
|
for (const { seq, envelope } of r.envelopes) {
|
|
109
111
|
state.cursors[base] = Math.max(state.cursors[base] ?? 0, seq);
|
|
110
112
|
try { await onEnvelope(envelope, { carrier: base, seq }); }
|
|
111
|
-
catch (e) { onError?.(new Error(`onEnvelope threw: ${e.message}`)); }
|
|
113
|
+
catch (e) { onError?.(new Error(`onEnvelope threw: ${e.message}`), { carrier: base }); }
|
|
112
114
|
}
|
|
113
115
|
} catch (e) {
|
|
114
|
-
onError?.(e);
|
|
116
|
+
onError?.(e, { carrier: base });
|
|
115
117
|
// Back off on error so a dead carrier doesn't spin the CPU.
|
|
116
118
|
await new Promise(r2 => setTimeout(r2, 2000));
|
|
117
119
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observability — metrics/analytics for the Pillar communication system.
|
|
3
|
+
*
|
|
4
|
+
* Zero-dependency, in-memory counters / gauges / histograms with an optional
|
|
5
|
+
* append-only JSONL sink (same pattern as the escrow journal). Every Pillar
|
|
6
|
+
* and EscrowService owns one instance; when none is passed in, a default is
|
|
7
|
+
* created internally so instrumentation sites never null-check.
|
|
8
|
+
*
|
|
9
|
+
* inc("sent.via.carrier") counters — labels compose into dotted
|
|
10
|
+
* keys at the CALL SITE, e.g.
|
|
11
|
+
* `inc(\`received.kind.${kind}\`)`
|
|
12
|
+
* setGauge("escrow.offers.open", 3) gauges — last write wins
|
|
13
|
+
* observe("send.latencyMs", ms) histograms — fixed latency buckets
|
|
14
|
+
*
|
|
15
|
+
* Sink: journal({event, ...}) appends an event line; flush() appends a full
|
|
16
|
+
* state snapshot. An external analytics pipeline can tail the file without
|
|
17
|
+
* a live process. summary() renders the one-glance human view.
|
|
18
|
+
*
|
|
19
|
+
* Keys are sanitized (lowercased, non-alphanumerics folded to "-") so
|
|
20
|
+
* attacker-influenced strings (envelope kinds, error reasons) can never
|
|
21
|
+
* fabricate keys or escape the dotted namespace.
|
|
22
|
+
*/
|
|
23
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
24
|
+
import { dirname } from "node:path";
|
|
25
|
+
|
|
26
|
+
// Fixed latency buckets (ms) — covers fast local sends (<10 ms) up to slow
|
|
27
|
+
// carrier round-trips (>10 s). Values above the top bucket still count into
|
|
28
|
+
// n/sum/max; percentiles interpolate within the top bucket's upper bound.
|
|
29
|
+
export const LATENCY_BUCKETS = [10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000];
|
|
30
|
+
|
|
31
|
+
const MAX_KEY = 64;
|
|
32
|
+
|
|
33
|
+
export function slugKey(key) {
|
|
34
|
+
return String(key)
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
.replace(/[^a-z0-9.-]+/g, "-")
|
|
37
|
+
.replace(/^-+|-+$/g, "") // a trailing "!!!" run must not leave a dangling dash
|
|
38
|
+
.slice(0, MAX_KEY);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function newHistogram() {
|
|
42
|
+
return { n: 0, sum: 0, min: Infinity, max: -Infinity, buckets: new Array(LATENCY_BUCKETS.length).fill(0) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function percentile(h, p) {
|
|
46
|
+
if (h.n === 0) return 0;
|
|
47
|
+
const target = h.n * p;
|
|
48
|
+
let acc = 0;
|
|
49
|
+
let lower = 0;
|
|
50
|
+
for (let i = 0; i < LATENCY_BUCKETS.length; i++) {
|
|
51
|
+
acc += h.buckets[i];
|
|
52
|
+
if (acc >= target) {
|
|
53
|
+
const upper = LATENCY_BUCKETS[i];
|
|
54
|
+
const inBucket = h.buckets[i];
|
|
55
|
+
const frac = inBucket === 0 ? 0 : (target - (acc - inBucket)) / inBucket;
|
|
56
|
+
return Math.round(lower + frac * (upper - lower));
|
|
57
|
+
}
|
|
58
|
+
lower = LATENCY_BUCKETS[i];
|
|
59
|
+
}
|
|
60
|
+
return h.max;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class Observability {
|
|
64
|
+
constructor({ sinkPath = null } = {}) {
|
|
65
|
+
this.sinkPath = sinkPath;
|
|
66
|
+
this.counters = new Map();
|
|
67
|
+
this.gauges = new Map();
|
|
68
|
+
this.histograms = new Map();
|
|
69
|
+
this.startedAt = new Date().toISOString();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* ------------------------------------------------ write paths -------- */
|
|
73
|
+
|
|
74
|
+
inc(key, n = 1) {
|
|
75
|
+
const k = slugKey(key);
|
|
76
|
+
this.counters.set(k, (this.counters.get(k) ?? 0) + n);
|
|
77
|
+
return this.counters.get(k);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
setGauge(key, value) {
|
|
81
|
+
this.gauges.set(slugKey(key), value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
observe(key, ms) {
|
|
85
|
+
const k = slugKey(key);
|
|
86
|
+
let h = this.histograms.get(k);
|
|
87
|
+
if (!h) { h = newHistogram(); this.histograms.set(k, h); }
|
|
88
|
+
const v = Number(ms);
|
|
89
|
+
if (!Number.isFinite(v)) return;
|
|
90
|
+
h.n += 1;
|
|
91
|
+
h.sum += v;
|
|
92
|
+
if (v < h.min) h.min = v;
|
|
93
|
+
if (v > h.max) h.max = v;
|
|
94
|
+
const idx = LATENCY_BUCKETS.findIndex((b) => v <= b);
|
|
95
|
+
h.buckets[idx < 0 ? LATENCY_BUCKETS.length - 1 : idx] += 1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Append an event line to the sink (no-op without a sinkPath). */
|
|
99
|
+
journal({ event, ...fields }) {
|
|
100
|
+
if (!this.sinkPath) return;
|
|
101
|
+
mkdirSync(dirname(this.sinkPath), { recursive: true });
|
|
102
|
+
appendFileSync(this.sinkPath, JSON.stringify({ at: new Date().toISOString(), event, ...fields }) + "\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Append a full state snapshot line to the sink (no-op without a sinkPath). */
|
|
106
|
+
flush() {
|
|
107
|
+
if (!this.sinkPath) return;
|
|
108
|
+
mkdirSync(dirname(this.sinkPath), { recursive: true });
|
|
109
|
+
appendFileSync(this.sinkPath, JSON.stringify({ type: "snapshot", ...this.snapshot() }) + "\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* ------------------------------------------------ read paths --------- */
|
|
113
|
+
|
|
114
|
+
snapshot() {
|
|
115
|
+
const histograms = {};
|
|
116
|
+
for (const [k, h] of this.histograms) {
|
|
117
|
+
histograms[k] = { n: h.n, sum: h.sum, min: h.min, max: h.max, p50: percentile(h, 0.5), p95: percentile(h, 0.95) };
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
at: new Date().toISOString(),
|
|
121
|
+
startedAt: this.startedAt,
|
|
122
|
+
counters: Object.fromEntries(this.counters),
|
|
123
|
+
gauges: Object.fromEntries(this.gauges),
|
|
124
|
+
histograms,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** One-glance human view for CLI / dashboards. */
|
|
129
|
+
summary() {
|
|
130
|
+
const s = this.snapshot();
|
|
131
|
+
const lines = [
|
|
132
|
+
`pillar metrics (since ${s.startedAt}) — ${this.counters.size} counters, ${this.gauges.size} gauges, ${this.histograms.size} histograms`,
|
|
133
|
+
];
|
|
134
|
+
for (const [k, v] of Object.entries(s.counters).sort()) lines.push(` ${k.padEnd(48)} ${v}`);
|
|
135
|
+
for (const [k, v] of Object.entries(s.gauges).sort()) lines.push(` ${k.padEnd(48)} = ${v}`);
|
|
136
|
+
for (const [k, h] of Object.entries(s.histograms).sort()) {
|
|
137
|
+
lines.push(` ${k.padEnd(48)} n=${h.n} min=${h.min}ms p50=${h.p50}ms p95=${h.p95}ms max=${h.max}ms`);
|
|
138
|
+
}
|
|
139
|
+
return lines.join("\n");
|
|
140
|
+
}
|
|
141
|
+
}
|