agentic-wallet-mcp 0.11.0 → 0.12.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/CHANGELOG.md +44 -0
- package/README.md +5 -2
- package/dist/server-bundle.cjs +797 -37
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
8
8
|
> Entries for 0.5.0 and earlier were reconstructed from commit history when this file was
|
|
9
9
|
> introduced in 0.6.0, so they summarise each release rather than being exhaustive.
|
|
10
10
|
|
|
11
|
+
## [0.12.0] — 22 September 2026
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **Three free policy read tools**: `get_policy_template_schema` (which spending rules a template
|
|
16
|
+
allows), `get_my_policy` (the policies this owner has deployed on chain) and `policy_preflight`
|
|
17
|
+
(is a draft policy valid, and does it MEAN what the user thinks). All read-only: they sign
|
|
18
|
+
nothing, spend nothing and deploy nothing. Each reports a three-state result, so "this network has
|
|
19
|
+
no policy system" is never reported as "you have no policy".
|
|
20
|
+
- **`clear_stuck_payment_receipt`**: the supported way to discard a Verified AI Birthcert payment
|
|
21
|
+
receipt the wallet is holding and refusing to pay past — previously only possible by deleting a
|
|
22
|
+
file on the server, which a hosted subscriber cannot do. Two steps by construction: the first call
|
|
23
|
+
clears nothing and returns the receipt id, and clearing requires echoing that exact id back.
|
|
24
|
+
- **`request_ai_birthcert_verification` gains `discardStuckReceiptAndPayFresh`**: discard a stuck
|
|
25
|
+
receipt and pay again in one call. Takes the receipt id, never a boolean, so a receipt cannot be
|
|
26
|
+
discarded that was not first shown to the user. Refuses a mismatched id, a receipt belonging to a
|
|
27
|
+
live session, and combination with `dryRun` — spending nothing in each case.
|
|
28
|
+
- **`SETTLEMENT_STUCK_AFTER_MS`** (default `86400000`, 24h): how long an unconfirmed settlement may
|
|
29
|
+
stay unresolved before the wallet stops calling it "still settling" and calls it permanently stuck.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- **`check_ai_birthcert_verification` now advances a queued settlement** instead of only reporting
|
|
34
|
+
it. It replays the saved receipt — never a new payment — and returns the live session once it
|
|
35
|
+
settles, so "check back in a few minutes" genuinely progresses the flow.
|
|
36
|
+
- **A queued settlement no longer blocks the tool call for ~20 minutes.** It returns within a bounded
|
|
37
|
+
wall-clock budget (`SETTLEMENT_WAIT_BUDGET_MS`, default 90s) as
|
|
38
|
+
`{ settlementPending: true, paymentReceipt, message }` — the payment succeeded and is in flight,
|
|
39
|
+
which is not an error.
|
|
40
|
+
- **Settlement messages lead with their verdict** (`PAYMENT SENT` / `OUTCOME UNKNOWN` / `RECEIPT VOID`)
|
|
41
|
+
and carry `paymentReceipt` as its own field, so an assistant summarising the result cannot turn
|
|
42
|
+
"we do not know yet" into "it failed".
|
|
43
|
+
- **The three settlement verdicts are now told apart.** A receipt the service declares finished is
|
|
44
|
+
terminal and reported as `status: "receipt_void"`; an unresolved one is judged by the receipt's own
|
|
45
|
+
age — recent means "still settling, check back", older than `SETTLEMENT_STUCK_AFTER_MS` means
|
|
46
|
+
"this is not coming back". None of them claims the fee was refunded: a settlement can expire after
|
|
47
|
+
the money has already moved, so the receipt id is always handed back for support.
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- A stuck receipt no longer dead-ends. The wallet still refuses to buy a new credential while one is
|
|
52
|
+
held — that refusal is what prevents a second charge — but it now names the way out and states
|
|
53
|
+
plainly that starting over costs the fee a second time.
|
|
54
|
+
|
|
11
55
|
## [0.11.0] — 17 September 2026
|
|
12
56
|
|
|
13
57
|
### Added
|
package/README.md
CHANGED
|
@@ -28,8 +28,9 @@ VC issuance → identity proof → pay-per-use).
|
|
|
28
28
|
| `create_holder_account` | Onboarding: mint an HSM account (the MCP already auto-creates one at startup if `ZETRIX_ADDRESS` is omitted — see Environment below). Always checks for an existing account first — if one is active for this session, returns `{ alreadyExists: true, existing }` without creating anything; pass `confirmNew: true` (after asking the user) to mint a new one anyway | `{ password, label?, purpose?, confirmNew? }` | `{ created, alreadyExists, existing?, zetrixAddress?, holderDid?, publicKeyHex?, message }` |
|
|
29
29
|
| `get_template_schema` | **Free** read of a VC template's declared attribute schema. Call before `subscribe_and_issue` to learn which attributes it requires | `{ templateId }` | `{ templateId, schema: { required, optional } }` or `{ templateId, error }` |
|
|
30
30
|
| `query_contract` | Read-only query against any Zetrix contract — call an arbitrary method and return its raw result. No signing, no state change | `{ contractAddress, method, params? }` | `{ ok: true, result }` or `{ ok: false, error }` |
|
|
31
|
-
| `request_ai_birthcert_verification` | Start a **Verified** AI Birthcert issuance session with myid (MyDigital ID owner verification) — distinct from `subscribe_and_issue`'s self-declared Basic AI Birthcert. Optional `gasPayer` (`"sponsored"` \| `"self"`) overrides the gas payer for this call only — precedence is per-call `gasPayer` > `GAS_PREFERENCE` config > `sponsored` hardcoded default. If a sponsored quote is refused before any money moved, the wallet automatically falls back to paying gas itself; it never falls back once payment is merely pending/indeterminate. `dryRun: true` quotes instead of paying: it stops after the 402 challenge, pays nothing, creates no session, and returns `{ quote }` — the asset, the amount and which side pays gas. Quote and paid request send byte-identical signed bodies | `{ agentName, agentPurpose?, evidenceAssuranceLevel?, ownerType?, ownerVerified?, gasPayer?, dryRun? }` | `{ sessionId, verificationUrl, expiresAt }
|
|
32
|
-
| `check_ai_birthcert_verification` | **Free
|
|
31
|
+
| `request_ai_birthcert_verification` | Start a **Verified** AI Birthcert issuance session with myid (MyDigital ID owner verification) — distinct from `subscribe_and_issue`'s self-declared Basic AI Birthcert. Optional `gasPayer` (`"sponsored"` \| `"self"`) overrides the gas payer for this call only — precedence is per-call `gasPayer` > `GAS_PREFERENCE` config > `sponsored` hardcoded default. If a sponsored quote is refused before any money moved, the wallet automatically falls back to paying gas itself; it never falls back once payment is merely pending/indeterminate. `dryRun: true` quotes instead of paying: it stops after the 402 challenge, pays nothing, creates no session, and returns `{ quote }` — the asset, the amount and which side pays gas. Quote and paid request send byte-identical signed bodies | `{ agentName, agentPurpose?, evidenceAssuranceLevel?, ownerType?, ownerVerified?, gasPayer?, dryRun?, discardStuckReceiptAndPayFresh? }` | `{ sessionId, verificationUrl, expiresAt }`, `{ quote: { asset, maxAmountRequired, payTo?, gasModel } }`, or — when the sponsored settlement is still clearing after ~90s — `{ settlementPending: true, paymentReceipt, message }`, which means the payment SUCCEEDED and is in progress, not that it failed. While a stuck receipt is held this tool can only REPLAY it — it never buys a new credential, which is what stops a second charge. `discardStuckReceiptAndPayFresh: "<exact receipt id>"` is the way to start over: it throws that payment away and pays the fee again, refuses a mismatched id (discarding and paying nothing), refuses a receipt that belongs to a live session, and returns `discardedPaymentReceipt` so the lost payment stays traceable |
|
|
32
|
+
| `check_ai_birthcert_verification` | **Free** (never pays). Poll the most recently requested Verified AI Birthcert session; on `status: "issued"`, also fetches, verifies, and caches the credential. While the session is still open it replays the stored `verificationUrl` alongside SSIVC's live `expiresAt` — SSIVC issues the link only once, at creation, so this store is the only place it survives. That makes *"where is my link?"* answerable without touching the paid tool. No link is returned once `status` is `"issued"`: the link is spent. If a prior payment is still clearing this tool **advances** it — it replays the saved receipt (never a new payment) and returns the live session once it settles, so "check back in a few minutes" genuinely progresses the flow | (none) | `{ status: "pending" \| "issued" \| "no_session" \| "settlement_pending" | "receipt_void", verificationUrl?, expiresAt?, vcId?, vc?, cacheError?, paymentReceipt?, outcomeUnknown?, message? }` — `settlement_pending` means a payment was made and is being followed: without `outcomeUnknown` it is queued and progressing (check again shortly); with `outcomeUnknown: true` the outcome could not be determined at all and has been unresolved longer than `SETTLEMENT_STUCK_AFTER_MS` (24h default), so it is not coming back on its own — `stuckFor` says how long, the fee was most likely already taken, and quoting `paymentReceipt` to support is the way forward. Starting over via `clear_stuck_payment_receipt` costs a second fee. `receipt_void` is terminal and different: SSIVC has ruled the receipt finished (status_code 67 expired / 68 failed), so checking again cannot help — it does NOT mean the fee was refunded, and buying the credential means paying again |
|
|
33
|
+
| `clear_stuck_payment_receipt` | **Free, last resort, destructive.** Discard a stuck Verified AI Birthcert payment receipt the wallet is holding and refusing to pay past — the supported replacement for deleting `<stateDir>/ssivc-session.json` by hand, which a hosted subscriber cannot do. Two steps by construction: with no argument it clears nothing and returns the receipt id plus a warning; clearing requires echoing that exact id back as `confirmReceiptId`, and a mismatch clears nothing. If the settlement completed in between — which is what happens when you follow the advice to call `check_ai_birthcert_verification` first — the receipt now belongs to a **live, paid-for session**, and the id alone no longer clears it: the tool hands back `sessionId` and `verificationUrl` instead, and only `confirmDiscardLiveSession: true` will discard them. The discarded payment is unrecoverable | `{ confirmReceiptId?, confirmDiscardLiveSession? }` | `{ cleared, paymentReceipt?, requiresConfirmation?, sessionId?, verificationUrl?, message?, error? }` |
|
|
33
34
|
|
|
34
35
|
> **VCs are cached locally**, keyed by `templateId`, under `~/.agentic-wallet-mcp/vc-cache/`
|
|
35
36
|
> (scoped per network + holder — different identities or networks never share a cache).
|
|
@@ -183,6 +184,8 @@ Node ≥ 18 required (built-in `fetch`).
|
|
|
183
184
|
| `AI_BIRTHCERT_VERIFIED_TEMPLATE_ID` | no | The Verified AI Birthcert's on-chain `did:zid:...` template id. Auto-derived per network by default — **the mainnet default is unverified**, so override this explicitly once the mainnet template id is confirmed |
|
|
184
185
|
| `GAS_PREFERENCE` | no | `sponsored` or `self` — the default gas payer for the Verified AI Birthcert flow (`request_ai_birthcert_verification`). **Defaults to `sponsored`; any unrecognised value also falls back to `sponsored`** rather than throwing. Sponsorship only actually engages when the resource server's x402 quote offers a sponsored option (`extra.gasModel: "facilitator"`) — a server that only quotes self-pay behaves unchanged. **Sponsorship is testnet-only today**; override per-call with the tool's `gasPayer` parameter |
|
|
185
186
|
| `MAX_SETTLEMENT_ATTEMPTS` | no | Cap on retry attempts while polling a sponsored payment for settlement after SSIVC returns `202 Accepted` (queued). **Defaults to `20`; a non-positive or unparseable value also falls back to `20`** |
|
|
187
|
+
| `SETTLEMENT_WAIT_BUDGET_MS` | no | Total time `request_ai_birthcert_verification` **and** `check_ai_birthcert_verification` will each wait for a queued settlement before returning `{ settlementPending: true }` / `{ status: "settlement_pending" }` — `check_` advances the settlement too, so it can block for this long. The attempt cap alone cannot bound this, because the delay between attempts is server-supplied. At least one poll always happens, even if the budget is shorter than the first retry delay. Not capped at the top end — raising it *and* `MAX_SETTLEMENT_ATTEMPTS` together can reinstate the long blocking call this budget exists to prevent, so raise it deliberately and temporarily. **Defaults to `90000` (90s); a non-positive or unparseable value also falls back to `90000`** |
|
|
188
|
+
| `SETTLEMENT_STUCK_AFTER_MS` | no | How long a settlement SSIVC cannot confirm may stay unresolved before `check_ai_birthcert_verification` stops calling it "still settling" and calls it permanently stuck. SSIVC returns the same `status_code 69` for a settlement two minutes old and one three weeks old, so the receipt's own age is what separates *"payment sent, check back shortly"* from *"this is not coming back, the fee was most likely already taken, and starting over costs a second fee"*. This changes **only the wording**: either side of the line the wallet never pays again and never discards the receipt by itself. **Defaults to `86400000` (24h); a non-positive or unparseable value also falls back to `86400000`** |
|
|
186
189
|
|
|
187
190
|
\* sensitive — never logged, never returned in a tool result, and never a tool parameter.
|
|
188
191
|
|
package/dist/server-bundle.cjs
CHANGED
|
@@ -12539,6 +12539,7 @@ var index_exports = {};
|
|
|
12539
12539
|
__export(index_exports, {
|
|
12540
12540
|
asPayRequest: () => asPayRequest,
|
|
12541
12541
|
buildPayers: () => buildPayers,
|
|
12542
|
+
buildSettlementWiring: () => buildSettlementWiring,
|
|
12542
12543
|
buildToolList: () => buildToolList
|
|
12543
12544
|
});
|
|
12544
12545
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -12551,7 +12552,7 @@ var import_node_path7 = require("node:path");
|
|
|
12551
12552
|
// package.json
|
|
12552
12553
|
var package_default = {
|
|
12553
12554
|
name: "agentic-wallet-mcp",
|
|
12554
|
-
version: "0.
|
|
12555
|
+
version: "0.12.0",
|
|
12555
12556
|
description: "Agent-facing MCP wallet for Zetrix \u2014 orchestrates x401 identity proof, x402 payment, and MBI VC issuance",
|
|
12556
12557
|
keywords: [
|
|
12557
12558
|
"mcp",
|
|
@@ -12589,7 +12590,8 @@ var package_default = {
|
|
|
12589
12590
|
build: "tsc --noEmit && esbuild src/index.ts --bundle --platform=node --target=node18 --format=cjs --external:zetrix-sdk-nodejs --outfile=dist/server-bundle.cjs",
|
|
12590
12591
|
test: "vitest run",
|
|
12591
12592
|
"test:watch": "vitest",
|
|
12592
|
-
typecheck: "tsc --noEmit"
|
|
12593
|
+
typecheck: "tsc --noEmit",
|
|
12594
|
+
"typecheck:tests": "tsc --noEmit -p tsconfig.test.json"
|
|
12593
12595
|
},
|
|
12594
12596
|
dependencies: {
|
|
12595
12597
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
@@ -21615,6 +21617,12 @@ function deriveMbiBaseUrl(network) {
|
|
|
21615
21617
|
function deriveTemplateRegistryAddress(network) {
|
|
21616
21618
|
return network.includes("testnet") ? "ZTX3JszqPgRUx743SAp7q7zURfjvkWuH2FMEz" : "ZTX3GqJM1U6ifMPonwD4fGvrgoTKJua7b2cKX";
|
|
21617
21619
|
}
|
|
21620
|
+
function derivePolicyRegistryAddress(network) {
|
|
21621
|
+
return isTestnet(network) ? "ZTX3Z2Fgsssx5fVq5v8EnhTBh6mqxJ8FQFqnk" : void 0;
|
|
21622
|
+
}
|
|
21623
|
+
function derivePolicyTemplateAddress(network) {
|
|
21624
|
+
return isTestnet(network) ? "ZTX3WfTbuZwsLQDWe4f7mzrfULiNdDU84BLJ5" : void 0;
|
|
21625
|
+
}
|
|
21618
21626
|
function deriveSsivcBaseUrl(network) {
|
|
21619
21627
|
return network.includes("testnet") ? "https://ssivc-api-uat.myegdev.com/api" : void 0;
|
|
21620
21628
|
}
|
|
@@ -21633,6 +21641,7 @@ function defaultCredentialIssuanceCaps(network) {
|
|
|
21633
21641
|
return jmyr ? { [jmyr]: "1000000", "*": "0" } : { "*": "0" };
|
|
21634
21642
|
}
|
|
21635
21643
|
var KNOWN_NETWORKS = ["zetrix:testnet", "zetrix:mainnet"];
|
|
21644
|
+
var SETTLEMENT_WAIT_BUDGET_WARN_MS = 6e5;
|
|
21636
21645
|
function isTestnet(network) {
|
|
21637
21646
|
return network.includes("testnet");
|
|
21638
21647
|
}
|
|
@@ -21679,6 +21688,8 @@ function loadConfig(env) {
|
|
|
21679
21688
|
nodePort: opt("ZETRIX_NODE_PORT") ?? "",
|
|
21680
21689
|
templateRegistryAddress: opt("ZETRIX_TEMPLATE_REGISTRY_ADDRESS") ?? deriveTemplateRegistryAddress(network),
|
|
21681
21690
|
zidResolverBaseUrl: stripTrailingSlash(opt("ZID_RESOLVER_BASE_URL") ?? deriveZidResolverBaseUrl(network)),
|
|
21691
|
+
policyRegistryAddress: opt("POLICY_REGISTRY_ADDRESS") ?? derivePolicyRegistryAddress(network),
|
|
21692
|
+
policyTemplateAddress: opt("POLICY_TEMPLATE_ADDRESS") ?? derivePolicyTemplateAddress(network),
|
|
21682
21693
|
// Fail closed: an unset cap means "spend nothing", not "spend anything". A wallet that starts
|
|
21683
21694
|
// with no configuration at all must not be able to auto-pay a hostile x402 challenge. Raising
|
|
21684
21695
|
// it is a deliberate act.
|
|
@@ -21698,6 +21709,33 @@ function loadConfig(env) {
|
|
|
21698
21709
|
maxSettlementAttempts: (() => {
|
|
21699
21710
|
const attempts = Number(env.MAX_SETTLEMENT_ATTEMPTS);
|
|
21700
21711
|
return Number.isInteger(attempts) && attempts > 0 ? attempts : 20;
|
|
21712
|
+
})(),
|
|
21713
|
+
// APP-L02: deliberately NOT clamped to a ceiling. This is the ops escape hatch for a slow
|
|
21714
|
+
// paymaster, and a hard cap turns a tuning knob into a wall with no way around it. Setting it
|
|
21715
|
+
// very high cannot restore worse-than-before behaviour on its own either: maxSettlementAttempts
|
|
21716
|
+
// still bounds the loop independently, so the old ~20-minute block needs BOTH knobs
|
|
21717
|
+
// raised, deliberately.
|
|
21718
|
+
//
|
|
21719
|
+
// It IS warned about, though, on the same stderr channel parsePaymentCaps already uses above —
|
|
21720
|
+
// an earlier version of this comment claimed no logging channel existed, which was simply wrong.
|
|
21721
|
+
settlementWaitBudgetMs: (() => {
|
|
21722
|
+
const ms = Number(env.SETTLEMENT_WAIT_BUDGET_MS);
|
|
21723
|
+
const budget = Number.isInteger(ms) && ms > 0 ? ms : 9e4;
|
|
21724
|
+
if (budget > SETTLEMENT_WAIT_BUDGET_WARN_MS) {
|
|
21725
|
+
process.stderr.write(
|
|
21726
|
+
`agentic-wallet-mcp: SETTLEMENT_WAIT_BUDGET_MS is ${budget}ms \u2014 request_ai_birthcert_verification and check_ai_birthcert_verification can each block a caller for that long (the budget bounds both). Above ~${SETTLEMENT_WAIT_BUDGET_WARN_MS}ms this reinstates the long blocking call the 90s default exists to prevent. Intended for temporary debugging only.
|
|
21727
|
+
`
|
|
21728
|
+
);
|
|
21729
|
+
}
|
|
21730
|
+
return budget;
|
|
21731
|
+
})(),
|
|
21732
|
+
// Not clamped and not warned about: unlike the wait budget, nothing blocks on this —
|
|
21733
|
+
// it only decides which of two true statements the user is told about a receipt SSIVC will not
|
|
21734
|
+
// resolve. Setting it very low makes the wallet give up on the wording early (it still never
|
|
21735
|
+
// pays again or discards anything on its own); very high just means it keeps saying "check back".
|
|
21736
|
+
settlementStuckAfterMs: (() => {
|
|
21737
|
+
const ms = Number(env.SETTLEMENT_STUCK_AFTER_MS);
|
|
21738
|
+
return Number.isInteger(ms) && ms > 0 ? ms : 864e5;
|
|
21701
21739
|
})()
|
|
21702
21740
|
};
|
|
21703
21741
|
}
|
|
@@ -23205,6 +23243,317 @@ function uncheckable(isVerified, isFree = false) {
|
|
|
23205
23243
|
return items;
|
|
23206
23244
|
}
|
|
23207
23245
|
|
|
23246
|
+
// src/clients/policy-read-client.ts
|
|
23247
|
+
async function queryPolicy(contractAddress, method, params, query) {
|
|
23248
|
+
let response;
|
|
23249
|
+
try {
|
|
23250
|
+
response = await query({
|
|
23251
|
+
contractAddress,
|
|
23252
|
+
input: JSON.stringify({ method, params }),
|
|
23253
|
+
optType: 2
|
|
23254
|
+
});
|
|
23255
|
+
} catch (e) {
|
|
23256
|
+
return { ok: false, detail: `${method}: RPC call failed \u2014 ${e.message}` };
|
|
23257
|
+
}
|
|
23258
|
+
if (response?.errorCode !== 0) {
|
|
23259
|
+
return { ok: false, detail: `${method}: contract call failed with errorCode ${response?.errorCode}` };
|
|
23260
|
+
}
|
|
23261
|
+
const entries = response.result?.query_rets;
|
|
23262
|
+
if (!entries || entries.length === 0) {
|
|
23263
|
+
return { ok: false, detail: `${method}: no query_rets returned` };
|
|
23264
|
+
}
|
|
23265
|
+
const raw = entries[entries.length - 1]?.result?.value;
|
|
23266
|
+
if (raw === void 0) {
|
|
23267
|
+
return { ok: false, detail: `${method}: no result value in the final query_rets entry` };
|
|
23268
|
+
}
|
|
23269
|
+
try {
|
|
23270
|
+
return { ok: true, value: JSON.parse(raw) };
|
|
23271
|
+
} catch {
|
|
23272
|
+
return { ok: false, detail: `${method}: result was not JSON \u2014 ${raw}` };
|
|
23273
|
+
}
|
|
23274
|
+
}
|
|
23275
|
+
async function getPolicyContract(owner, registryAddress, query) {
|
|
23276
|
+
if (typeof owner !== "string" || owner.trim() === "") {
|
|
23277
|
+
return {
|
|
23278
|
+
error: "query_failed",
|
|
23279
|
+
detail: "owner is required: the Registry answers an absent owner with {found:false}, which cannot be told apart from an owner who genuinely has no policy"
|
|
23280
|
+
};
|
|
23281
|
+
}
|
|
23282
|
+
const raw = await queryPolicy(registryAddress, "getPolicyContract", { owner }, query);
|
|
23283
|
+
if (!raw.ok) return { error: "query_failed", detail: raw.detail };
|
|
23284
|
+
const value = raw.value;
|
|
23285
|
+
if (!value || value.found !== true) return { found: false };
|
|
23286
|
+
if (typeof value.address !== "string" || value.address === "") {
|
|
23287
|
+
return { error: "query_failed", detail: "getPolicyContract: found:true without a usable address" };
|
|
23288
|
+
}
|
|
23289
|
+
return { found: true, value: value.address };
|
|
23290
|
+
}
|
|
23291
|
+
function toTemplateRead(raw) {
|
|
23292
|
+
if (!raw.ok) return { error: "query_failed", detail: raw.detail };
|
|
23293
|
+
const value = raw.value;
|
|
23294
|
+
if (!value || value.found !== true) return { found: false };
|
|
23295
|
+
return { found: true, value };
|
|
23296
|
+
}
|
|
23297
|
+
async function getTemplateViaRegistry(publisher, policyKey, registryAddress, query) {
|
|
23298
|
+
return toTemplateRead(await queryPolicy(registryAddress, "getTemplate", { publisher, policyKey }, query));
|
|
23299
|
+
}
|
|
23300
|
+
async function getTemplateById(templateId, templateAddress, query) {
|
|
23301
|
+
return toTemplateRead(await queryPolicy(templateAddress, "getTemplateById", { templateId }, query));
|
|
23302
|
+
}
|
|
23303
|
+
function declaredVocabulary(template) {
|
|
23304
|
+
const vocab = /* @__PURE__ */ new Map();
|
|
23305
|
+
for (const entry of template.attributes ?? []) {
|
|
23306
|
+
if (typeof entry?.attributeName === "string" && entry.attributeName !== "") {
|
|
23307
|
+
vocab.set(entry.attributeName, typeof entry.attributeType === "string" ? entry.attributeType : "");
|
|
23308
|
+
}
|
|
23309
|
+
}
|
|
23310
|
+
return vocab;
|
|
23311
|
+
}
|
|
23312
|
+
var KEY_COUNT_WARNING_THRESHOLD = 50;
|
|
23313
|
+
async function listPolicyKeys(policyAddress, query) {
|
|
23314
|
+
const raw = await queryPolicy(policyAddress, "listPolicyKeys", {}, query);
|
|
23315
|
+
if (!raw.ok) return { error: "query_failed", detail: raw.detail };
|
|
23316
|
+
if (!Array.isArray(raw.value)) {
|
|
23317
|
+
return { error: "query_failed", detail: "listPolicyKeys: expected an array of key strings" };
|
|
23318
|
+
}
|
|
23319
|
+
return { found: true, value: raw.value.filter((k) => typeof k === "string") };
|
|
23320
|
+
}
|
|
23321
|
+
async function getPolicyByKey(policyAddress, policyKey, query) {
|
|
23322
|
+
const raw = await queryPolicy(policyAddress, "getPolicy", { policyKey }, query);
|
|
23323
|
+
if (!raw.ok) return { error: "query_failed", detail: raw.detail };
|
|
23324
|
+
const value = raw.value;
|
|
23325
|
+
if (!value || value.found !== true) return { found: false };
|
|
23326
|
+
return { found: true, value };
|
|
23327
|
+
}
|
|
23328
|
+
async function readOwnerPolicies(owner, registryAddress, query) {
|
|
23329
|
+
const contract = await getPolicyContract(owner, registryAddress, query);
|
|
23330
|
+
if (!("found" in contract) || contract.found !== true) {
|
|
23331
|
+
return { contract, keys: null, policies: [] };
|
|
23332
|
+
}
|
|
23333
|
+
const keys = await listPolicyKeys(contract.value, query);
|
|
23334
|
+
if (!("found" in keys) || keys.found !== true) {
|
|
23335
|
+
return { contract, keys, policies: [] };
|
|
23336
|
+
}
|
|
23337
|
+
const policies = [];
|
|
23338
|
+
for (const policyKey of keys.value) {
|
|
23339
|
+
policies.push({ policyKey, result: await getPolicyByKey(contract.value, policyKey, query) });
|
|
23340
|
+
}
|
|
23341
|
+
const warning = keys.value.length > KEY_COUNT_WARNING_THRESHOLD ? `This owner has ${keys.value.length} policy keys; reading them all costs ${keys.value.length + 2} chain calls.` : void 0;
|
|
23342
|
+
return { contract, keys, policies, ...warning ? { warning } : {} };
|
|
23343
|
+
}
|
|
23344
|
+
|
|
23345
|
+
// src/policy-window-rules.ts
|
|
23346
|
+
var WINDOW_RULES = [
|
|
23347
|
+
{
|
|
23348
|
+
cap: "cumulativeMax",
|
|
23349
|
+
window: "cumulativeWindow",
|
|
23350
|
+
outcome: "lifetime",
|
|
23351
|
+
withoutWindowMeans: 'a cap for the entire lifetime of this policy, not per period \u2014 "RM500 a month" written this way silently means "RM500 ever"'
|
|
23352
|
+
},
|
|
23353
|
+
{
|
|
23354
|
+
cap: "velocityCap",
|
|
23355
|
+
window: "velocityWindow",
|
|
23356
|
+
outcome: "denied",
|
|
23357
|
+
withoutWindowMeans: "rejected outright as VALUE_INVALID \u2014 a rate limit with no period is meaningless, so unlike a cumulative cap it is not quietly treated as lifetime"
|
|
23358
|
+
},
|
|
23359
|
+
{
|
|
23360
|
+
cap: "maxTransactionCount",
|
|
23361
|
+
window: "countWindow",
|
|
23362
|
+
outcome: "not-requested",
|
|
23363
|
+
withoutWindowMeans: "not a limit at all \u2014 without its window this dimension was not asked for, which is NOT the same as an unlimited-period count"
|
|
23364
|
+
}
|
|
23365
|
+
];
|
|
23366
|
+
function windowRuleFor(attributeName) {
|
|
23367
|
+
return WINDOW_RULES.find((rule) => rule.cap === attributeName);
|
|
23368
|
+
}
|
|
23369
|
+
var INFORMATIONAL_ATTRIBUTES = /* @__PURE__ */ new Set(["approvalPolicy", "settlementChannel"]);
|
|
23370
|
+
var QUALIFIER_ATTRIBUTES = /* @__PURE__ */ new Set([
|
|
23371
|
+
"assetScope",
|
|
23372
|
+
"unknownAttributePolicy",
|
|
23373
|
+
...WINDOW_RULES.map((rule) => rule.window)
|
|
23374
|
+
]);
|
|
23375
|
+
function isListAttribute(attributeName) {
|
|
23376
|
+
return /_list$/i.test(attributeName) || /[a-z0-9]List$/.test(attributeName);
|
|
23377
|
+
}
|
|
23378
|
+
|
|
23379
|
+
// src/orchestrator/policy-preflight.ts
|
|
23380
|
+
var NUMERIC_TYPES = /* @__PURE__ */ new Set(["uint", "uint256", "int", "number"]);
|
|
23381
|
+
var BOOLEAN_TYPES = /* @__PURE__ */ new Set(["bool", "boolean"]);
|
|
23382
|
+
function isNumericString(value) {
|
|
23383
|
+
return typeof value === "string" && /^\d+$/.test(value);
|
|
23384
|
+
}
|
|
23385
|
+
function isEmptyList(value) {
|
|
23386
|
+
const trimmed = value.trim();
|
|
23387
|
+
if (trimmed === "" || trimmed === "[]") return true;
|
|
23388
|
+
try {
|
|
23389
|
+
const parsed = JSON.parse(trimmed);
|
|
23390
|
+
return Array.isArray(parsed) && parsed.length === 0;
|
|
23391
|
+
} catch {
|
|
23392
|
+
return false;
|
|
23393
|
+
}
|
|
23394
|
+
}
|
|
23395
|
+
function baseNotChecked(network) {
|
|
23396
|
+
const items = [
|
|
23397
|
+
"Whether the policy will actually be ENFORCED. Nothing outside the policy registry currently consults the decision service, so a valid policy may gate nothing today.",
|
|
23398
|
+
"Whether the write will be accepted, and what it will cost \u2014 the deploy path is not built yet.",
|
|
23399
|
+
"Whether a decision would currently be ALLOW, STEP_UP or DENY \u2014 that needs off-chain spend state this wallet cannot read. A clean preflight is not permission to spend."
|
|
23400
|
+
];
|
|
23401
|
+
if (network && !network.includes("testnet")) {
|
|
23402
|
+
items.push(
|
|
23403
|
+
`On ${network} none of this is deployed \u2014 there is no policy registry to read, so nothing here has been checked against a real contract.`
|
|
23404
|
+
);
|
|
23405
|
+
}
|
|
23406
|
+
return items;
|
|
23407
|
+
}
|
|
23408
|
+
function unavailableResult(policyKey, reason, network) {
|
|
23409
|
+
return {
|
|
23410
|
+
policyKey,
|
|
23411
|
+
ready: false,
|
|
23412
|
+
blockers: [reason],
|
|
23413
|
+
interpretation: [],
|
|
23414
|
+
notChecked: baseNotChecked(network)
|
|
23415
|
+
};
|
|
23416
|
+
}
|
|
23417
|
+
function structuralBlockers(draft) {
|
|
23418
|
+
const blockers = [];
|
|
23419
|
+
if (typeof draft?.policyKey !== "string" || draft.policyKey === "") {
|
|
23420
|
+
blockers.push("policyKey is required \u2014 it is the key this policy would be stored under.");
|
|
23421
|
+
}
|
|
23422
|
+
if (!Array.isArray(draft?.attributes)) {
|
|
23423
|
+
blockers.push("attributes must be an array of { attributeName, attributeType, value } rules.");
|
|
23424
|
+
} else if (draft.attributes.some(
|
|
23425
|
+
(a) => !a || typeof a !== "object" || typeof a.attributeName !== "string"
|
|
23426
|
+
)) {
|
|
23427
|
+
blockers.push("every entry in attributes must be an object with a string attributeName.");
|
|
23428
|
+
}
|
|
23429
|
+
return blockers;
|
|
23430
|
+
}
|
|
23431
|
+
function checkAttribute(attribute, vocabulary, present) {
|
|
23432
|
+
const blockers = [];
|
|
23433
|
+
const interpretation = [];
|
|
23434
|
+
const notChecked = [];
|
|
23435
|
+
const { attributeName: name, value } = attribute;
|
|
23436
|
+
if (vocabulary && !vocabulary.has(name)) {
|
|
23437
|
+
blockers.push(
|
|
23438
|
+
`"${name}" is not declared by this template, so it would deploy and then enforce nothing. Declared attributes are: ${[...vocabulary.keys()].join(", ")}.`
|
|
23439
|
+
);
|
|
23440
|
+
return { blockers, interpretation, notChecked };
|
|
23441
|
+
}
|
|
23442
|
+
if (INFORMATIONAL_ATTRIBUTES.has(name)) {
|
|
23443
|
+
interpretation.push(
|
|
23444
|
+
`"${name}" is informational only and is never enforced \u2014 it records an intention and restricts nothing. Do not rely on it as a control.`
|
|
23445
|
+
);
|
|
23446
|
+
}
|
|
23447
|
+
const rule = windowRuleFor(name);
|
|
23448
|
+
if (rule && !present.has(rule.window)) {
|
|
23449
|
+
if (rule.outcome === "denied") {
|
|
23450
|
+
blockers.push(
|
|
23451
|
+
`"${name}" has no "${rule.window}", so the chain rejects it as VALUE_INVALID: ${rule.withoutWindowMeans}.`
|
|
23452
|
+
);
|
|
23453
|
+
} else {
|
|
23454
|
+
interpretation.push(`"${name}" has no "${rule.window}", so it means ${rule.withoutWindowMeans}.`);
|
|
23455
|
+
}
|
|
23456
|
+
} else if (rule) {
|
|
23457
|
+
interpretation.push(`"${name}" is measured over each "${rule.window}" period, not over the policy's lifetime.`);
|
|
23458
|
+
}
|
|
23459
|
+
if (isListAttribute(name)) {
|
|
23460
|
+
if (isEmptyList(value)) {
|
|
23461
|
+
blockers.push(
|
|
23462
|
+
`"${name}" is an empty list, which denies EVERYTHING \u2014 an empty list contains no one, so every recipient fails the check. This is stored and enforced exactly as written.`
|
|
23463
|
+
);
|
|
23464
|
+
} else {
|
|
23465
|
+
interpretation.push(`"${name}" allows ONLY the entries listed; everything absent from it is denied.`);
|
|
23466
|
+
}
|
|
23467
|
+
return { blockers, interpretation, notChecked };
|
|
23468
|
+
}
|
|
23469
|
+
const type = vocabulary?.get(name) ?? attribute.attributeType;
|
|
23470
|
+
if (NUMERIC_TYPES.has(type) && !isNumericString(value)) {
|
|
23471
|
+
blockers.push(`"${name}" is declared ${type} but its value "${value}" is not a whole number.`);
|
|
23472
|
+
} else if (BOOLEAN_TYPES.has(type) && value !== "true" && value !== "false") {
|
|
23473
|
+
blockers.push(`"${name}" is declared ${type} but its value "${value}" is not true or false.`);
|
|
23474
|
+
} else if (!NUMERIC_TYPES.has(type) && !BOOLEAN_TYPES.has(type)) {
|
|
23475
|
+
notChecked.push(
|
|
23476
|
+
`Whether "${name}"'s value suits its declared type "${type}" \u2014 that type is not one this wallet knows how to check.`
|
|
23477
|
+
);
|
|
23478
|
+
} else if (vocabulary && !INFORMATIONAL_ATTRIBUTES.has(name) && !rule) {
|
|
23479
|
+
interpretation.push(`"${name}" is limited to ${value}.`);
|
|
23480
|
+
}
|
|
23481
|
+
return { blockers, interpretation, notChecked };
|
|
23482
|
+
}
|
|
23483
|
+
function checkBlockRange(draft) {
|
|
23484
|
+
const blockers = [];
|
|
23485
|
+
if (!isNumericString(draft.validFromBlock)) {
|
|
23486
|
+
blockers.push(`validFromBlock must be a whole number written as a string, got "${draft.validFromBlock}".`);
|
|
23487
|
+
}
|
|
23488
|
+
if (!isNumericString(draft.validToBlock)) {
|
|
23489
|
+
blockers.push(`validToBlock must be a whole number written as a string, got "${draft.validToBlock}".`);
|
|
23490
|
+
}
|
|
23491
|
+
if (isNumericString(draft.validFromBlock) && isNumericString(draft.validToBlock) && // "0" means no end date, so from > to is not an inversion here.
|
|
23492
|
+
BigInt(draft.validToBlock) !== 0n && BigInt(draft.validFromBlock) > BigInt(draft.validToBlock)) {
|
|
23493
|
+
blockers.push(
|
|
23494
|
+
`validFromBlock (${draft.validFromBlock}) is after validToBlock (${draft.validToBlock}), so this policy would never be in force.`
|
|
23495
|
+
);
|
|
23496
|
+
}
|
|
23497
|
+
return blockers;
|
|
23498
|
+
}
|
|
23499
|
+
async function policyPreflight(deps, draft) {
|
|
23500
|
+
const notChecked = baseNotChecked(deps.network);
|
|
23501
|
+
const structural = structuralBlockers(draft);
|
|
23502
|
+
if (structural.length > 0) {
|
|
23503
|
+
return {
|
|
23504
|
+
policyKey: typeof draft?.policyKey === "string" ? draft.policyKey : "",
|
|
23505
|
+
ready: false,
|
|
23506
|
+
blockers: structural,
|
|
23507
|
+
interpretation: [],
|
|
23508
|
+
notChecked
|
|
23509
|
+
};
|
|
23510
|
+
}
|
|
23511
|
+
const blockers = [];
|
|
23512
|
+
const interpretation = [];
|
|
23513
|
+
const template = await deps.readTemplate(draft);
|
|
23514
|
+
if ("error" in template) {
|
|
23515
|
+
blockers.push(`Could not read the template (${template.detail}) \u2014 retry before deploying.`);
|
|
23516
|
+
} else if (template.found === false) {
|
|
23517
|
+
blockers.push(
|
|
23518
|
+
`No template found for this policy. A policy whose template does not exist declares no vocabulary, so nothing in it can be enforced.`
|
|
23519
|
+
);
|
|
23520
|
+
}
|
|
23521
|
+
const vocabulary = "found" in template && template.found === true ? declaredVocabulary(template.value) : null;
|
|
23522
|
+
const declared = vocabulary ? [...vocabulary.keys()] : void 0;
|
|
23523
|
+
if (!vocabulary) {
|
|
23524
|
+
interpretation.push(
|
|
23525
|
+
"The template could not be read, so what these rules actually mean cannot be confirmed. Treat nothing below as verified until the template resolves."
|
|
23526
|
+
);
|
|
23527
|
+
}
|
|
23528
|
+
if (draft.attributes.length === 0) {
|
|
23529
|
+
blockers.push("This policy has no attributes \u2014 it would deploy successfully and restrict nothing.");
|
|
23530
|
+
}
|
|
23531
|
+
const present = new Set(draft.attributes.map((attribute) => attribute.attributeName));
|
|
23532
|
+
for (const attribute of draft.attributes) {
|
|
23533
|
+
const found = checkAttribute(attribute, vocabulary, present);
|
|
23534
|
+
blockers.push(...found.blockers);
|
|
23535
|
+
interpretation.push(...found.interpretation);
|
|
23536
|
+
notChecked.push(...found.notChecked);
|
|
23537
|
+
}
|
|
23538
|
+
const enforceable = draft.attributes.filter(
|
|
23539
|
+
(attribute) => !QUALIFIER_ATTRIBUTES.has(attribute.attributeName) && !INFORMATIONAL_ATTRIBUTES.has(attribute.attributeName)
|
|
23540
|
+
);
|
|
23541
|
+
if (draft.attributes.length > 0 && enforceable.length === 0) {
|
|
23542
|
+
blockers.push(
|
|
23543
|
+
`This policy has no enforceable constraint \u2014 only qualifiers and informational values. It answers NO_ENFORCEABLE_CONSTRAINTS, which is a DENY, so the agent could spend nothing at all.`
|
|
23544
|
+
);
|
|
23545
|
+
}
|
|
23546
|
+
blockers.push(...checkBlockRange(draft));
|
|
23547
|
+
return {
|
|
23548
|
+
policyKey: draft.policyKey,
|
|
23549
|
+
ready: blockers.length === 0,
|
|
23550
|
+
...declared ? { declared } : {},
|
|
23551
|
+
blockers,
|
|
23552
|
+
interpretation,
|
|
23553
|
+
notChecked
|
|
23554
|
+
};
|
|
23555
|
+
}
|
|
23556
|
+
|
|
23208
23557
|
// src/mcp-tools.ts
|
|
23209
23558
|
async function loadValidCachedCredentials(cache) {
|
|
23210
23559
|
if (!cache) return [];
|
|
@@ -23363,11 +23712,86 @@ function createTools(deps) {
|
|
|
23363
23712
|
input
|
|
23364
23713
|
);
|
|
23365
23714
|
},
|
|
23715
|
+
/**
|
|
23716
|
+
* Free vocabulary lookup. Prefers the Registry proxy when given publisher+policyKey, because
|
|
23717
|
+
* the Registry holds the Template address it trusts and the proxied reply also carries
|
|
23718
|
+
* templateAttributeIds. Falls back to a direct Template call for an id-only lookup, which the
|
|
23719
|
+
* Registry does not proxy.
|
|
23720
|
+
*/
|
|
23721
|
+
async get_policy_template_schema(input = {}) {
|
|
23722
|
+
const registry2 = deps.config.policyRegistryAddress;
|
|
23723
|
+
const templateContract = deps.config.policyTemplateAddress;
|
|
23724
|
+
const hasPair = Boolean(input.publisher && input.policyKey);
|
|
23725
|
+
if (!registry2 && !templateContract) {
|
|
23726
|
+
return { error: `Policy contracts are not deployed on ${deps.config.network}.` };
|
|
23727
|
+
}
|
|
23728
|
+
if (!hasPair && !input.templateId) {
|
|
23729
|
+
return { error: "Provide either { publisher, policyKey } or { templateId }." };
|
|
23730
|
+
}
|
|
23731
|
+
const read = input.templateId ? templateContract ? await getTemplateById(input.templateId, templateContract, deps.chainQuery) : null : registry2 ? await getTemplateViaRegistry(input.publisher, input.policyKey, registry2, deps.chainQuery) : null;
|
|
23732
|
+
if (!read) {
|
|
23733
|
+
return {
|
|
23734
|
+
error: input.templateId ? `A templateId lookup needs the policy template contract, which is not configured on ${deps.config.network}.` : `A publisher + policyKey lookup needs the policy registry, which is not configured on ${deps.config.network}.`
|
|
23735
|
+
};
|
|
23736
|
+
}
|
|
23737
|
+
if ("error" in read) return { error: read.detail };
|
|
23738
|
+
if (read.found === false) return { found: false };
|
|
23739
|
+
return {
|
|
23740
|
+
found: true,
|
|
23741
|
+
declared: [...declaredVocabulary(read.value).entries()].map(([name, type]) => ({ name, type })),
|
|
23742
|
+
// Present only on the Registry route; the id-only read does not return them. Carried
|
|
23743
|
+
// through rather than dropped, since it is the stated reason to prefer that route and the
|
|
23744
|
+
// write path needs it (APP-M06).
|
|
23745
|
+
...read.value.templateAttributeIds ? { templateAttributeIds: read.value.templateAttributeIds } : {}
|
|
23746
|
+
};
|
|
23747
|
+
},
|
|
23748
|
+
/** The owner's deployed policies. 2 + N chain calls; warns above 50 keys. */
|
|
23749
|
+
async get_my_policy(input = {}) {
|
|
23750
|
+
const registry2 = deps.config.policyRegistryAddress;
|
|
23751
|
+
if (!registry2) return { error: `The policy registry is not deployed on ${deps.config.network}.` };
|
|
23752
|
+
const owner = input.owner ?? deps.config.zetrixAddress;
|
|
23753
|
+
if (!owner) return { error: "No owner address \u2014 pass one, or configure ZETRIX_ADDRESS." };
|
|
23754
|
+
return readOwnerPolicies(owner, registry2, deps.chainQuery);
|
|
23755
|
+
},
|
|
23756
|
+
/** Validate a draft policy before anything signs or pays for it. Free, signs nothing. */
|
|
23757
|
+
async policy_preflight(input) {
|
|
23758
|
+
const registry2 = deps.config.policyRegistryAddress;
|
|
23759
|
+
const templateContract = deps.config.policyTemplateAddress;
|
|
23760
|
+
const network = deps.config.network;
|
|
23761
|
+
if (!registry2 && !templateContract) {
|
|
23762
|
+
return unavailableResult(
|
|
23763
|
+
typeof input?.policyKey === "string" ? input.policyKey : "",
|
|
23764
|
+
`Policy contracts are not deployed on ${network}, so this draft could not be checked against a template.`,
|
|
23765
|
+
network
|
|
23766
|
+
);
|
|
23767
|
+
}
|
|
23768
|
+
return policyPreflight(
|
|
23769
|
+
{
|
|
23770
|
+
network,
|
|
23771
|
+
readTemplate: async (draft) => (
|
|
23772
|
+
// An explicit templateId wins. `draft.policyKey` is the key this policy would be
|
|
23773
|
+
// STORED under, which is not necessarily the template's key — preferring the pair
|
|
23774
|
+
// made an explicitly supplied templateId unreachable (APP-M04).
|
|
23775
|
+
draft.templateId && templateContract ? getTemplateById(draft.templateId, templateContract, deps.chainQuery) : draft.publisher && draft.policyKey && registry2 ? getTemplateViaRegistry(draft.publisher, draft.policyKey, registry2, deps.chainQuery) : {
|
|
23776
|
+
error: "query_failed",
|
|
23777
|
+
detail: "no template identifier supplied \u2014 pass templateId, or publisher with policyKey"
|
|
23778
|
+
}
|
|
23779
|
+
)
|
|
23780
|
+
},
|
|
23781
|
+
input
|
|
23782
|
+
);
|
|
23783
|
+
},
|
|
23366
23784
|
check_ai_birthcert_verification() {
|
|
23367
23785
|
if (!deps.verifyAiBirthcert) {
|
|
23368
23786
|
return { error: AI_BIRTHCERT_NOT_CONFIGURED_ERROR };
|
|
23369
23787
|
}
|
|
23370
23788
|
return deps.verifyAiBirthcert.check();
|
|
23789
|
+
},
|
|
23790
|
+
clear_stuck_payment_receipt(input = {}) {
|
|
23791
|
+
if (!deps.verifyAiBirthcert) {
|
|
23792
|
+
return { error: AI_BIRTHCERT_NOT_CONFIGURED_ERROR };
|
|
23793
|
+
}
|
|
23794
|
+
return deps.verifyAiBirthcert.clearStuckReceipt(input);
|
|
23371
23795
|
}
|
|
23372
23796
|
};
|
|
23373
23797
|
return tools;
|
|
@@ -23586,6 +24010,9 @@ async function buildToolContent(result, readFile6) {
|
|
|
23586
24010
|
}
|
|
23587
24011
|
|
|
23588
24012
|
// src/clients/ssivc-client.ts
|
|
24013
|
+
var SETTLEMENT_UNCONFIRMED_STATUS_CODE = "69";
|
|
24014
|
+
var SETTLEMENT_EXPIRED_STATUS_CODE = "67";
|
|
24015
|
+
var SETTLEMENT_FAILED_STATUS_CODE = "68";
|
|
23589
24016
|
var SsivcError = class extends Error {
|
|
23590
24017
|
httpStatus;
|
|
23591
24018
|
/** SSIVC's own `status_code` string (distinct from the HTTP status) — e.g. "50" signature invalid, "55" expired timestamp, "23" not found. */
|
|
@@ -23679,6 +24106,10 @@ var SsivcClient = class {
|
|
|
23679
24106
|
msg = j.errors?.length ? j.errors.join("; ") : j.message ?? j.error ?? text;
|
|
23680
24107
|
if (res.status === 402 && j.error === "payment_invalid") kind = "payment_invalid";
|
|
23681
24108
|
else if (res.status === 503 && j.error === "facilitator_unavailable") kind = "facilitator_unavailable";
|
|
24109
|
+
else if (res.status === 400 && statusCode === SETTLEMENT_UNCONFIRMED_STATUS_CODE) kind = "settlement_unconfirmed";
|
|
24110
|
+
else if (res.status === 400 && (statusCode === SETTLEMENT_EXPIRED_STATUS_CODE || statusCode === SETTLEMENT_FAILED_STATUS_CODE)) {
|
|
24111
|
+
kind = "settlement_void";
|
|
24112
|
+
}
|
|
23682
24113
|
} catch {
|
|
23683
24114
|
}
|
|
23684
24115
|
const prefix = context ? `${context} \u2014 ` : "";
|
|
@@ -23689,10 +24120,11 @@ var SsivcClient = class {
|
|
|
23689
24120
|
// src/clients/ssivc-session-store.ts
|
|
23690
24121
|
var import_promises4 = require("node:fs/promises");
|
|
23691
24122
|
var import_node_path5 = require("node:path");
|
|
24123
|
+
var isAbsentOrString = (v) => v === void 0 || typeof v === "string";
|
|
23692
24124
|
function isStoredSessionShape(value) {
|
|
23693
24125
|
if (typeof value !== "object" || value === null) return false;
|
|
23694
24126
|
const v = value;
|
|
23695
|
-
return typeof v.sessionId === "string" && typeof v.agentName === "string" && typeof v.createdAt === "string" && typeof v.verificationUrl === "string" && typeof v.paymentReceipt === "string";
|
|
24127
|
+
return typeof v.sessionId === "string" && typeof v.agentName === "string" && typeof v.createdAt === "string" && typeof v.verificationUrl === "string" && typeof v.paymentReceipt === "string" && isAbsentOrString(v.agentPurpose) && isAbsentOrString(v.evidenceAssuranceLevel) && isAbsentOrString(v.ownerType) && isAbsentOrString(v.ownerVerified);
|
|
23696
24128
|
}
|
|
23697
24129
|
function createFsSsivcSessionStore(filePath) {
|
|
23698
24130
|
return {
|
|
@@ -23710,6 +24142,9 @@ function createFsSsivcSessionStore(filePath) {
|
|
|
23710
24142
|
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
23711
24143
|
await (0, import_promises4.writeFile)(tmpPath, JSON.stringify(session, null, 2), { encoding: "utf8", mode: 384 });
|
|
23712
24144
|
await (0, import_promises4.rename)(tmpPath, filePath);
|
|
24145
|
+
},
|
|
24146
|
+
async clear() {
|
|
24147
|
+
await (0, import_promises4.rm)(filePath, { force: true });
|
|
23713
24148
|
}
|
|
23714
24149
|
};
|
|
23715
24150
|
}
|
|
@@ -23803,10 +24238,10 @@ async function getConfirmedStatus(deps, sessionId) {
|
|
|
23803
24238
|
async function decidePriorSession(deps, agentName) {
|
|
23804
24239
|
const stored = await deps.sessionStore.get();
|
|
23805
24240
|
if (!stored) return { kind: "pay_fresh" };
|
|
23806
|
-
if (stored.sessionId === "") return { kind: "replay_receipt", receipt: stored.paymentReceipt };
|
|
24241
|
+
if (stored.sessionId === "") return { kind: "replay_receipt", receipt: stored.paymentReceipt, stored };
|
|
23807
24242
|
if (stored.agentName !== agentName) {
|
|
23808
24243
|
const otherStatus = await getConfirmedStatus(deps, stored.sessionId);
|
|
23809
|
-
if (otherStatus === "gone") return { kind: "replay_receipt", receipt: stored.paymentReceipt };
|
|
24244
|
+
if (otherStatus === "gone") return { kind: "replay_receipt", receipt: stored.paymentReceipt, stored };
|
|
23810
24245
|
if (otherStatus.status === "pending") {
|
|
23811
24246
|
return {
|
|
23812
24247
|
kind: "blocked",
|
|
@@ -23814,10 +24249,10 @@ async function decidePriorSession(deps, agentName) {
|
|
|
23814
24249
|
};
|
|
23815
24250
|
}
|
|
23816
24251
|
if (otherStatus.status === "issued") return { kind: "pay_fresh" };
|
|
23817
|
-
return { kind: "replay_receipt", receipt: stored.paymentReceipt };
|
|
24252
|
+
return { kind: "replay_receipt", receipt: stored.paymentReceipt, stored };
|
|
23818
24253
|
}
|
|
23819
24254
|
const status = await getConfirmedStatus(deps, stored.sessionId);
|
|
23820
|
-
if (status === "gone") return { kind: "replay_receipt", receipt: stored.paymentReceipt };
|
|
24255
|
+
if (status === "gone") return { kind: "replay_receipt", receipt: stored.paymentReceipt, stored };
|
|
23821
24256
|
if (status.status === "pending") {
|
|
23822
24257
|
return {
|
|
23823
24258
|
kind: "still_pending",
|
|
@@ -23825,7 +24260,7 @@ async function decidePriorSession(deps, agentName) {
|
|
|
23825
24260
|
};
|
|
23826
24261
|
}
|
|
23827
24262
|
if (status.status === "issued") return { kind: "pay_fresh" };
|
|
23828
|
-
return { kind: "replay_receipt", receipt: stored.paymentReceipt };
|
|
24263
|
+
return { kind: "replay_receipt", receipt: stored.paymentReceipt, stored };
|
|
23829
24264
|
}
|
|
23830
24265
|
var requestQueue = Promise.resolve();
|
|
23831
24266
|
function withRequestLock(fn) {
|
|
@@ -23840,6 +24275,20 @@ var NoPaymentOptionsError = class extends Error {
|
|
|
23840
24275
|
};
|
|
23841
24276
|
var DEFAULT_MAX_SETTLEMENT_ATTEMPTS = 20;
|
|
23842
24277
|
var MAX_RETRY_DELAY_MS = 6e4;
|
|
24278
|
+
var DEFAULT_SETTLEMENT_WAIT_BUDGET_MS = 9e4;
|
|
24279
|
+
var DEFAULT_SETTLEMENT_STUCK_AFTER_MS = 864e5;
|
|
24280
|
+
function settlementAge(stored, now, stuckAfterMs) {
|
|
24281
|
+
const created = stored?.createdAt ? new Date(stored.createdAt).getTime() : NaN;
|
|
24282
|
+
const ageMs = Number.isNaN(created) ? 0 : Math.max(0, now.getTime() - created);
|
|
24283
|
+
const hours = ageMs / 36e5;
|
|
24284
|
+
const humanAge = hours < 1 ? `${Math.round(ageMs / 6e4)} minutes` : hours < 48 ? `${Math.round(hours)} hours` : `${Math.round(hours / 24)} days`;
|
|
24285
|
+
return { ageMs, stuck: ageMs > stuckAfterMs, humanAge };
|
|
24286
|
+
}
|
|
24287
|
+
var FALLBACK_RETRY_DELAY_MS = 15e3;
|
|
24288
|
+
function retryDelayMs(retryAfterSeconds) {
|
|
24289
|
+
if (!Number.isFinite(retryAfterSeconds) || retryAfterSeconds <= 0) return FALLBACK_RETRY_DELAY_MS;
|
|
24290
|
+
return Math.min(retryAfterSeconds * 1e3, MAX_RETRY_DELAY_MS);
|
|
24291
|
+
}
|
|
23843
24292
|
var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
23844
24293
|
var SettlementStillQueuedError = class extends Error {
|
|
23845
24294
|
constructor(message, paymentReceipt) {
|
|
@@ -23855,9 +24304,27 @@ var SettlementOutcomeUnknownError = class extends Error {
|
|
|
23855
24304
|
this.name = "SettlementOutcomeUnknownError";
|
|
23856
24305
|
}
|
|
23857
24306
|
};
|
|
24307
|
+
var SettlementReceiptVoidError = class extends Error {
|
|
24308
|
+
constructor(message, paymentReceipt) {
|
|
24309
|
+
super(message);
|
|
24310
|
+
this.paymentReceipt = paymentReceipt;
|
|
24311
|
+
this.name = "SettlementReceiptVoidError";
|
|
24312
|
+
}
|
|
24313
|
+
};
|
|
24314
|
+
function isReceiptVoid(err) {
|
|
24315
|
+
return err instanceof SsivcError && err.kind === "settlement_void";
|
|
24316
|
+
}
|
|
24317
|
+
function unresolvedCause(err) {
|
|
24318
|
+
if (err instanceof SsivcError && err.kind === "settlement_unconfirmed") {
|
|
24319
|
+
return "the payment service reports that it cannot confirm the settlement either way";
|
|
24320
|
+
}
|
|
24321
|
+
return `the receipt-replay call itself failed (${err instanceof Error ? err.message : String(err)})`;
|
|
24322
|
+
}
|
|
23858
24323
|
async function resolveSettlement(deps, buildBody, initialOutcome, onQueued) {
|
|
23859
24324
|
const sleep = deps.sleep ?? defaultSleep;
|
|
23860
24325
|
const maxAttempts = deps.maxSettlementAttempts ?? DEFAULT_MAX_SETTLEMENT_ATTEMPTS;
|
|
24326
|
+
const waitBudgetMs = deps.settlementWaitBudgetMs ?? DEFAULT_SETTLEMENT_WAIT_BUDGET_MS;
|
|
24327
|
+
let waitedMs = 0;
|
|
23861
24328
|
let outcome = initialOutcome;
|
|
23862
24329
|
let lastPersistedReceipt;
|
|
23863
24330
|
if (outcome.kind === "queued") {
|
|
@@ -23865,14 +24332,17 @@ async function resolveSettlement(deps, buildBody, initialOutcome, onQueued) {
|
|
|
23865
24332
|
await onQueued(outcome.paymentReceipt);
|
|
23866
24333
|
}
|
|
23867
24334
|
for (let attempt = 0; outcome.kind === "queued" && attempt < maxAttempts; attempt++) {
|
|
23868
|
-
const delayMs =
|
|
24335
|
+
const delayMs = retryDelayMs(outcome.retryAfterSeconds);
|
|
24336
|
+
if (attempt > 0 && waitedMs + delayMs > waitBudgetMs) break;
|
|
24337
|
+
waitedMs += delayMs;
|
|
23869
24338
|
await sleep(delayMs);
|
|
23870
24339
|
const receiptSent = outcome.paymentReceipt;
|
|
23871
24340
|
try {
|
|
23872
24341
|
outcome = await deps.ssivc.createSessionWithReceipt(await buildBody(), receiptSent);
|
|
23873
24342
|
} catch (err) {
|
|
24343
|
+
if (isReceiptVoid(err)) throw new SettlementReceiptVoidError(err.message, receiptSent);
|
|
23874
24344
|
throw new SettlementOutcomeUnknownError(
|
|
23875
|
-
`the settlement outcome for payment receipt ${receiptSent} could not be determined \u2014
|
|
24345
|
+
`the settlement outcome for payment receipt ${receiptSent} could not be determined \u2014 ` + unresolvedCause(err),
|
|
23876
24346
|
receiptSent
|
|
23877
24347
|
);
|
|
23878
24348
|
}
|
|
@@ -23883,7 +24353,7 @@ async function resolveSettlement(deps, buildBody, initialOutcome, onQueued) {
|
|
|
23883
24353
|
}
|
|
23884
24354
|
if (outcome.kind === "queued") {
|
|
23885
24355
|
throw new SettlementStillQueuedError(
|
|
23886
|
-
"the sponsored
|
|
24356
|
+
"PAYMENT SENT \u2014 the sponsored settlement is still being processed. Nothing has gone wrong and no funds are lost: the receipt has been saved. Call check_ai_birthcert_verification in a few minutes to follow it through. Do not pay again.",
|
|
23887
24357
|
outcome.paymentReceipt
|
|
23888
24358
|
);
|
|
23889
24359
|
}
|
|
@@ -23982,9 +24452,22 @@ async function requestAiBirthcertVerification(deps, input) {
|
|
|
23982
24452
|
throw new Error("requestAiBirthcertVerification: agentName is required");
|
|
23983
24453
|
}
|
|
23984
24454
|
const agentName = input.agentName.trim();
|
|
24455
|
+
if (input.dryRun && input.discardStuckReceiptAndPayFresh !== void 0) {
|
|
24456
|
+
return {
|
|
24457
|
+
error: `dryRun and discardStuckReceiptAndPayFresh cannot be combined \u2014 nothing was discarded and nothing was quoted. A quote never spends and never discards; discarding a receipt forfeits a real payment. Decide which one you meant and call again with only that.`
|
|
24458
|
+
};
|
|
24459
|
+
}
|
|
23985
24460
|
if (input.dryRun) return quoteVerification(deps, agentName, input);
|
|
23986
24461
|
return withRequestLock(() => requestAiBirthcertVerificationLocked(deps, agentName, input));
|
|
23987
24462
|
}
|
|
24463
|
+
function optionalRequestFields(source) {
|
|
24464
|
+
const fields = {};
|
|
24465
|
+
if (source.agentPurpose) fields.agentPurpose = source.agentPurpose;
|
|
24466
|
+
if (source.evidenceAssuranceLevel) fields.evidenceAssuranceLevel = source.evidenceAssuranceLevel;
|
|
24467
|
+
if (source.ownerType) fields.ownerType = source.ownerType;
|
|
24468
|
+
if (source.ownerVerified) fields.ownerVerified = source.ownerVerified;
|
|
24469
|
+
return fields;
|
|
24470
|
+
}
|
|
23988
24471
|
async function buildSessionBody(deps, agentName, input) {
|
|
23989
24472
|
const fields = {
|
|
23990
24473
|
publicKey: deps.publicKeyHex,
|
|
@@ -23994,10 +24477,7 @@ async function buildSessionBody(deps, agentName, input) {
|
|
|
23994
24477
|
id: agentName,
|
|
23995
24478
|
ownerReference: deps.holderDid
|
|
23996
24479
|
};
|
|
23997
|
-
|
|
23998
|
-
if (input.evidenceAssuranceLevel) fields.evidenceAssuranceLevel = input.evidenceAssuranceLevel;
|
|
23999
|
-
if (input.ownerType) fields.ownerType = input.ownerType;
|
|
24000
|
-
if (input.ownerVerified) fields.ownerVerified = input.ownerVerified;
|
|
24480
|
+
Object.assign(fields, optionalRequestFields(input));
|
|
24001
24481
|
const digestHex = (0, import_node_crypto5.createHash)("sha256").update(canonicalizeJson(fields), "utf8").digest("hex");
|
|
24002
24482
|
const { signBlob: signedData } = await deps.signHexBlob(digestHex);
|
|
24003
24483
|
return { ...fields, signedData };
|
|
@@ -24019,11 +24499,49 @@ async function quoteVerification(deps, agentName, input) {
|
|
|
24019
24499
|
}
|
|
24020
24500
|
};
|
|
24021
24501
|
}
|
|
24502
|
+
async function discardStuckReceiptBeforePaying(deps, confirmReceiptId) {
|
|
24503
|
+
const stored = await deps.sessionStore.get();
|
|
24504
|
+
if (!stored) {
|
|
24505
|
+
return {
|
|
24506
|
+
refusal: {
|
|
24507
|
+
error: `Nothing was discarded and nothing was paid: this wallet holds no payment receipt at all. There is nothing to start over from \u2014 call request_ai_birthcert_verification again without discardStuckReceiptAndPayFresh and it will pay normally.`
|
|
24508
|
+
}
|
|
24509
|
+
};
|
|
24510
|
+
}
|
|
24511
|
+
if (stored.paymentReceipt !== confirmReceiptId) {
|
|
24512
|
+
return {
|
|
24513
|
+
refusal: {
|
|
24514
|
+
paymentReceipt: stored.paymentReceipt,
|
|
24515
|
+
error: `discardStuckReceiptAndPayFresh does not match the stored receipt \u2014 nothing was discarded and NOTHING WAS PAID. Stored receipt is ${stored.paymentReceipt}; you sent ${confirmReceiptId}. Confirm with the user which payment they are forfeiting before retrying.`
|
|
24516
|
+
}
|
|
24517
|
+
};
|
|
24518
|
+
}
|
|
24519
|
+
if (stored.sessionId !== "") {
|
|
24520
|
+
return {
|
|
24521
|
+
refusal: {
|
|
24522
|
+
paymentReceipt: stored.paymentReceipt,
|
|
24523
|
+
error: `STOP \u2014 nothing was discarded and nothing was paid. Receipt ${stored.paymentReceipt} is not stuck: it belongs to live verification session ${stored.sessionId}, which is already paid for. Call check_ai_birthcert_verification to get its verification link and finish it \u2014 paying again here would buy a second copy of something the user already owns. If they genuinely want to abandon it, clear_stuck_payment_receipt asks for its own confirmation.`
|
|
24524
|
+
}
|
|
24525
|
+
};
|
|
24526
|
+
}
|
|
24527
|
+
await deps.sessionStore.clear();
|
|
24528
|
+
return { discarded: stored.paymentReceipt };
|
|
24529
|
+
}
|
|
24022
24530
|
async function requestAiBirthcertVerificationLocked(deps, agentName, input) {
|
|
24531
|
+
let discardedPaymentReceipt;
|
|
24532
|
+
if (input.discardStuckReceiptAndPayFresh !== void 0) {
|
|
24533
|
+
const outcome = await discardStuckReceiptBeforePaying(deps, input.discardStuckReceiptAndPayFresh);
|
|
24534
|
+
if ("refusal" in outcome) return outcome.refusal;
|
|
24535
|
+
discardedPaymentReceipt = outcome.discarded;
|
|
24536
|
+
}
|
|
24023
24537
|
const decision = await decidePriorSession(deps, agentName);
|
|
24024
24538
|
if (decision.kind === "still_pending") return decision.result;
|
|
24025
24539
|
if (decision.kind === "blocked") return { error: decision.message };
|
|
24026
|
-
const
|
|
24540
|
+
const effectiveOptionalFields = {
|
|
24541
|
+
...decision.kind === "replay_receipt" ? optionalRequestFields(decision.stored) : {},
|
|
24542
|
+
...optionalRequestFields(input)
|
|
24543
|
+
};
|
|
24544
|
+
const buildBody = () => buildSessionBody(deps, agentName, { ...input, ...effectiveOptionalFields });
|
|
24027
24545
|
const persistQueuedReceipt = async (receipt) => {
|
|
24028
24546
|
try {
|
|
24029
24547
|
await deps.sessionStore.set({
|
|
@@ -24031,7 +24549,12 @@ async function requestAiBirthcertVerificationLocked(deps, agentName, input) {
|
|
|
24031
24549
|
agentName,
|
|
24032
24550
|
createdAt: deps.now().toISOString(),
|
|
24033
24551
|
verificationUrl: "",
|
|
24034
|
-
paymentReceipt: receipt
|
|
24552
|
+
paymentReceipt: receipt,
|
|
24553
|
+
// check_ai_birthcert_verification replays this receipt with no user input to work
|
|
24554
|
+
// from. Without these the replayed body silently drops whatever the user supplied here.
|
|
24555
|
+
// APP-M03: the EFFECTIVE set, so a bare retry re-persists what was paid for instead of
|
|
24556
|
+
// erasing it.
|
|
24557
|
+
...effectiveOptionalFields
|
|
24035
24558
|
});
|
|
24036
24559
|
} catch {
|
|
24037
24560
|
}
|
|
@@ -24044,8 +24567,9 @@ async function requestAiBirthcertVerificationLocked(deps, agentName, input) {
|
|
|
24044
24567
|
try {
|
|
24045
24568
|
initialOutcome = await deps.ssivc.createSessionWithReceipt(await buildBody(), decision.receipt);
|
|
24046
24569
|
} catch (err) {
|
|
24570
|
+
if (isReceiptVoid(err)) throw new SettlementReceiptVoidError(err.message, decision.receipt);
|
|
24047
24571
|
throw new SettlementOutcomeUnknownError(
|
|
24048
|
-
`the settlement outcome for payment receipt ${decision.receipt} could not be determined \u2014
|
|
24572
|
+
`the settlement outcome for payment receipt ${decision.receipt} could not be determined \u2014 ` + unresolvedCause(err),
|
|
24049
24573
|
decision.receipt
|
|
24050
24574
|
);
|
|
24051
24575
|
}
|
|
@@ -24070,21 +24594,42 @@ async function requestAiBirthcertVerificationLocked(deps, agentName, input) {
|
|
|
24070
24594
|
agentName,
|
|
24071
24595
|
createdAt: deps.now().toISOString(),
|
|
24072
24596
|
verificationUrl: "",
|
|
24073
|
-
paymentReceipt: err.paymentReceipt
|
|
24597
|
+
paymentReceipt: err.paymentReceipt,
|
|
24598
|
+
...effectiveOptionalFields
|
|
24074
24599
|
});
|
|
24075
24600
|
} catch {
|
|
24076
24601
|
return {
|
|
24077
|
-
|
|
24602
|
+
paymentReceipt: err.paymentReceipt,
|
|
24603
|
+
error: `PAYMENT SENT \u2014 the sponsored settlement is still processing, but the receipt could NOT be saved on this machine. Keep this value safe, it is the only record of the payment: ${err.paymentReceipt}. Do not pay again. Checking the status will not find it \u2014 quote this receipt id to support to resume manually.`
|
|
24078
24604
|
};
|
|
24079
24605
|
}
|
|
24080
|
-
return {
|
|
24606
|
+
return { settlementPending: true, paymentReceipt: err.paymentReceipt, message: err.message };
|
|
24081
24607
|
}
|
|
24082
24608
|
if (err instanceof SsivcError && err.kind === "blob_already_settled") {
|
|
24083
24609
|
return { error: `payment already settled for this attempt: ${err.message}` };
|
|
24084
24610
|
}
|
|
24611
|
+
if (err instanceof SettlementReceiptVoidError) {
|
|
24612
|
+
return {
|
|
24613
|
+
paymentReceipt: err.paymentReceipt,
|
|
24614
|
+
error: `RECEIPT VOID \u2014 this payment can no longer be used, and no credential was issued. The payment service has ruled on it: ${err.message}. Nothing was paid on this call, and the receipt is still stored. Whether the original fee was actually taken is NOT settled by this \u2014 a settlement can expire after the money has already moved \u2014 so do not tell the user they were not charged; give them receipt ${err.paymentReceipt} to quote to support. To buy the credential now, they must pay the fee AGAIN: with their explicit agreement, call request_ai_birthcert_verification with discardStuckReceiptAndPayFresh set to exactly ${err.paymentReceipt}.`
|
|
24615
|
+
};
|
|
24616
|
+
}
|
|
24085
24617
|
if (err instanceof SettlementOutcomeUnknownError) {
|
|
24618
|
+
const age = settlementAge(
|
|
24619
|
+
decision.kind === "replay_receipt" ? decision.stored : null,
|
|
24620
|
+
deps.now(),
|
|
24621
|
+
deps.settlementStuckAfterMs ?? DEFAULT_SETTLEMENT_STUCK_AFTER_MS
|
|
24622
|
+
);
|
|
24623
|
+
if (!age.stuck) {
|
|
24624
|
+
return {
|
|
24625
|
+
paymentReceipt: err.paymentReceipt,
|
|
24626
|
+
settlementPending: true,
|
|
24627
|
+
message: `PAYMENT SENT \u2014 the settlement has not been confirmed yet, ${age.humanAge} in. Nothing is lost: receipt ${err.paymentReceipt} is saved and no new payment was attempted. Do NOT pay again. Call check_ai_birthcert_verification in a few minutes to follow it through.`
|
|
24628
|
+
};
|
|
24629
|
+
}
|
|
24086
24630
|
return {
|
|
24087
|
-
|
|
24631
|
+
paymentReceipt: err.paymentReceipt,
|
|
24632
|
+
error: `OUTCOME UNKNOWN \u2014 do not retry, do not assume failure. The sponsored settlement for payment receipt ${err.paymentReceipt} could not be determined either way (${err.message}), and has been unresolved for ${age.humanAge} \u2014 past the point where it resolves on its own. The fee was most likely already taken and no credential was issued. This call did NOT pay again: the receipt is kept, and while it is kept this tool can only ever replay it, never buy a new credential. To start over, the user must first discard it with clear_stuck_payment_receipt \u2014 which forfeits that payment \u2014 and the next request then pays a SECOND fee. Both steps at once: call request_ai_birthcert_verification with discardStuckReceiptAndPayFresh set to exactly ${err.paymentReceipt}. Either way, show the user that cost and get their agreement first. Quote receipt ${err.paymentReceipt} to support.`
|
|
24088
24633
|
};
|
|
24089
24634
|
}
|
|
24090
24635
|
throw err;
|
|
@@ -24094,24 +24639,149 @@ async function requestAiBirthcertVerificationLocked(deps, agentName, input) {
|
|
|
24094
24639
|
agentName,
|
|
24095
24640
|
createdAt: deps.now().toISOString(),
|
|
24096
24641
|
verificationUrl: paid.session.verificationUrl,
|
|
24097
|
-
paymentReceipt: paid.paymentReceipt
|
|
24642
|
+
paymentReceipt: paid.paymentReceipt,
|
|
24643
|
+
// Benign today — a record with a real sessionId never replays — but the same latent gap as the
|
|
24644
|
+
// queued writes, so it carries the fields for consistency (APP-M03).
|
|
24645
|
+
...effectiveOptionalFields
|
|
24098
24646
|
});
|
|
24099
|
-
return paid.session;
|
|
24647
|
+
return discardedPaymentReceipt ? { ...paid.session, discardedPaymentReceipt } : paid.session;
|
|
24100
24648
|
}
|
|
24101
|
-
async function
|
|
24649
|
+
async function advanceQueuedSettlement(deps, stored) {
|
|
24650
|
+
const buildBody = () => buildSessionBody(deps, stored.agentName, {
|
|
24651
|
+
agentName: stored.agentName,
|
|
24652
|
+
...optionalRequestFields(stored)
|
|
24653
|
+
});
|
|
24654
|
+
let initialOutcome;
|
|
24655
|
+
try {
|
|
24656
|
+
initialOutcome = await deps.ssivc.createSessionWithReceipt(await buildBody(), stored.paymentReceipt);
|
|
24657
|
+
} catch (err) {
|
|
24658
|
+
if (isReceiptVoid(err)) return voidReceiptOutcome(stored.paymentReceipt, err.message);
|
|
24659
|
+
return unknownSettlementOutcome(stored.paymentReceipt, err, void 0, settlementAge(stored, deps.now(), deps.settlementStuckAfterMs ?? DEFAULT_SETTLEMENT_STUCK_AFTER_MS));
|
|
24660
|
+
}
|
|
24661
|
+
let settled;
|
|
24662
|
+
try {
|
|
24663
|
+
const persistReplayedReceipt = async (receipt) => {
|
|
24664
|
+
try {
|
|
24665
|
+
await deps.sessionStore.set({ ...stored, paymentReceipt: receipt });
|
|
24666
|
+
} catch {
|
|
24667
|
+
}
|
|
24668
|
+
};
|
|
24669
|
+
settled = await resolveSettlement(deps, buildBody, initialOutcome, persistReplayedReceipt);
|
|
24670
|
+
} catch (err) {
|
|
24671
|
+
if (err instanceof SettlementStillQueuedError) {
|
|
24672
|
+
return { status: "settlement_pending", message: err.message, paymentReceipt: err.paymentReceipt };
|
|
24673
|
+
}
|
|
24674
|
+
if (err instanceof SettlementReceiptVoidError) return voidReceiptOutcome(err.paymentReceipt, err.message);
|
|
24675
|
+
if (err instanceof SettlementOutcomeUnknownError) {
|
|
24676
|
+
return unknownSettlementOutcome(err.paymentReceipt, void 0, err.message, settlementAge(stored, deps.now(), deps.settlementStuckAfterMs ?? DEFAULT_SETTLEMENT_STUCK_AFTER_MS));
|
|
24677
|
+
}
|
|
24678
|
+
throw err;
|
|
24679
|
+
}
|
|
24680
|
+
try {
|
|
24681
|
+
await deps.sessionStore.set({
|
|
24682
|
+
...stored,
|
|
24683
|
+
sessionId: settled.session.sessionId,
|
|
24684
|
+
verificationUrl: settled.session.verificationUrl,
|
|
24685
|
+
paymentReceipt: settled.paymentReceipt
|
|
24686
|
+
});
|
|
24687
|
+
} catch {
|
|
24688
|
+
}
|
|
24689
|
+
return {
|
|
24690
|
+
sessionId: settled.session.sessionId,
|
|
24691
|
+
status: "pending",
|
|
24692
|
+
expiresAt: settled.session.expiresAt,
|
|
24693
|
+
verificationUrl: settled.session.verificationUrl
|
|
24694
|
+
};
|
|
24695
|
+
}
|
|
24696
|
+
function noStoredSession() {
|
|
24697
|
+
return {
|
|
24698
|
+
status: "no_session",
|
|
24699
|
+
message: "No verification session found for this wallet \u2014 call request_ai_birthcert_verification first."
|
|
24700
|
+
};
|
|
24701
|
+
}
|
|
24702
|
+
function voidReceiptOutcome(paymentReceipt, detail) {
|
|
24703
|
+
return {
|
|
24704
|
+
status: "receipt_void",
|
|
24705
|
+
paymentReceipt,
|
|
24706
|
+
message: `RECEIPT VOID \u2014 this payment can no longer be used and no credential was issued. The payment service has ruled on it: ${detail}. Checking again will not change this. Whether the fee was actually taken is NOT settled by this \u2014 a settlement can expire after the money has moved \u2014 so do not tell the user they were not charged; give them receipt ${paymentReceipt} to quote to support. Buying the credential now means paying the fee AGAIN, which needs their explicit agreement: request_ai_birthcert_verification with discardStuckReceiptAndPayFresh set to exactly ${paymentReceipt}.`
|
|
24707
|
+
};
|
|
24708
|
+
}
|
|
24709
|
+
function unknownSettlementOutcome(paymentReceipt, err, message, age) {
|
|
24710
|
+
const detail = message ?? unresolvedCause(err);
|
|
24711
|
+
if (age && !age.stuck) {
|
|
24712
|
+
return {
|
|
24713
|
+
status: "settlement_pending",
|
|
24714
|
+
paymentReceipt,
|
|
24715
|
+
message: `PAYMENT SENT \u2014 the settlement has not been confirmed yet, ${age.humanAge} in. Nothing has gone wrong and no funds are lost: receipt ${paymentReceipt} is saved. Do NOT pay again. Call check_ai_birthcert_verification again in a few minutes to follow it through.`
|
|
24716
|
+
};
|
|
24717
|
+
}
|
|
24718
|
+
return {
|
|
24719
|
+
status: "settlement_pending",
|
|
24720
|
+
paymentReceipt,
|
|
24721
|
+
// R2-L01: the machine-readable half of the OUTCOME UNKNOWN verdict. Without it the status alone
|
|
24722
|
+
// reads as "queued, check back later", which is the one piece of advice this branch must not give.
|
|
24723
|
+
outcomeUnknown: true,
|
|
24724
|
+
...age ? { stuckFor: age.humanAge } : {},
|
|
24725
|
+
message: `OUTCOME UNKNOWN \u2014 do not retry, do not assume failure. The settlement outcome for payment receipt ${paymentReceipt} could not be determined: ${detail}. ` + (age ? `It has been unresolved for ${age.humanAge}, which is past the point where it resolves on its own. The fee was most likely already taken and no credential was issued \u2014 say so plainly rather than implying the payment may still land. ` : "") + `The receipt has been kept. Quote this receipt id to support. ` + (age ? `To start over, clear_stuck_payment_receipt discards it \u2014 and the next request pays a SECOND fee.` : "")
|
|
24726
|
+
};
|
|
24727
|
+
}
|
|
24728
|
+
async function clearStuckPaymentReceipt(deps, input) {
|
|
24729
|
+
return withRequestLock(() => clearStuckPaymentReceiptLocked(deps, input));
|
|
24730
|
+
}
|
|
24731
|
+
async function clearStuckPaymentReceiptLocked(deps, input) {
|
|
24102
24732
|
const stored = await deps.sessionStore.get();
|
|
24103
24733
|
if (!stored) {
|
|
24734
|
+
return { cleared: false, message: "Nothing to clear \u2014 this wallet has no stored verification session or payment receipt." };
|
|
24735
|
+
}
|
|
24736
|
+
const isLiveSession = stored.sessionId !== "";
|
|
24737
|
+
if (input.confirmReceiptId === void 0) {
|
|
24104
24738
|
return {
|
|
24105
|
-
|
|
24106
|
-
|
|
24739
|
+
cleared: false,
|
|
24740
|
+
requiresConfirmation: true,
|
|
24741
|
+
paymentReceipt: stored.paymentReceipt,
|
|
24742
|
+
...isLiveSession ? { sessionId: stored.sessionId, verificationUrl: stored.verificationUrl } : {},
|
|
24743
|
+
message: `This will discard payment receipt ${stored.paymentReceipt}` + (isLiveSession ? ` AND the live verification session ${stored.sessionId}, which may still be open and usable \u2014 check check_ai_birthcert_verification before discarding it.` : ".") + ` The payment it represents becomes unrecoverable and CANNOT be undone \u2014 if that settlement ever completes, the funds are forfeit and the credential is not issued. Only do this when the outcome is genuinely stuck. Show the receipt id to the user, get their explicit agreement, then call this tool again with confirmReceiptId set to exactly that id` + (isLiveSession ? ` and confirmDiscardLiveSession set to true.` : `.`)
|
|
24107
24744
|
};
|
|
24108
24745
|
}
|
|
24109
|
-
if (
|
|
24746
|
+
if (input.confirmReceiptId !== stored.paymentReceipt) {
|
|
24747
|
+
return {
|
|
24748
|
+
cleared: false,
|
|
24749
|
+
paymentReceipt: stored.paymentReceipt,
|
|
24750
|
+
error: `confirmReceiptId does not match the stored receipt \u2014 nothing was cleared. Stored receipt is ${stored.paymentReceipt}; you sent ${input.confirmReceiptId}. Confirm you are discarding the right payment before retrying.`
|
|
24751
|
+
};
|
|
24752
|
+
}
|
|
24753
|
+
if (isLiveSession && input.confirmDiscardLiveSession !== true) {
|
|
24110
24754
|
return {
|
|
24111
|
-
|
|
24112
|
-
|
|
24755
|
+
cleared: false,
|
|
24756
|
+
requiresConfirmation: true,
|
|
24757
|
+
paymentReceipt: stored.paymentReceipt,
|
|
24758
|
+
sessionId: stored.sessionId,
|
|
24759
|
+
verificationUrl: stored.verificationUrl,
|
|
24760
|
+
message: `STOP \u2014 nothing was cleared. This is no longer a stuck receipt: the settlement completed and receipt ${stored.paymentReceipt} now belongs to LIVE verification session ${stored.sessionId}` + (stored.verificationUrl ? `, whose verification link is ${stored.verificationUrl}` : "") + `. The payment worked. Give the user that link and let them finish MyDigital ID verification \u2014 clearing now would throw away a session they have already paid for, and the link cannot be reissued. If they genuinely want to abandon it and start over, call again with the same confirmReceiptId AND confirmDiscardLiveSession set to true.`
|
|
24113
24761
|
};
|
|
24114
24762
|
}
|
|
24763
|
+
await deps.sessionStore.clear();
|
|
24764
|
+
return {
|
|
24765
|
+
cleared: true,
|
|
24766
|
+
paymentReceipt: stored.paymentReceipt,
|
|
24767
|
+
...isLiveSession ? { sessionId: stored.sessionId } : {},
|
|
24768
|
+
message: `Cleared payment receipt ${stored.paymentReceipt}` + (isLiveSession ? ` AND live verification session ${stored.sessionId}, which was open and paid for \u2014 its verification link is gone and cannot be reissued.` : ".") + ` The wallet no longer holds it, so request_ai_birthcert_verification will pay fresh on the next call. Keep this receipt id in case the original payment needs to be traced.`
|
|
24769
|
+
};
|
|
24770
|
+
}
|
|
24771
|
+
async function checkAiBirthcertVerification(deps) {
|
|
24772
|
+
const stored = await deps.sessionStore.get();
|
|
24773
|
+
if (!stored) return noStoredSession();
|
|
24774
|
+
if (stored.sessionId === "") {
|
|
24775
|
+
return withRequestLock(async () => {
|
|
24776
|
+
const fresh = await deps.sessionStore.get();
|
|
24777
|
+
if (!fresh) return noStoredSession();
|
|
24778
|
+
if (fresh.sessionId === "") return advanceQueuedSettlement(deps, fresh);
|
|
24779
|
+
return checkExistingSession(deps, fresh);
|
|
24780
|
+
});
|
|
24781
|
+
}
|
|
24782
|
+
return checkExistingSession(deps, stored);
|
|
24783
|
+
}
|
|
24784
|
+
async function checkExistingSession(deps, stored) {
|
|
24115
24785
|
const status = await deps.ssivc.getSession(stored.sessionId);
|
|
24116
24786
|
if (status.status !== "issued" && stored.verificationUrl) {
|
|
24117
24787
|
return { ...status, verificationUrl: stored.verificationUrl };
|
|
@@ -24251,6 +24921,56 @@ function buildToolList() {
|
|
|
24251
24921
|
required: ["templateId"]
|
|
24252
24922
|
}
|
|
24253
24923
|
},
|
|
24924
|
+
{
|
|
24925
|
+
name: "get_policy_template_schema",
|
|
24926
|
+
description: `Read a POLICY template's declared attribute vocabulary from chain \u2014 FREE, no payment, no signing. This is the only vocabulary that means anything on chain: the policy contract validates nothing, so an attribute name outside this list deploys cleanly and then enforces nothing at all. Accepts either { publisher, policyKey } or { templateId }. A template that cannot be read reports { error } rather than { found: false }, so "no such template" is never confused with "could not look it up".`,
|
|
24927
|
+
inputSchema: {
|
|
24928
|
+
type: "object",
|
|
24929
|
+
properties: {
|
|
24930
|
+
templateId: { type: "string", description: "Template id, as it appears inside a deployed policy." },
|
|
24931
|
+
publisher: { type: "string", description: "Publisher address. Use together with policyKey." },
|
|
24932
|
+
policyKey: { type: "string", description: "Policy key. Use together with publisher." }
|
|
24933
|
+
}
|
|
24934
|
+
}
|
|
24935
|
+
},
|
|
24936
|
+
{
|
|
24937
|
+
name: "get_my_policy",
|
|
24938
|
+
description: `Read the spending policies this owner has deployed on chain \u2014 FREE, no payment, no signing. Defaults to this wallet's own address. Costs 2 + N chain calls and warns above 50 keys. An owner who has never deployed a policy is reported as a normal absence, NOT an error \u2014 the policy contract is created lazily on first write. A failed lookup keeps its own error state, so "we could not list your policies" is never presented as "you have none".`,
|
|
24939
|
+
inputSchema: {
|
|
24940
|
+
type: "object",
|
|
24941
|
+
properties: {
|
|
24942
|
+
owner: { type: "string", description: "Owner address. Defaults to this wallet's configured address." }
|
|
24943
|
+
}
|
|
24944
|
+
}
|
|
24945
|
+
},
|
|
24946
|
+
{
|
|
24947
|
+
name: "policy_preflight",
|
|
24948
|
+
description: 'Check a draft policy BEFORE it is deployed \u2014 FREE, no payment, no signing. It answers two questions. First, is the draft well-formed: every blocker is returned at once in `blockers`, so one round of fixes is enough rather than discovering them one failure at a time. Second, and more important, does the policy MEAN what the user thinks: `interpretation` states in plain words what it actually does. ALWAYS show `interpretation` to the user, INCLUDING when ready is true \u2014 a policy can be perfectly valid and still mean something other than what was intended (a spending cap with no window is a LIFETIME cap, not a monthly one), and reporting only "ready" hides exactly that. A clean result is NOT a guarantee: `notChecked` lists what could not be verified, including whether the policy will be enforced at all and whether a payment would currently be allowed.',
|
|
24949
|
+
inputSchema: {
|
|
24950
|
+
type: "object",
|
|
24951
|
+
properties: {
|
|
24952
|
+
policyKey: { type: "string", description: "The key this policy would be stored under." },
|
|
24953
|
+
attributes: {
|
|
24954
|
+
type: "array",
|
|
24955
|
+
description: "The draft rules \u2014 one { attributeName, attributeType, value } per rule.",
|
|
24956
|
+
items: {
|
|
24957
|
+
type: "object",
|
|
24958
|
+
properties: {
|
|
24959
|
+
attributeName: { type: "string" },
|
|
24960
|
+
attributeType: { type: "string" },
|
|
24961
|
+
value: { type: "string" }
|
|
24962
|
+
},
|
|
24963
|
+
required: ["attributeName", "attributeType", "value"]
|
|
24964
|
+
}
|
|
24965
|
+
},
|
|
24966
|
+
validFromBlock: { type: "string", description: "Block this policy starts at, written as a string." },
|
|
24967
|
+
validToBlock: { type: "string", description: 'Block it ends at, as a string. "0" means no end.' },
|
|
24968
|
+
templateId: { type: "string", description: "Template id. Supply this OR publisher + policyKey." },
|
|
24969
|
+
publisher: { type: "string", description: "Publisher address, used together with policyKey." }
|
|
24970
|
+
},
|
|
24971
|
+
required: ["policyKey", "attributes", "validFromBlock", "validToBlock"]
|
|
24972
|
+
}
|
|
24973
|
+
},
|
|
24254
24974
|
{
|
|
24255
24975
|
name: "query_contract",
|
|
24256
24976
|
description: 'Read-only query against a Zetrix contract or account \u2014 call an arbitrary contract method (e.g. "balanceOf", "contractInfo") and return its raw result. No signing, no state change.',
|
|
@@ -24293,7 +25013,7 @@ function buildToolList() {
|
|
|
24293
25013
|
},
|
|
24294
25014
|
{
|
|
24295
25015
|
name: "request_ai_birthcert_verification",
|
|
24296
|
-
description: `Start a Verified AI Birthcert issuance session with myid (MyDigital ID owner verification). Returns { sessionId, verificationUrl, expiresAt } \u2014 show verificationUrl to the human owner and ask them to open it and complete MyDigital ID verification (typically finishes in seconds). Once they confirm they are done, call check_ai_birthcert_verification to see whether the credential was issued. IMPORTANT: agentName must be unique \u2014 if this exact name has already been used to request a Verified AI Birthcert, issuance will fail. Before calling, ask the human owner whether they want to supply any of the optional fields \u2014 agentPurpose, evidenceAssuranceLevel, ownerType, ownerVerified \u2014 do not silently omit them; they only need to say no. Calling this again with the SAME agentName while a prior session is still pending returns that same session unchanged \u2014 no new session is started and nothing is paid again. This tool spends real funds: it self-pays an x402 challenge, subject to the same credential-issuance payment cap as subscribe_and_issue \u2014 a separate, narrower cap than pay_and_fetch's, which defaults to refusing everything on mainnet. Set MAX_PAYMENT_AMOUNT to override either. It can return { error: "..." } instead of a session if that payment fails (insufficient funds, or the payment cap blocked it) \u2014 nothing is created in that case. If the user did NOT ask for a "verified" credential specifically, they most likely want the self-declared, non-verified Basic AI Birthcert instead \u2014 use subscribe_and_issue for that.`,
|
|
25016
|
+
description: `Start a Verified AI Birthcert issuance session with myid (MyDigital ID owner verification). ALWAYS run credential_preflight for "verified_ai_birthcert" immediately before calling this, even if you checked earlier in the conversation \u2014 preflight is free, this tool spends real funds, and a balance the user topped up a minute ago is not the balance you read before that. Returns { sessionId, verificationUrl, expiresAt } \u2014 show verificationUrl to the human owner and ask them to open it and complete MyDigital ID verification (typically finishes in seconds). Once they confirm they are done, call check_ai_birthcert_verification to see whether the credential was issued. IMPORTANT: agentName must be unique \u2014 if this exact name has already been used to request a Verified AI Birthcert, issuance will fail. Before calling, ask the human owner whether they want to supply any of the optional fields \u2014 agentPurpose, evidenceAssuranceLevel, ownerType, ownerVerified \u2014 do not silently omit them; they only need to say no. Calling this again with the SAME agentName while a prior session is still pending returns that same session unchanged \u2014 no new session is started and nothing is paid again. This tool spends real funds: it self-pays an x402 challenge, subject to the same credential-issuance payment cap as subscribe_and_issue \u2014 a separate, narrower cap than pay_and_fetch's, which defaults to refusing everything on mainnet. Set MAX_PAYMENT_AMOUNT to override either. It can return { error: "..." } instead of a session if that payment fails (insufficient funds, or the payment cap blocked it) \u2014 nothing is created in that case. It can also return { settlementPending: true, paymentReceipt, message } \u2014 this means the payment SUCCEEDED and the sponsored settlement is still clearing, which can take a few minutes. That is NOT a failure and NOT an error: report it to the user as "payment sent, still settling", never as "the payment failed". Do not pay again and do not call this tool again to retry \u2014 call check_ai_birthcert_verification to follow it through. If the user did NOT ask for a "verified" credential specifically, they most likely want the self-declared, non-verified Basic AI Birthcert instead \u2014 use subscribe_and_issue for that.`,
|
|
24297
25017
|
inputSchema: {
|
|
24298
25018
|
type: "object",
|
|
24299
25019
|
properties: {
|
|
@@ -24313,6 +25033,10 @@ function buildToolList() {
|
|
|
24313
25033
|
dryRun: {
|
|
24314
25034
|
type: "boolean",
|
|
24315
25035
|
description: "Ask the price WITHOUT paying. Returns { quote: { asset, maxAmountRequired, payTo, gasModel } } and spends nothing, creates no session, and starts no verification \u2014 so you can tell the user the cost before collecting anything. `maxAmountRequired` is in the asset's RAW base units; resolve decimals (wallet_status returns `display`) before quoting a figure to a human. A quote does NOT reserve the name and does NOT check whether it is already taken \u2014 myid checks uniqueness only at issuance, so a name already in use still quotes cleanly. `agentName` is still required because the server rejects a request without one, but the fee does not depend on it."
|
|
25036
|
+
},
|
|
25037
|
+
discardStuckReceiptAndPayFresh: {
|
|
25038
|
+
type: "string",
|
|
25039
|
+
description: "DESTRUCTIVE, and it SPENDS. Only for a payment that is genuinely stuck. While the wallet holds a stuck receipt this tool can only replay it \u2014 it will never buy a new credential \u2014 so this is the way to start over: it throws that payment away and pays a SECOND fee. Pass the stuck receipt id EXACTLY as check_ai_birthcert_verification or clear_stuck_payment_receipt reported it \u2014 never a guess, never true. A mismatched id discards nothing and pays nothing. Before using it, show the user the receipt id, tell them the first payment is forfeit and that this costs the fee again, and get their explicit agreement. If the receipt turns out to belong to a live session this is refused: that session is already paid for, so call check_ai_birthcert_verification instead. The result carries discardedPaymentReceipt \u2014 keep it, support needs it to trace the lost payment. A receipt only counts as stuck once it is older than SETTLEMENT_STUCK_AFTER_MS (24h by default, e.g. SETTLEMENT_STUCK_AFTER_MS=3600000 for one hour); before that the wallet will say the settlement may still be in flight, and starting over is the user decision, not yours."
|
|
24316
25040
|
}
|
|
24317
25041
|
},
|
|
24318
25042
|
required: ["agentName"]
|
|
@@ -24338,9 +25062,26 @@ function buildToolList() {
|
|
|
24338
25062
|
},
|
|
24339
25063
|
{
|
|
24340
25064
|
name: "check_ai_birthcert_verification",
|
|
24341
|
-
description: 'Check the status of the most recently requested Verified AI Birthcert session (see request_ai_birthcert_verification). FREE \u2014
|
|
25065
|
+
description: 'Check the status of the most recently requested Verified AI Birthcert session (see request_ai_birthcert_verification). FREE \u2014 it never pays for anything. Use this, not request_ai_birthcert_verification, whenever the user asks where their verification link is, what happened to their session, or whether their credential is ready. While the session is still open the result carries `verificationUrl` (the same link issued at creation) and `expiresAt` \u2014 give the user both, so they know how long it is good for. Returns { status: "pending" } while the owner has not yet completed MyDigital ID verification, or { status: "issued", vcId } once myid has minted the credential \u2014 myid returns vcId ONLY when status is "issued", never otherwise. On { status: "issued" }, the wallet also fetches the credential from MBI, verifies it, and caches it locally, returning it as `vc` \u2014 it is then also visible via wallet_status and usable by prove_identity without any further call. If `cacheError` is present instead of `vc`, the credential WAS issued successfully but could not be fetched/verified/cached yet (e.g. a transient MBI error) \u2014 this is NOT the same as issuance failing, so do not retry request_ai_birthcert_verification; call check_ai_birthcert_verification again instead. Returns { status: "no_session" } if request_ai_birthcert_verification has never been called. If a previous payment is still clearing, this tool ACTIVELY ADVANCES it \u2014 so in that one case it can take up to ~90s to return (it is waiting on the settlement, not hung; every other case returns immediately). It replays the saved receipt (never a new payment) and returns the live session once it settles, so telling the user to check back here genuinely moves things forward. While it is still clearing you get { status: "settlement_pending", paymentReceipt, message }: a payment HAS been made, so never call request_ai_birthcert_verification and never tell the user it failed. Two cases, told apart by outcomeUnknown and by the first words of message: without outcomeUnknown (message leads "PAYMENT SENT") it is queued and progressing \u2014 just check again in a few minutes. With outcomeUnknown: true (message leads "OUTCOME UNKNOWN") the settlement outcome could not be determined at all and has been unresolved long enough that it is not coming back (stuckFor says how long). The fee was most likely ALREADY TAKEN and no credential was issued \u2014 say that plainly rather than implying it may still land. do not just tell the user to wait; give them the paymentReceipt and tell them to quote it to support. Quote paymentReceipt to support in either case if they ask. Separately, { status: "receipt_void" } is TERMINAL: the payment service has ruled that this receipt is finished (it expired, or the settlement failed), so checking again cannot help and no credential will come from it. That does NOT mean the fee was refunded \u2014 never tell the user they were not charged; give them paymentReceipt for support. Buying the credential then means paying the fee AGAIN, which needs their explicit agreement.',
|
|
24342
25066
|
inputSchema: { type: "object", properties: {} }
|
|
24343
25067
|
},
|
|
25068
|
+
{
|
|
25069
|
+
name: "clear_stuck_payment_receipt",
|
|
25070
|
+
description: 'LAST RESORT. Discard a stuck Verified AI Birthcert payment receipt that the wallet is holding and refusing to pay past. DESTRUCTIVE and CANNOT BE UNDONE: the payment it represents becomes unrecoverable \u2014 if that settlement ever completes, the funds are forfeit and no credential is issued. Do NOT use this as a retry. If the wallet reports { status: "settlement_pending" }, the payment is still in progress and will most likely resolve on its own \u2014 call check_ai_birthcert_verification again instead; it actively advances a queued settlement. Only reach for this tool when the outcome has been stuck with no change for a long time and the user accepts losing the payment. Two steps, deliberately: call it with no arguments first and it clears NOTHING \u2014 it returns the receipt id and a warning. Show that id to the user, get their explicit agreement, then call again with confirmReceiptId set to exactly that id. A mismatched id clears nothing. If the settlement completed in between \u2014 which is exactly what happens when you follow the advice above and call check_ai_birthcert_verification first \u2014 the receipt now belongs to a LIVE, paid-for session and this tool REFUSES to clear on the id alone: it hands back the session id and verification link instead. Give that link to the user; only if they truly want to abandon a session they already paid for, call again with confirmDiscardLiveSession set to true as well.',
|
|
25071
|
+
inputSchema: {
|
|
25072
|
+
type: "object",
|
|
25073
|
+
properties: {
|
|
25074
|
+
confirmReceiptId: {
|
|
25075
|
+
type: "string",
|
|
25076
|
+
description: "The receipt id to discard, copied exactly from a prior no-argument call. Omit it to be shown the id and the warning first \u2014 never guess or invent this value."
|
|
25077
|
+
},
|
|
25078
|
+
confirmDiscardLiveSession: {
|
|
25079
|
+
type: "boolean",
|
|
25080
|
+
description: "Set true ONLY after the tool has refused because the receipt now belongs to a live verification session, and the user has been shown that session's link and has explicitly chosen to throw it away anyway. Never set it pre-emptively."
|
|
25081
|
+
}
|
|
25082
|
+
}
|
|
25083
|
+
}
|
|
25084
|
+
},
|
|
24344
25085
|
{
|
|
24345
25086
|
name: "create_holder_account",
|
|
24346
25087
|
description: "Create a new holder HSM account on Wallet BE (onboarding). ALWAYS check first: if an account already exists for this session, this returns { alreadyExists: true, existing: {...} } WITHOUT creating anything \u2014 ask the user whether to keep using the existing account or create a new one, then call again with confirmNew:true only if they choose new. The wallet manages its own credentials; you neither need nor can supply any. A freshly created account is saved to this MCP's local account store and reused automatically on the next restart; an explicit ZETRIX_ADDRESS in the MCP config still overrides it.",
|
|
@@ -24365,6 +25106,15 @@ function buildPayers(config2, makePay) {
|
|
|
24365
25106
|
preflightCaps: config2.credentialIssuanceCaps
|
|
24366
25107
|
};
|
|
24367
25108
|
}
|
|
25109
|
+
function buildSettlementWiring(config2) {
|
|
25110
|
+
return {
|
|
25111
|
+
gasPreference: config2.gasPreference,
|
|
25112
|
+
maxSettlementAttempts: config2.maxSettlementAttempts,
|
|
25113
|
+
settlementWaitBudgetMs: config2.settlementWaitBudgetMs,
|
|
25114
|
+
settlementStuckAfterMs: config2.settlementStuckAfterMs,
|
|
25115
|
+
verifiedTemplateId: config2.aiBirthcertVerifiedTemplateId
|
|
25116
|
+
};
|
|
25117
|
+
}
|
|
24368
25118
|
function asPayRequest(accept) {
|
|
24369
25119
|
const extra = accept.extra ?? {};
|
|
24370
25120
|
const prepareEndpoint = extra.prepareEndpoint;
|
|
@@ -24524,17 +25274,18 @@ async function main() {
|
|
|
24524
25274
|
holderDid,
|
|
24525
25275
|
now: () => /* @__PURE__ */ new Date(),
|
|
24526
25276
|
sessionStore: ssivcSessionStore,
|
|
24527
|
-
verifiedTemplateId: config2.aiBirthcertVerifiedTemplateId,
|
|
24528
25277
|
cache: vcCache,
|
|
24529
25278
|
quarantine: downloadQuarantine,
|
|
24530
|
-
|
|
24531
|
-
|
|
25279
|
+
// gasPreference, maxSettlementAttempts, settlementWaitBudgetMs and verifiedTemplateId —
|
|
25280
|
+
// see buildSettlementWiring (R2-L03).
|
|
25281
|
+
...buildSettlementWiring(config2),
|
|
24532
25282
|
formatAssetAmount,
|
|
24533
25283
|
passImagesDir
|
|
24534
25284
|
};
|
|
24535
25285
|
return {
|
|
24536
25286
|
request: (input) => requestAiBirthcertVerification(verifyAiBirthcertDeps, input),
|
|
24537
|
-
check: () => checkAiBirthcertVerification(verifyAiBirthcertDeps)
|
|
25287
|
+
check: () => checkAiBirthcertVerification(verifyAiBirthcertDeps),
|
|
25288
|
+
clearStuckReceipt: (input) => clearStuckPaymentReceipt(verifyAiBirthcertDeps, input)
|
|
24538
25289
|
};
|
|
24539
25290
|
})() : void 0;
|
|
24540
25291
|
const payer = async (req) => {
|
|
@@ -24578,7 +25329,13 @@ async function main() {
|
|
|
24578
25329
|
{ signer, vc: new MbiVpAdapter(mbi, walletBeSignerFn, messageSigner, zetrixAddress, resolveIssuerKeys, present), submitAuth }
|
|
24579
25330
|
);
|
|
24580
25331
|
const deps = {
|
|
24581
|
-
config: {
|
|
25332
|
+
config: {
|
|
25333
|
+
holderDid,
|
|
25334
|
+
zetrixAddress,
|
|
25335
|
+
network: config2.network,
|
|
25336
|
+
policyRegistryAddress: config2.policyRegistryAddress,
|
|
25337
|
+
policyTemplateAddress: config2.policyTemplateAddress
|
|
25338
|
+
},
|
|
24582
25339
|
makeWallet,
|
|
24583
25340
|
payer,
|
|
24584
25341
|
subscribeDeps: {
|
|
@@ -24594,6 +25351,8 @@ async function main() {
|
|
|
24594
25351
|
quarantine: downloadQuarantine,
|
|
24595
25352
|
passImagesDir
|
|
24596
25353
|
},
|
|
25354
|
+
// The RAW seam, for the policy client, which reads the query_rets envelope itself.
|
|
25355
|
+
chainQuery: contractQuery,
|
|
24597
25356
|
queryContract: (input) => queryContract(input, contractQuery),
|
|
24598
25357
|
queryTokenBalance: queryTokenBalance2,
|
|
24599
25358
|
// Read-only, for credential_preflight's cap headroom. Preflight only ever prices credentials,
|
|
@@ -24643,5 +25402,6 @@ if (process.env.NODE_ENV !== "test") {
|
|
|
24643
25402
|
0 && (module.exports = {
|
|
24644
25403
|
asPayRequest,
|
|
24645
25404
|
buildPayers,
|
|
25405
|
+
buildSettlementWiring,
|
|
24646
25406
|
buildToolList
|
|
24647
25407
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentic-wallet-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Agent-facing MCP wallet for Zetrix — orchestrates x401 identity proof, x402 payment, and MBI VC issuance",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
"build": "tsc --noEmit && esbuild src/index.ts --bundle --platform=node --target=node18 --format=cjs --external:zetrix-sdk-nodejs --outfile=dist/server-bundle.cjs",
|
|
39
39
|
"test": "vitest run",
|
|
40
40
|
"test:watch": "vitest",
|
|
41
|
-
"typecheck": "tsc --noEmit"
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"typecheck:tests": "tsc --noEmit -p tsconfig.test.json"
|
|
42
43
|
},
|
|
43
44
|
"dependencies": {
|
|
44
45
|
"@modelcontextprotocol/sdk": "^1.0.0",
|