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 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 }` or `{ quote: { asset, maxAmountRequired, payTo?, gasModel } }` |
32
- | `check_ai_birthcert_verification` | **Free.** 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 | (none) | `{ status: "pending" \| "issued" \| "no_session", verificationUrl?, expiresAt?, vcId?, vc?, cacheError? }` |
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
 
@@ -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.11.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 = Math.min(outcome.retryAfterSeconds * 1e3, MAX_RETRY_DELAY_MS);
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 the receipt-retry call itself failed (${err instanceof Error ? err.message : String(err)})`,
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 payment is being processed and has not settled yet \u2014 no funds are lost and the receipt has been kept; run request_ai_birthcert_verification again shortly to resume",
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
- if (input.agentPurpose) fields.agentPurpose = input.agentPurpose;
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 buildBody = () => buildSessionBody(deps, agentName, input);
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 the receipt-replay call itself failed (${err instanceof Error ? err.message : String(err)})`,
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
- error: err.message + ` (could not save the receipt locally \u2014 keep this value to resume manually: ${err.paymentReceipt})`
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 { error: err.message };
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
- error: `could not determine whether the sponsored settlement succeeded or failed for payment receipt ${err.paymentReceipt} (${err.message}) \u2014 this is NOT a confirmed failure, so the receipt has been kept as-is and no new payment has been attempted; this requires manual investigation by an operator before retrying, rather than calling request_ai_birthcert_verification again`
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 checkAiBirthcertVerification(deps) {
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
- status: "no_session",
24106
- message: "No verification session found for this wallet \u2014 call request_ai_birthcert_verification first."
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 (stored.sessionId === "") {
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
- status: "no_session",
24112
- message: "a sponsored payment is still settling and no verification session exists yet \u2014 the payment receipt has been kept, so calling request_ai_birthcert_verification again resumes it and will not pay twice."
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 spends nothing and starts nothing. 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.',
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
- gasPreference: config2.gasPreference,
24531
- maxSettlementAttempts: config2.maxSettlementAttempts,
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: { holderDid, zetrixAddress, network: config2.network },
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.11.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",