agentic-wallet-mcp 0.14.2 → 0.15.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
@@ -10,6 +10,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.15.0] — 15 October 2026
14
+
15
+ ### Changed
16
+
17
+ - **Paid issuance asks before it pays, and the yes is tied to the price.** `request_ai_birthcert_verification` and a chargeable `subscribe_and_issue` used to pay on the first call, so an agent could spend before the user said yes. Called without a confirmed price, both now return `{ needsConfirmation: true, quote, askUser }` and pay nothing (`subscribe_and_issue` asks MBI's `/quote`, so nothing is even signed). `confirm` is no longer a boolean: it is the `quote` object, passed back unchanged (`asset`, `maxAmountRequired`, `payTo`, `gasModel`), and is declared that way in the tool schema; `true` is refused. Just before paying, the wallet compares the payment request with the confirmed quote and, if the token, amount, payee or gas model differ, pays nothing and returns the new price to ask about. The confirmed gas model picks the payment option: when a sponsored option is refused, the Verified AI Birthcert no longer switches to paying gas itself, it offers the self-pay price instead. A free issuance, a credential already held, the same open verification link and a new link that reuses an earlier payment need no confirmation. The price question names the credential ("Issuing this Basic AI Birthcert (self-declared, not identity-verified) costs …") and says when ZTX network gas is paid on top. **Agents and scripts that called these two tools once and expected a payment now get the price back first.**
18
+ - **Results say what to do next.** `request_ai_birthcert_verification`, `subscribe_and_issue` and `credential_preflight` results carry `nextStep` (for example `ask_user_to_confirm_payment`, `show_link_and_wait`, `confirm_kind_then_collect_details`, `follow_message`) and, when there is a question to put to the user, `askUser`, so a model does not have to find the next step in the prose.
19
+ - **A Verified AI Birthcert name is never treated as the Basic one.** "Verified AI Birthcert" contains "birthcert", so it resolved to the Basic template. Names that say verified, verify, verification, MyDigital ID or MyID (with or without spaces) are now the Verified one; "not verified", "self-declared" and "basic" mean Basic; a name with both kinds of wording is asked about. `subscribe_and_issue` refuses a Verified or unclear name with nothing paid.
20
+
21
+ ### Fixed
22
+
23
+ - **The agent no longer says it paid again for a new verification link.** A new link after an expired one reuses the earlier, unused payment, but every result looked the same and the agent told the user it had paid once per link. Every `request_ai_birthcert_verification` session result now says `paidThisCall`, its message opens with "No new payment" when it did not pay, and `credential_preflight` reports an earlier payment the next link will reuse (`unusedPayment`) instead of quoting the fee again.
24
+ - **A credential already held is no longer reported as issued.** Asked for a Basic AI Birthcert under a new username, `subscribe_and_issue` returned the cached one, with the old username, as `issued: true`. A cache hit is now `{ issued: false, alreadyHeld: true }`, and `differsFromRequest` names any requested value the held credential does not carry, so the agent can ask whether to keep or replace it.
25
+ - **Amounts are reported in whole tokens only, and never wrongly.** `wallet_status` told the agent to quote `display` but not to stop there, and QA saw "1 JMYR (1,000,000 base units, 6 decimals)": the agent now quotes only the whole-token amount. Where a token's decimals cannot be read, the amount is no longer shown as whole tokens (it read "473999900 JMYR" for 473.9999 JMYR): `wallet_status`, `credential_preflight`, the policy price and cap messages say the amount could not be converted, and a token whose decimals cannot be read reports `decimals: null`, not 0.
26
+
13
27
  ## [0.14.2] — 13 October 2026
14
28
 
15
29
  ### Changed
package/README.md CHANGED
@@ -20,12 +20,12 @@ VC issuance → identity proof → pay-per-use).
20
20
 
21
21
  | Tool | Does | Input | Output (shape) |
22
22
  |---|---|---|---|
23
- | `wallet_status` | Report holder DID/address/network + held VCs (client-supplied, or the local cache); optionally one token balance (`token`) or several in one call (`tokens`). Each balance carries `balance` (raw base units), `decimals` and `display` — the same amount in whole tokens with its symbol. Quote `display`; a raw count beside a ticker is wrong by orders of magnitude. A token may be named by ticker **or** contract address, and a ZTP20 result also carries its contract address as `tokenAddress` — the value a policy needs, so it never has to be asked for | `{ heldCredentials?, token?, tokens? }` | `{ holderDid, zetrixAddress, network, credentials, balances?, tokenBalance?, tokenBalances? }` |
24
- | `credential_preflight` | **Free.** The first call for any credential — composes the quote, the balances and the spending cap into one answer, before a single application field is collected. Spends nothing, starts nothing. `blockers` lists *every* reason the wallet is not ready, at once; `notChecked` lists what preflight cannot know — agent-name availability is decided by myid at issuance, after payment, so a `ready` result is never a reservation. **It also says when the wallet already holds a valid copy of the credential** (`alreadyHeld`, with `ready: false`): it looks at the credentials the wallet has saved, locally and for free, before pricing anything, so the agent stops with "you already have one" instead of asking for an agent name. Buying another would replace it (one credential per template) and costs the fee; only when the user explicitly wants a replacement is it run again with `replacing: true`. An expired credential does not count, and a lookup that fails is reported in `notChecked`, never as "nothing is held". When a user asks for an AI Birthcert without saying Basic or Verified, every result carries a `choice` note: the agent checks both kinds, explains the difference, and asks which they prefer; if they want both, it issues them one after the other, Basic first. | `{ credential, agentName?, replacing? }` | `{ credential, ready, fee?, balances, cap?, schema?, alreadyHeld?, blockers, notChecked, choice? }` |
23
+ | `wallet_status` | Report holder DID/address/network + held VCs (client-supplied, or the local cache); optionally one token balance (`token`) or several in one call (`tokens`). Each balance carries `balance` (raw base units), `decimals` and `display` — the same amount in whole tokens with its symbol. Quote `display`; a raw count beside a ticker is wrong by orders of magnitude. When `decimals` is `null`, `display` is the raw base-unit count, not whole tokens: say the amount could not be converted. A token may be named by ticker **or** contract address, and a ZTP20 result also carries its contract address as `tokenAddress` — the value a policy needs, so it never has to be asked for | `{ heldCredentials?, token?, tokens? }` | `{ holderDid, zetrixAddress, network, credentials, balances?, tokenBalance?, tokenBalances? }` |
24
+ | `credential_preflight` | **Free.** The first call for any credential — composes the quote, the balances and the spending cap into one answer, before a single application field is collected. Spends nothing, starts nothing. `blockers` lists *every* reason the wallet is not ready, at once; `notChecked` lists what preflight cannot know — agent-name availability is decided by myid at issuance, after payment, so a `ready` result is never a reservation. **It also says when the wallet already holds a valid copy of the credential** (`alreadyHeld`, with `ready: false`): it looks at the credentials the wallet has saved, locally and for free, before pricing anything, so the agent stops with "you already have one" instead of asking for an agent name. Buying another would replace it (one credential per template) and costs the fee; only when the user explicitly wants a replacement is it run again with `replacing: true`. An expired credential does not count, and a lookup that fails is reported in `notChecked`, never as "nothing is held". When a user asks for an AI Birthcert without saying Basic or Verified, every result carries a `choice` note: the agent checks both kinds, explains the difference, and asks which they prefer; if they want both, it issues them one after the other, Basic first. | `{ credential, agentName?, replacing? }` | `{ credential, ready, fee?, balances, cap?, schema?, alreadyHeld?, unusedPayment?, blockers, notChecked, choice? }` — `unusedPayment: { agentName, linkOpen, note }` (Verified only) means an earlier payment has not been used yet and the next link reuses it, so balance and spending limit do not gate and no fee is asked for |
25
25
  | `prove_identity` | Answer an x401 `PROOF-REQUEST` → return the `PROOF-RESPONSE` header to replay | `{ proofRequest, vc?, revealAttribute?, issuerKeys? }` | `{ proofResponseHeader, verified, presentationId }` |
26
26
  | `create_verification_qr` | Create a link and a QR code that open this wallet's credential in the MyID app, for a human to verify the agent. Presents the credential to MBI — with no `vc` given, the Verified AI Birthcert if the wallet holds one, otherwise the Basic AI Birthcert, and if it holds neither it answers `created: false` and says to create one first. The link holds only the reference id, but that id grants access to whatever is revealed until it expires (5 minutes by default, up to 60), so by default it reveals only a standard minimal set (Verified: `agentName`, `evidenceProvider`, `ownerVerified`; Basic: `agentUsername`) — name other attributes with `revealAttribute`, and pass `revealAll: true` only when the user explicitly asks to reveal everything; show the link only to the human user. The QR code comes back as an image and as a PNG file (`qrCodePath`) that a chat channel such as WhatsApp can attach; a file is removed when the wallet starts and the next time a QR is made, once its link has expired. The file is on the wallet's own machine, so a client that runs the wallet remotely cannot read the path and gets the image block and the link instead. The agent is told never to send another image as the QR code, and to give the link and say so if it cannot attach one. When the credential has a saved pass design, the result also carries its path (`vcPassImagePaths`), so the agent can show both pictures, each captioned for what it is. A Basic credential does not mean the owner was verified, and the result says so. Needs `MYID_VERIFY_LINK_TEMPLATE`; without it the tool answers `created: false` before anything is created | `{ vc?, revealAttribute?, revealAll?, expiryMinutes? }` | `{ created, link, referenceId, expiresAt, expiresInMinutes, revealed (the attribute paths, or `"all"`), credentialUsed, revealedByDefault, qrCodePngBase64 (sent as an image; absent with a `qrError` if it could not be drawn), message }` or `{ created: false, reason }` |
27
27
  | `pay_and_fetch` | Fetch a URL, auto-pay with x402 (self-pay via Wallet BE) on `402` | `{ url, method?, headers?, body? }` | `{ status, body, paymentMade, amountPaid, amountPaidHuman, asset }` |
28
- | `subscribe_and_issue` | Reuse a cached VC if still valid, else pay x402 → MBI issues → return the VC. Replacing a still-valid cached VC needs `forceReissue: true` **and** `confirmReplaceExistingVc` set to the exact `vcId` you were shown first — `forceReissue` alone only shows you the existing VC (`issued: false`), it never pays | `{ templateId, attributes, expirationDate?, dryRun?, forceReissue?, confirmReplaceExistingVc? }` | `{ issued, vcId, vc, txHash, fromCache?, schema?, originalPayment?, paymentAttempted?, recovery?, vcPassImagePaths? }` — `issued: false` with `fromCache: true` means an existing valid VC is being shown, not an error |
28
+ | `subscribe_and_issue` | Reuse a cached VC if still valid, else pay x402 → MBI issues → return the VC. Replacing a still-valid cached VC needs `forceReissue: true` **and** `confirmReplaceExistingVc` set to the exact `vcId` you were shown first — `forceReissue` alone only shows you the existing VC (`issued: false`), it never pays | `{ templateId, attributes, expirationDate?, dryRun?, forceReissue?, confirmReplaceExistingVc?, confirm? }` (`confirm` is the `quote` from a `needsConfirmation` result, passed back unchanged; anything else, including `true`, returns `{ issued: false, needsConfirmation: true, quote, askUser, nextStep }` and signs, pays and issues nothing; a confirmed call whose payment request differs from the quote in token, amount, payee or gas model is not paid either) | `{ issued, vcId, vc, txHash, fromCache?, alreadyHeld?, differsFromRequest?, schema?, originalPayment?, paymentAttempted?, recovery?, vcPassImagePaths? }` — `issued: false` with `fromCache: true` means an existing valid VC is being shown, not an error: a plain cache hit adds `alreadyHeld: true` (nothing paid, nothing new issued) and, when the held VC carries different values from the ones asked for, `differsFromRequest: [{ attribute, held, requested }]` |
29
29
  | `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 }` |
30
30
  | `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 }` |
31
31
  | `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 }` |
@@ -37,7 +37,7 @@ VC issuance → identity proof → pay-per-use).
37
37
  | `update_policy` | **Pays a real fee, and only with `confirm: true`.** Replace a deployed policy's attributes and validity window over x402. `attributes` is the **full replacement set**, not a patch. An amount may be given as `valueHuman` (whole tokens, `"100"` for 100 JMYR) instead of `value`: the wallet converts it with the token's own decimals, shows both forms, and never sends `valueHuman` on. It is only for the amount attributes (`perTransactionMax`, `cumulativeMax`, `velocityCap`, and any attribute the service lists as an amount in the smallest unit): `maxTransactionCount` is a count and a window is a duration, and neither is ever scaled. Each amount attribute may carry its own `valueHuman`. Never put `convertedAmounts` in `valueHuman`: those are raw base units. A number is accepted only as a safe whole number; write anything else, such as `"0.5"`, as text. An attribute needs a `value` or a `valueHuman`, whatever its type. If `value` and `valueHuman` are both given they must agree, and `amountUnit` does not apply to a `valueHuman`. Read the policy first with `get_my_policy` and pass its `forUpdate.expectedUpdatedAtBlock` (a string): if the policy has changed since, the update is refused for free (`modified`). The policy keeps the template it already references — pass none — and its validity window is kept unless `validFromBlock` / `validToBlock` are given. The same three steps, states and `dryRun` as `write_policy`; `check_policy_write` finishes a paid update from its receipt. Free refusals, each saying nothing was paid: `not_found`, `modified`, `template_unavailable`, `refused`. `written` is the only state that means the policy was updated; `submitted` is **not** (the block has not confirmed it); On the sponsored path `write_failed` means the update was **not** applied, the policy is unchanged and a payment may have been taken. The fee goes through the same payment cap as every other paying tool. **Who pays the gas is the user's choice** (`gasPayer`): `"sponsored"` pays the fee in JMYR and the gas is covered (no ZTX needed), `"self"` costs no fee and only the network gas in ZTX. The wallet has no default for it: when the wallet can pay the gas itself, a call without `gasPayer` stops at `needs_choice` (nothing paid, whatever `confirm` says) with both options, what each costs and whether the wallet can pay it right now, so the user can decide; a value that is neither is refused before the service is contacted. Self-pay is on offer where the policy contracts are deployed (testnet today); elsewhere nothing changes and `"self"` is refused. A self-paid write is the owner's own `createOrUpdatePolicy` call on the Policy Registry, built here and signed through Wallet BE, and the policy service is never contacted. The wallet reads the chain first (a policy that already exists, or one that has changed since you read it, is refused for free), checks the ZTX balance covers the estimated gas plus a reserve (a balance or estimate it cannot read is not a yes), and reports `written` only when the chain then holds exactly what was sent: the same attributes, template and validity window, and for an update `updatedAtBlock` moved. Otherwise it reports `submitted` with the `txHash`: do not send it again, and `get_my_policy` shows when it lands. Wallet BE refusing to sign is a decision (`policyDenied`); its check failing is transient (`policyCheckUnavailable`). Nothing is sent when the node's own simulation rejects the call. Without `confirm: true` it quotes the gas and sends nothing. **A self-paid send also needs `maxGasRaw`**, the `requiredRaw` of the self option the user was shown (the most gas they agreed to): without it, or when the gas now costs more than it, nothing is signed and the refusal gives the current figure to show and what to call with, so the fee limit signed can never exceed what the user saw. **A self-paid send is remembered until its outcome is settled:** the transaction carries an expiry (60 ledgers past the current one), a small note (policy key, nonce, transaction hash, expiry) is written before it is submitted, and a later write or update for the same key is refused, with nothing sent, while the transaction could still land, and so is a sponsored write, the collect of an already-paid write and `remove_policy` (the self-paid transaction would replace the whole policy when it lands, so nothing else may write or remove in the meantime; nothing is paid or removed). The note holds the owner, policy key, operation, nonce, transaction hash, expiry and send time (no secret), is kept per network, and is removed only by its own transaction. Every confirmed write, collect or removal for a key in a process waits for the one in front of it for up to 15 seconds, and then refuses with nothing sent, paid, collected or removed, so a call never acts after its client could have given up. The note is cleared when the transaction is found on chain (landed or failed) or the ledger has passed its expiry, so an outcome that never arrived is settled in bounded time. A send is refused if the ledger number cannot be read or the note cannot be written, and a note that exists but cannot be read (unreadable, empty or damaged, as a crash can leave it) is never read as "no note": the call stops and names the file, which a person can delete once 60 ledgers (about 10 minutes) have passed since it was last changed, or once `get_my_policy` shows the write. A note is deleted only after at least 15 minutes and with the chain producing ledgers. **With `gasPayer "self"` there is no `paymentReceipt` and `check_policy_write` does not apply:** `submitted` means the network gas is spent (do not send it again; `get_my_policy` shows when it lands, and quote the `txHash` if it never does), `unknown` means it may be on chain (do not send it again; quote the nonce), and `write_failed` means the transaction was included and rejected (the gas is spent and nothing was written). The gas is spent even when the write fails on chain. The wallet signs with the fee ceiling it checked the balance against, and refuses (nothing signed) if the node's fresh estimate is above it. A pending paid write for the same `policyKey` blocks a self-paid one (finish it with `check_policy_write` first), and so does an attribute vocabulary that could not be read or that no source is configured for (the vocabulary comes from the policy service, so self-pay needs it), because a self-paid write has no service to catch what it would have. A receipt the service said failed does not hold a self-paid write back; one with an unresolved outcome does. When neither way of paying is available the answer is `unavailable` and names both reasons. A list attribute is capped at 500 entries and a policy at 64 attributes. A wallet that already has a policy can self-pay too: Wallet BE (a current version) signs the owner's own Registry call. A Wallet BE older than that refuses it (`UNSUPPORTED_OPERATION`, a decision made before any policy is consulted), which is reported as final, not about the policy's limits, with no invitation to retry. A self-paid update is a whole-policy replacement that the contract does not compare with what was there, so another write by the same owner landing in the few seconds between the wallet's last chain read and the block that includes the transaction is overwritten (the wallet still reports `written` because its own content is there and `updatedAtBlock` moved). A self-paid write has not yet been run end to end against a live chain. | `{ policyKey, attributes, expectedUpdatedAtBlock, validFromBlock?, validToBlock?, amountUnit?, confirm?, dryRun?, gasPayer?, maxGasRaw?, pollBudgetMs? }` | `{ state, message, paymentReceipt?, policyKey?, txHash?, paid?, payFresh?, quote?, affordability?, interpretation?, gasOptions?, needsConfirmation?, policyDenied?, policyCheckUnavailable?, upstream? }` |
38
38
  | `remove_policy` | **Free, but it lifts every limit the policy set** — Wallet BE then signs spends of that asset without any limit — so nothing is sent without `confirm: true`. Without it you get `needs_confirmation` with what the policy currently limits. `removed` is the only state that means the policy is gone, and it is reported only when a chain read no longer holds it; `submitted` is a removal on its way, **not** a removed policy. Repeating the call never submits a second removal. `not_found` means there was nothing to remove (or it is already gone); `in_progress` means a paid update has not been collected yet (`check_policy_write` first); `refused` naming a self-paid transaction means an earlier self-paid update may still land and would bring the old policy back, so nothing was removed (wait until the ledger it names, or read the policy with `get_my_policy`) | `{ policyKey, confirm?, pollBudgetMs? }` | `{ state, message, policyKey?, txHash?, currentAttributes? }` |
39
39
  | `check_policy_write` | **Free — never pays, on any path.** Finish a policy write or update that has already been paid for, from its `paymentReceipt` (or the most recent pending one when omitted). This is the right answer to “did my policy get created?”; calling `write_policy` again is not. A receipt this wallet does not recognise does **not** mean the write failed — the service completes a paid write on its own, and asking to write the same `policyKey` again reports the truth for free before any payment. When a collect ends with an error that says nothing about the write (a gateway timeout, a 5xx), the wallet reads the chain itself (up to 45 seconds, less when the poll has already used part of its budget; each read is cut off at 10 seconds) and reports `written` only when the chain holds exactly the attributes that were submitted, under the same template (for an update, with `updatedAtBlock` moved). That covers every answer the wallet cannot read: a 5xx, a gateway page, the service's own "unknown", and any other status it does not recognise (a 4xx included); the service's own "the chain rejected it" is never second-guessed. `check_policy_write` looks at the chain first for a receipt that still carries what to verify, so a write that already landed costs no collect. A receipt stops being verified against the chain once the service has said its write failed, or once a newer paid write for the same `policyKey` has been made, and a receipt bought by another owner is never checked against this owner's policy. An error answer is returned apart in `upstream: { status, detail }` (control characters replaced, at most 200 characters) | `{ paymentReceipt?, pollBudgetMs? }` | `{ state, message, paymentReceipt?, policyKey?, txHash?, paid?, payFresh?, upstream? }` |
40
- | `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. **Checks for an existing Verified AI Birthcert VC before paying**: if this holder already has a still-valid one (found in the local cache, or resolved from a previously-issued session the cache never saw), nothing is paid and no session is started — you get `{ existingVerifiedVc: { vcId, validUntil }, message }` instead; call again with `confirmReplaceExistingVc` set to exactly that `vcId` to replace it anyway. An existing VC that has already expired is replaced automatically, no confirmation needed, and the result carries `replacedExpiredVc: { vcId, validUntil }` naming the one it replaced. It can also refuse with `{ error }` specifically because an already-issued credential exists but could not be confirmed as valid or expired — nothing is paid on that path either; relay `error` as given, which names the concrete next step | `{ agentName, agentPurpose?, evidenceAssuranceLevel?, ownerType?, ownerVerified?, gasPayer?, dryRun?, discardStuckReceiptAndPayFresh?, confirmReplaceExistingVc? }` | `{ sessionId, verificationUrl, expiresAt, expiresIn, expiresInSeconds, message, replacedExpiredVc? }` (`expiresIn` / `expiresInSeconds` are worked out by the wallet from its own clock, so an agent quotes `expiresIn` instead of doing timezone arithmetic itself; `message` carries a "Tell the user:" sentence about following up, worded to match how much time is actually left on the link), `{ quote: { asset, maxAmountRequired, payTo?, gasModel } }`, `{ existingVerifiedVc: { vcId, validUntil? }, message }` (nothing paid, see above), or — instead of a session — `{ settlementPending: true, paymentReceipt, message }` after ~90s, whose meaning depends on which flag (if any) is set below: never assume the payment succeeded from this shape alone. While a merely-stuck receipt (outcome still unresolved) is held this tool can only REPLAY it — it never buys a new credential, which is what stops a second charge. A credential-service refusal is different again: `issuerRejected: true` means the service was reached and refused the request (the settlement is NOT what failed); `paymentInvalid: true` means the service specifically ruled the payment or receipt invalid (this one IS about the payment). Neither tells you whether the fee was taken, in either direction. A **void** receipt (SSIVC status_code 67/68) is different again: the wallet discards it itself, with no confirmation and no flag, so the very next call is an ordinary fresh purchase — see `{ error }` below. `discardStuckReceiptAndPayFresh: "<exact receipt id>"` is the way to start over on a merely-stuck receipt instead: 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, **refuses a receipt that is not stuck yet** (younger than `SETTLEMENT_STUCK_AFTER_MS`, 24h by default — enforced by the wallet, so an agent that mistakes a bare "retry" for consent still cannot cause a second payment; nothing is discarded and nothing is paid), and returns `discardedPaymentReceipt` so the lost payment stays traceable. `{ error }` (`RequestVerificationFailure`) also carries `discardedPaymentReceipt` when this call itself discarded a dead receipt — that confirmed stuck-receipt discard, or an unprompted void one, or **both on one call**; it also rides on `settlementPending` results, not only successes. When both happen (the confirmed discard, then the fresh payment ruled void) the field holds the **void** receipt and the id the user confirmed is named at the end of the `error`/`message` text ("ALSO DISCARDED earlier on this same call") — quote both ids to support, not just the field. Retrying is fine only for an insufficient-funds or payment-cap `{ error }` (nothing was paid); any other `{ error }` (already-settled, receipt not saved, receipt void, outcome unknown) may mean money moved, so get the user's explicit agreement before calling again |
40
+ | `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 option is refused before any money moved, the wallet does not switch to paying gas itself on its own: it returns `needsConfirmation` with the self-pay price (its own token and amount, plus ZTX gas) to ask the user about, and pays it only once that quote is confirmed; it never offers a switch 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. **Checks for an existing Verified AI Birthcert VC before paying**: if this holder already has a still-valid one (found in the local cache, or resolved from a previously-issued session the cache never saw), nothing is paid and no session is started — you get `{ existingVerifiedVc: { vcId, validUntil }, message }` instead; call again with `confirmReplaceExistingVc` set to exactly that `vcId` to replace it anyway. An existing VC that has already expired needs no `confirmReplaceExistingVc` (the payment still stops at the price like any other), and a result that went ahead carries `replacedExpiredVc: { vcId, validUntil }` naming the one it replaced. It can also refuse with `{ error }` specifically because an already-issued credential exists but could not be confirmed as valid or expired — nothing is paid on that path either; relay `error` as given, which names the concrete next step | `{ agentName, agentPurpose?, evidenceAssuranceLevel?, ownerType?, ownerVerified?, gasPayer?, dryRun?, discardStuckReceiptAndPayFresh?, confirmReplaceExistingVc?, confirm? }` (`confirm` is the `quote` from a `needsConfirmation` result, passed back unchanged; anything else, including `true`, returns `{ needsConfirmation: true, paidThisCall: false, quote, askUser, nextStep, message }` and pays nothing; a confirmed call whose payment request differs from the quote in token, amount, payee or gas model is not paid either, and the quote's `gasModel` decides which option is paid; a call that pays nothing needs no confirm). Results carry `nextStep` (one of `ask_user_to_confirm_payment`, `show_link_and_wait`, `wait_then_check`, `ask_user_whether_to_replace`, `tell_user_already_held`, `show_credential`, `collect_details`, `confirm_kind_then_collect_details`, `resolve_blockers`, `follow_message`) and, when there is a question to ask, `askUser` | `{ sessionId, verificationUrl, expiresAt, expiresIn, expiresInSeconds, paidThisCall, message, replacedExpiredVc? }` (`paidThisCall` is `true` only when this call sent a payment: the same still-pending link handed back, or a new link after an expired one, reuses the payment already made, which is only used up once the credential is issued, and `message` then opens with "No new payment"; `expiresIn` / `expiresInSeconds` are worked out by the wallet from its own clock, so an agent quotes `expiresIn` instead of doing timezone arithmetic itself; `message` carries a "Tell the user:" sentence about following up, worded to match how much time is actually left on the link), `{ quote: { asset, maxAmountRequired, payTo?, gasModel } }`, `{ existingVerifiedVc: { vcId, validUntil? }, message }` (nothing paid, see above), or — instead of a session — `{ settlementPending: true, paymentReceipt, message }` after ~90s, whose meaning depends on which flag (if any) is set below: never assume the payment succeeded from this shape alone. While a merely-stuck receipt (outcome still unresolved) is held this tool can only REPLAY it — it never buys a new credential, which is what stops a second charge. A credential-service refusal is different again: `issuerRejected: true` means the service was reached and refused the request (the settlement is NOT what failed); `paymentInvalid: true` means the service specifically ruled the payment or receipt invalid (this one IS about the payment). Neither tells you whether the fee was taken, in either direction. A **void** receipt (SSIVC status_code 67/68) is different again: the wallet discards it itself, with no confirmation and no flag, so the very next call is an ordinary fresh purchase — see `{ error }` below. `discardStuckReceiptAndPayFresh: "<exact receipt id>"` is the way to start over on a merely-stuck receipt instead: 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, **refuses a receipt that is not stuck yet** (younger than `SETTLEMENT_STUCK_AFTER_MS`, 24h by default — enforced by the wallet, so an agent that mistakes a bare "retry" for consent still cannot cause a second payment; nothing is discarded and nothing is paid), and returns `discardedPaymentReceipt` so the lost payment stays traceable. `{ error }` (`RequestVerificationFailure`) also carries `discardedPaymentReceipt` when this call itself discarded a dead receipt — that confirmed stuck-receipt discard, or an unprompted void one, or **both on one call**; it also rides on `settlementPending` results, not only successes. When both happen (the confirmed discard, then the fresh payment ruled void) the field holds the **void** receipt and the id the user confirmed is named at the end of the `error`/`message` text ("ALSO DISCARDED earlier on this same call") — quote both ids to support, not just the field. Retrying is fine only for an insufficient-funds or payment-cap `{ error }` (nothing was paid); any other `{ error }` (already-settled, receipt not saved, receipt void, outcome unknown) may mean money moved, so get the user's explicit agreement before calling again |
41
41
  | `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` and the wallet's own `expiresIn` / `expiresInSeconds` (time left, worked out from the wallet clock, never from the agent's) — 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?, issuerRejected?, paymentInvalid?, message? }` — `settlement_pending` means a payment was made and is being followed: without `outcomeUnknown`, `issuerRejected` or `paymentInvalid` the message leads `PAYMENT SENT` and covers **two** states that the following clause tells apart — *"still being processed"* is a confirmed queued settlement (check again shortly), while *"has not been confirmed yet"* means the outcome could not be determined at all yet, so it must not be reported as progressing or as succeeded; either way the receipt is saved, nothing is paid again, and checking again later is the next step; 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. `issuerRejected: true` is a distinct case: the credential service was reached and refused the request (a `4xx` that is not a settlement verdict — measured live as `status_code 99` "Error retrieving ZVG access token"), so the settlement is NOT what failed; `message` quotes what it said, the receipt is kept, no new payment was made, and it can clear once the service recovers. `paymentInvalid: true` is different again: the service has specifically ruled the payment or receipt invalid — this one IS about the payment. Both say nothing about whether the fee was taken, in either direction. `receipt_void` is terminal and different: SSIVC has ruled the receipt finished (status_code 67 expired / 68 failed), so checking again cannot help. The wallet **discards the dead receipt itself** here — no confirmation needed, because a receipt the server will never honour protects nothing and only blocks the next purchase — and returns it as `discardedPaymentReceipt`. It does NOT mean the fee was refunded, and buying the credential means calling `request_ai_birthcert_verification` again, which pays the fee again |
42
42
  | `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. A receipt younger than `SETTLEMENT_STUCK_AFTER_MS` (24h by default) is refused on both steps. 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? }` |
43
43
  | `transfer_token` | **Moves real funds, irreversible.** Send native ZTX or any ZTP20 token to an address. `token` takes `"ZTX"`, a registered symbol (no contract address needed) or a raw ZTP20 address; an unregistered symbol returns `needsTokenAddress: true` and signs nothing. Give `amountHuman` or `amount` — if both, they must agree, which is the cheapest way to catch a 1-vs-1000000 error. Nothing is signed without `confirm: true`, and the wallet never infers it. `outcomeUnknown: true` means the transaction may already be on chain — **do not retry**, check the reported nonce. `policyDenied: true` means the spending policy refused it (Wallet BE errorCode 1000033): a decision, not a failure, so retrying will not help. `policyCheckUnavailable: true` means Wallet BE could not complete the policy check (errorCode 1000034): nothing was signed or sent, it is transient, and trying again shortly is right | `{ token, to, amountHuman?, amount?, confirm?, dryRun? }` | `{ sent, txHash?, nonce, token, asset, amount, amountHuman, decimals, fee, needsConfirmation?, needsTokenAddress?, outcomeUnknown?, policyDenied?, policyCheckUnavailable? }` |
@@ -138,11 +138,13 @@ VC issuance → identity proof → pay-per-use).
138
138
  > | `REQUIRED` / `SETTLED` | Still unresolved when the poll budget ran out — check again later with the `paymentId`. |
139
139
  > | `UNKNOWN` | `/status` itself was unreachable. The charge still stands; retry the lookup, not the payment. |
140
140
 
141
- > `wallet_status({ token })` returns `tokenBalance: { token, balance, decimals }` for `ZTX` or any
141
+ > `wallet_status({ token })` returns `tokenBalance: { token, balance, decimals, display }` for `ZTX` or any
142
142
  > registered ZTP20 symbol (e.g. `JMYR`). `balance` is in the asset's **raw base units** — the same
143
143
  > unit x402 quotes `maxAmountRequired` in, so cap checks and quote comparisons stay integer-only.
144
- > Divide by `10^decimals` to display it: `"473999900"` with `decimals: 6` is `473.9999 JMYR`.
145
- > `decimals` is `null` if the token's `contractInfo` can't be read (the balance is still returned).
144
+ > Quote `display`, the same amount in whole tokens with its symbol: `"473999900"` with `decimals: 6` is
145
+ > `473.9999 JMYR`. `decimals` is `null` if the token's `contractInfo` can't be read, or reads a symbol but has missing or invalid
146
+ > decimals (the balance is still returned); `display` then carries the raw base-unit count, not whole tokens, so report that the amount
147
+ > could not be converted instead of quoting it as a balance.
146
148
  > A failed lookup reports `{ token, error: "query_failed" }` and an unregistered symbol
147
149
  > `{ token, error: "unknown_token" }` — **never a zero balance**, so "0" always means you really
148
150
  > hold nothing rather than that the node was unreachable.
@@ -332,7 +334,7 @@ the `<...>` placeholders (don't commit a filled copy; `mcp.local.json` is gitign
332
334
  ## Example prompts
333
335
 
334
336
  - *"Check my wallet status."* → `wallet_status`
335
- - *"What's my JMYR balance?"* → `wallet_status({ token: "JMYR" })` (divide `balance` by `10^decimals` to display it)
337
+ - *"What's my JMYR balance?"* → `wallet_status({ token: "JMYR" })` (quote `display`, the amount in whole tokens)
336
338
  - *"I got a 401 with this PROOF-REQUEST header — prove my identity and give me the PROOF-RESPONSE to replay."* → `prove_identity`
337
339
  - *"Fetch `https://api.example/data` and pay automatically if it asks."* → `pay_and_fetch`
338
340
  - *"What fields does the AI Birthcert template need?"* → `get_template_schema` (free — do this before issuing)
@@ -31391,7 +31391,7 @@ var import_node_path10 = require("node:path");
31391
31391
  // package.json
31392
31392
  var package_default = {
31393
31393
  name: "agentic-wallet-mcp",
31394
- version: "0.14.2",
31394
+ version: "0.15.0",
31395
31395
  description: "Agent-facing MCP wallet for Zetrix \u2014 orchestrates x401 identity proof, x402 payment, and MBI VC issuance",
31396
31396
  keywords: [
31397
31397
  "mcp",
@@ -40819,7 +40819,7 @@ async function queryTokenBalance(deps, token) {
40819
40819
  }
40820
40820
  const info = await fetchTokenInfo(contractAddress, deps.query);
40821
40821
  const label = byAddress ? info?.symbol ?? asked : symbol;
40822
- const decimals = info?.decimals ?? null;
40822
+ const decimals = info?.decimalsReadable ? info.decimals : null;
40823
40823
  return { token: label, balance, decimals, display: renderDisplay(balance, decimals, label), tokenAddress: contractAddress };
40824
40824
  }
40825
40825
  function renderDisplay(raw, decimals, label) {
@@ -41499,6 +41499,47 @@ function needsNativeGasCheck(accept) {
41499
41499
  return !isSponsored(accept);
41500
41500
  }
41501
41501
 
41502
+ // src/price-confirmation.ts
41503
+ function asPriceConfirmation(value) {
41504
+ if (typeof value === "string") {
41505
+ try {
41506
+ value = JSON.parse(value);
41507
+ } catch {
41508
+ return void 0;
41509
+ }
41510
+ }
41511
+ if (typeof value !== "object" || value === null) return void 0;
41512
+ const v = value;
41513
+ if (typeof v.asset !== "string" || v.asset === "") return void 0;
41514
+ if (typeof v.maxAmountRequired !== "string" || v.maxAmountRequired === "") return void 0;
41515
+ if (v.gasModel !== "sponsored" && v.gasModel !== "self") return void 0;
41516
+ if (v.payTo !== void 0 && typeof v.payTo !== "string") return void 0;
41517
+ return { asset: v.asset, maxAmountRequired: v.maxAmountRequired, payTo: v.payTo ?? "", gasModel: v.gasModel };
41518
+ }
41519
+ function quoteOf(accept) {
41520
+ return {
41521
+ asset: String(accept.asset ?? ""),
41522
+ maxAmountRequired: String(accept.maxAmountRequired ?? ""),
41523
+ payTo: String(accept.payTo ?? ""),
41524
+ gasModel: isSponsored(accept) ? "sponsored" : "self"
41525
+ };
41526
+ }
41527
+ function priceMismatch(confirmed, actual) {
41528
+ const differences = [];
41529
+ if (confirmed.asset !== actual.asset) {
41530
+ differences.push(`the token is ${actual.asset}, not ${confirmed.asset}`);
41531
+ }
41532
+ if (confirmed.maxAmountRequired !== actual.maxAmountRequired) {
41533
+ differences.push(`the amount is ${actual.maxAmountRequired}, not ${confirmed.maxAmountRequired} (raw base units)`);
41534
+ }
41535
+ if (confirmed.payTo !== actual.payTo) differences.push(`the payee is ${actual.payTo || "(none)"}, not ${confirmed.payTo || "(none)"}`);
41536
+ if (confirmed.gasModel !== actual.gasModel) differences.push(`network gas is ${actual.gasModel}, not ${confirmed.gasModel}`);
41537
+ return differences.length > 0 ? differences.join("; ") : void 0;
41538
+ }
41539
+ function gasNote(quote) {
41540
+ return quote.gasModel === "self" && quote.asset !== "ZTX" ? ", plus network gas paid in ZTX" : "";
41541
+ }
41542
+
41502
41543
  // src/zetrix-hex.ts
41503
41544
  function zetrixHexStringToBytes(s) {
41504
41545
  const up = s.toUpperCase();
@@ -41671,7 +41712,59 @@ async function resolveVcPassImagePaths(deps, vcId) {
41671
41712
  return void 0;
41672
41713
  }
41673
41714
  }
41715
+ function differsFromRequest(vc, requested) {
41716
+ const subject = vc?.credentialSubject;
41717
+ if (!requested || typeof subject !== "object" || subject === null) return void 0;
41718
+ const held = subject;
41719
+ const out = [];
41720
+ for (const [attribute, value] of Object.entries(requested)) {
41721
+ if (attribute === "id" || attribute === "agentDid") continue;
41722
+ if (value === void 0 || value === null || value === "" || typeof value === "object") continue;
41723
+ const current = held[attribute];
41724
+ if (current === void 0 || current === null || String(current) !== String(value)) {
41725
+ out.push({ attribute, held: current ?? void 0, requested: value });
41726
+ }
41727
+ }
41728
+ return out.length > 0 ? out : void 0;
41729
+ }
41730
+ async function renderPrice(deps, asset, raw, symbol) {
41731
+ if (raw === "") return `an amount the issuer did not state (${symbol})`;
41732
+ if (deps.formatAssetAmount) {
41733
+ try {
41734
+ return await deps.formatAssetAmount(asset, raw);
41735
+ } catch {
41736
+ }
41737
+ }
41738
+ return `${raw} base units of ${symbol}`;
41739
+ }
41740
+ async function askToConfirmPayment(deps, accepted, schema, options = {}) {
41741
+ const raw = String(accepted.asset ?? "");
41742
+ const symbol = deps.resolveSymbol ? await deps.resolveSymbol(raw) : raw;
41743
+ const quote = { ...quoteOf(accepted), assetSymbol: symbol };
41744
+ const display = await renderPrice(deps, raw, quote.maxAmountRequired, symbol) + gasNote(quote);
41745
+ const price = options.chargeUnknown ? `may cost up to ${display} (this issuer does not say whether it charges)` : `costs ${display}`;
41746
+ return {
41747
+ issued: false,
41748
+ needsConfirmation: true,
41749
+ quote,
41750
+ nextStep: "ask_user_to_confirm_payment",
41751
+ askUser: `Issuing this ${deps.credentialLabel ?? "credential"} ${price}. Shall I go ahead?`,
41752
+ reason: `NOT PAID YET: ${options.lead ?? ""}issuing this ${deps.credentialLabel ?? "credential"} ${price}, and nothing has been paid or issued. Tell the user the price and ask. Only after their explicit yes to this payment, call again with confirm set to this exact quote object (asset, maxAmountRequired, payTo, gasModel, unchanged), keeping every other argument the same (forceReissue and confirmReplaceExistingVc too, if you sent them). confirm: true is not accepted.`,
41753
+ ...schema ? { schema } : {}
41754
+ };
41755
+ }
41756
+ function describeHeld(d) {
41757
+ return d.held === void 0 || d.held === null ? `${d.attribute} not set` : `${d.attribute} "${String(d.held)}"`;
41758
+ }
41759
+ function describeDifference(d) {
41760
+ return `${describeHeld(d)}, not the "${String(d.requested)}" asked for`;
41761
+ }
41674
41762
  async function subscribeAndIssue(deps, opts) {
41763
+ const result = await subscribeAndIssueInner(deps, opts);
41764
+ if (result.nextStep || opts.dryRun && result.quote) return result;
41765
+ return { ...result, nextStep: result.issued ? "show_credential" : "follow_message" };
41766
+ }
41767
+ async function subscribeAndIssueInner(deps, opts) {
41675
41768
  if (!/^did:zid:/.test(opts.templateId)) {
41676
41769
  return { issued: false, reason: `templateId must be a did:zid:... credential-definition id, got "${opts.templateId}"` };
41677
41770
  }
@@ -41707,6 +41800,8 @@ async function subscribeAndIssue(deps, opts) {
41707
41800
  return {
41708
41801
  issued: false,
41709
41802
  reason: `A credential for this template already exists and is still valid` + (cached2.validUntil ? ` until ${cached2.validUntil}` : "") + ` (credential id ${confirmableVcId}). Nothing was paid and no new credential was issued. Show this to the user before doing anything else. Call subscribe_and_issue again with forceReissue set to true AND confirmReplaceExistingVc set to exactly "${confirmableVcId}" only if the user explicitly wants to replace it.`,
41803
+ nextStep: "ask_user_whether_to_replace",
41804
+ askUser: "You already have this credential, and it is still valid. Do you want to replace it?",
41710
41805
  ...cacheHitFields,
41711
41806
  // Overrides cacheHitFields.vcId (which may be absent/non-string on the fallback path
41712
41807
  // above) with the exact id the message just told the caller to echo back.
@@ -41714,7 +41809,20 @@ async function subscribeAndIssue(deps, opts) {
41714
41809
  };
41715
41810
  }
41716
41811
  } else {
41717
- return { issued: true, ...cacheHitFields };
41812
+ const differs = differsFromRequest(cached2.vc, opts.attributes);
41813
+ const until = cached2.validUntil ? ` until ${cached2.validUntil}` : "";
41814
+ const id = typeof cached2.vcId === "string" && cached2.vcId ? ` (credential id ${cached2.vcId})` : "";
41815
+ return {
41816
+ issued: false,
41817
+ alreadyHeld: true,
41818
+ reason: `Already held: this wallet already has a valid credential for this template${until}${id}. Nothing was paid and no new credential was issued. Tell the user they already have it; do not tell the user it was just issued.` + (differs ? ` It differs from what was asked for: it has ${differs.map(describeDifference).join("; ")}. Ask the user whether to keep it or replace it. Replacing it is a new issuance at the current price (credential_preflight with replacing: true shows it): call again with forceReissue set to true to see what would be replaced, and confirm only on the user's explicit yes.` : ""),
41819
+ ...differs ? { differsFromRequest: differs } : {},
41820
+ ...differs ? {
41821
+ nextStep: "ask_user_whether_to_replace",
41822
+ askUser: `You already have this credential, with ${differs.map(describeHeld).join(", ")}. Do you want to keep it, or replace it?`
41823
+ } : { nextStep: "tell_user_already_held" },
41824
+ ...cacheHitFields
41825
+ };
41718
41826
  }
41719
41827
  }
41720
41828
  }
@@ -41771,6 +41879,23 @@ async function subscribeAndIssue(deps, opts) {
41771
41879
  ...schema ? { schema } : {}
41772
41880
  };
41773
41881
  }
41882
+ const confirmed = asPriceConfirmation(opts.confirm);
41883
+ if (!confirmed && deps.mbi.quote) {
41884
+ let quoted2;
41885
+ try {
41886
+ quoted2 = await deps.mbi.quote(opts.templateId, attributes);
41887
+ } catch (err) {
41888
+ if (err instanceof MbiError) {
41889
+ return { issued: false, reason: err.message, httpStatus: err.httpStatus, ...schema ? { schema } : {} };
41890
+ }
41891
+ throw err;
41892
+ }
41893
+ if (quoted2.paymentRequired !== false) {
41894
+ const quotedAccept = quoted2.accepts?.[0];
41895
+ if (!quotedAccept) return { issued: false, reason: "MBI quoted no payment options", ...schema ? { schema } : {} };
41896
+ return askToConfirmPayment(deps, quotedAccept, schema, { chargeUnknown: quoted2.paymentRequired === void 0 });
41897
+ }
41898
+ }
41774
41899
  const data = JSON.stringify([
41775
41900
  deps.passDesignId ? { templateId: opts.templateId, passDesignId: deps.passDesignId, metadata: attributes } : { templateId: opts.templateId, metadata: attributes }
41776
41901
  ]);
@@ -41816,6 +41941,13 @@ async function subscribeAndIssue(deps, opts) {
41816
41941
  }
41817
41942
  const accept = challenge.accepts[0];
41818
41943
  if (!accept) return { issued: false, reason: "MBI 402 returned no payment options", ...schema ? { schema } : {} };
41944
+ if (!confirmed) return askToConfirmPayment(deps, accept, schema);
41945
+ const difference = priceMismatch(confirmed, quoteOf(accept));
41946
+ if (difference) {
41947
+ return askToConfirmPayment(deps, accept, schema, {
41948
+ lead: `the issuer's payment request is not what the user agreed to (${difference}), so nothing was paid. The new price: `
41949
+ });
41950
+ }
41819
41951
  let xPayment;
41820
41952
  try {
41821
41953
  xPayment = await deps.pay(accept);
@@ -42253,11 +42385,51 @@ var TEMPLATE_ALIASES = [
42253
42385
  function normalize2(s) {
42254
42386
  return s.toLowerCase().replace(/[^a-z0-9]/g, "");
42255
42387
  }
42388
+ function saysVerified(word) {
42389
+ if (/^(un|non|not)verif/.test(word)) return false;
42390
+ return word.includes("verif") && !word.includes("verifiable") || word.includes("mydigital") || word === "myid";
42391
+ }
42392
+ function saysBasic(word) {
42393
+ return ["basic", "self", "selfdeclared", "declared"].includes(word) || /^(un|non|not)verif/.test(word);
42394
+ }
42395
+ var NEGATORS = /* @__PURE__ */ new Set(["not", "non", "no", "un", "without"]);
42396
+ var VERIFIED_NEGATORS = /* @__PURE__ */ new Set([...NEGATORS, "self"]);
42397
+ var CLAUSE_BREAKS = /* @__PURE__ */ new Set(["but", "and", "or"]);
42398
+ function negated(words, i, negators) {
42399
+ for (let j = i - 1; j >= 0 && j >= i - 3; j--) {
42400
+ if (CLAUSE_BREAKS.has(words[j])) return false;
42401
+ if (negators.has(words[j])) return true;
42402
+ }
42403
+ return false;
42404
+ }
42405
+ function aiBirthcertKindOfName(input) {
42406
+ if (/^did:zid:/.test(input)) return void 0;
42407
+ if (!normalize2(input).includes("birthcert")) return void 0;
42408
+ const words = input.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
42409
+ let verified = false;
42410
+ let basic = false;
42411
+ words.forEach((w, i) => {
42412
+ if (saysVerified(w)) {
42413
+ if (negated(words, i, VERIFIED_NEGATORS)) basic = true;
42414
+ else verified = true;
42415
+ } else if (saysBasic(w)) {
42416
+ if (negated(words, i, NEGATORS)) verified = true;
42417
+ else basic = true;
42418
+ }
42419
+ });
42420
+ if (verified && basic) return "unclear";
42421
+ return verified ? "verified" : "basic";
42422
+ }
42423
+ function isVerifiedAiBirthcertName(input) {
42424
+ return aiBirthcertKindOfName(input) === "verified";
42425
+ }
42256
42426
  function findEntry(templateId, network) {
42257
42427
  if (/^did:zid:/.test(templateId)) {
42258
42428
  const key = network.includes("testnet") ? "testnet" : "mainnet";
42259
42429
  return TEMPLATE_ALIASES.find((e) => e[key] === templateId);
42260
42430
  }
42431
+ const kind = aiBirthcertKindOfName(templateId);
42432
+ if (kind === "verified" || kind === "unclear") return void 0;
42261
42433
  const normalized = normalize2(templateId);
42262
42434
  return TEMPLATE_ALIASES.find((e) => normalized.includes(e.match));
42263
42435
  }
@@ -42330,6 +42502,7 @@ async function credentialPreflight(deps, input) {
42330
42502
  `This wallet already holds a valid ${found.label}${until}${found.vcId ? `, ${found.vcId}` : ""}. Tell the user, and do not start another: a new one would REPLACE it (the wallet keeps one per template) and costs the fee. Only if the user explicitly says they want a replacement, run this check again with replacing: true.`
42331
42503
  ],
42332
42504
  notChecked: [],
42505
+ nextStep: "tell_user_already_held",
42333
42506
  ...choice
42334
42507
  };
42335
42508
  }
@@ -42344,11 +42517,43 @@ async function credentialPreflight(deps, input) {
42344
42517
  );
42345
42518
  }
42346
42519
  }
42520
+ let unusedPayment;
42521
+ if (isVerified && deps.findUnusedPayment) {
42522
+ try {
42523
+ const found = await deps.findUnusedPayment();
42524
+ if (found) {
42525
+ unusedPayment = { ...found, note: unusedPaymentNote(found) };
42526
+ notChecked.push(
42527
+ "Whether the service still accepts the earlier payment. If it refuses the earlier payment, request_ai_birthcert_verification says so and pays nothing; a new link would then cost the fee again, and the user must agree to that first."
42528
+ );
42529
+ }
42530
+ } catch (e) {
42531
+ notChecked.push(
42532
+ `Whether an earlier payment could be reused could not be checked (${e instanceof Error ? e.message : String(e)}), so the fee below is what a new link would cost if there is none.`
42533
+ );
42534
+ }
42535
+ }
42347
42536
  const quoted2 = isVerified ? await deps.quoteVerified({ agentName: input.agentName?.trim() || placeholderAgentName(), dryRun: true }) : await deps.quoteTemplate(input.credential);
42348
42537
  const quote = quoted2.quote;
42349
42538
  const schema = quoted2.schema;
42350
42539
  if (!quote) {
42351
42540
  const why = String(quoted2.error ?? quoted2.reason ?? "the price could not be read");
42541
+ if (unusedPayment) {
42542
+ return {
42543
+ credential: input.credential,
42544
+ ready: true,
42545
+ balances: [],
42546
+ unusedPayment,
42547
+ nextStep: "confirm_kind_then_collect_details",
42548
+ blockers: [],
42549
+ notChecked: [
42550
+ ...notChecked,
42551
+ `The price could not be read (${why}). It only matters if the earlier payment is refused.`,
42552
+ ...uncheckable(isVerified)
42553
+ ],
42554
+ ...choice
42555
+ };
42556
+ }
42352
42557
  return {
42353
42558
  credential: input.credential,
42354
42559
  ready: false,
@@ -42357,6 +42562,7 @@ async function credentialPreflight(deps, input) {
42357
42562
  ...schema ? { schema } : {},
42358
42563
  blockers: [`Could not price this credential: ${why}`],
42359
42564
  notChecked: [...notChecked, ...uncheckable(isVerified)],
42565
+ nextStep: "resolve_blockers",
42360
42566
  ...choice
42361
42567
  };
42362
42568
  }
@@ -42367,11 +42573,12 @@ async function credentialPreflight(deps, input) {
42367
42573
  const wanted = asset === NATIVE || !selfPaysGas ? [asset] : [asset, NATIVE];
42368
42574
  const balances = await Promise.all(wanted.map((token) => deps.queryTokenBalance(token)));
42369
42575
  const isFree = quote.paymentRequired === false;
42576
+ const noPaymentDue = isFree || unusedPayment !== void 0;
42370
42577
  const feeBalance = balances[0];
42371
42578
  if ("error" in feeBalance) {
42372
42579
  blockers.push(`Could not read the ${asset} balance (${feeBalance.error}) \u2014 retry before paying.`);
42373
- } else if (!isFree && BigInt(feeBalance.balance) < BigInt(requiredRaw)) {
42374
- blockers.push(`Not enough ${feeBalance.token}: the fee is ${renderAmount(requiredRaw, feeBalance)}, the balance is ${feeBalance.display}.`);
42580
+ } else if (!noPaymentDue && BigInt(feeBalance.balance) < BigInt(requiredRaw)) {
42581
+ blockers.push(`Not enough ${feeBalance.token}: the fee is ${renderAmount(requiredRaw, feeBalance)}, the balance is ${renderAmount(feeBalance.balance, feeBalance)}.`);
42375
42582
  }
42376
42583
  const gasNotes = [];
42377
42584
  if (selfPaysGas && asset !== NATIVE) {
@@ -42379,19 +42586,22 @@ async function credentialPreflight(deps, input) {
42379
42586
  if (gas && "error" in gas) {
42380
42587
  const msg = `Could not read the ZTX balance (${gas.error}) \u2014 ZTX pays network gas and is separate from the fee.`;
42381
42588
  if (isFree) gasNotes.push(`${msg} Issuance is free right now, so none is needed unless it becomes chargeable.`);
42589
+ else if (unusedPayment) gasNotes.push(`${msg} The next link reuses an earlier payment, so none is needed for it.`);
42382
42590
  else blockers.push(msg);
42383
42591
  } else if (gas && BigInt(gas.balance) === 0n) {
42384
42592
  if (isFree) {
42385
42593
  gasNotes.push(
42386
42594
  "This wallet holds no ZTX. Issuance is free right now, so no payment is made and no gas is needed; if it becomes chargeable and this wallet pays its own gas, it will need ZTX."
42387
42595
  );
42596
+ } else if (unusedPayment) {
42597
+ gasNotes.push("This wallet holds no ZTX. The next link reuses an earlier payment, so no transaction is sent and no gas is needed for it.");
42388
42598
  } else {
42389
42599
  blockers.push(`No ZTX for network gas. ZTX is separate from the ${feeBalance.token ?? asset} fee and is needed for every transaction.`);
42390
42600
  }
42391
42601
  }
42392
42602
  }
42393
42603
  const cap = deps.policyGoverns && await deps.policyGoverns(asset) ? describePolicyGovernedCap(asset) : describePaymentCap(asset, requiredRaw, deps.caps);
42394
- if (!cap.wouldPass && !isFree) blockers.push(renderCapBlocker(cap, requiredRaw, feeBalance));
42604
+ if (!cap.wouldPass && !noPaymentDue) blockers.push(renderCapBlocker(cap, requiredRaw, feeBalance));
42395
42605
  return {
42396
42606
  credential: input.credential,
42397
42607
  ready: blockers.length === 0,
@@ -42406,14 +42616,25 @@ async function credentialPreflight(deps, input) {
42406
42616
  balances,
42407
42617
  cap,
42408
42618
  ...alreadyHeld ? { alreadyHeld } : {},
42619
+ ...unusedPayment ? { unusedPayment } : {},
42409
42620
  ...schema ? { schema } : {},
42410
42621
  blockers,
42411
42622
  notChecked: [...notChecked, ...gasNotes, ...uncheckable(isVerified, isFree)],
42623
+ // An AI Birthcert carries the `choice` question; a bare collect_details made weaker models skip it (review R1).
42624
+ nextStep: blockers.length > 0 ? "resolve_blockers" : kind ? "confirm_kind_then_collect_details" : "collect_details",
42412
42625
  ...choice
42413
42626
  };
42414
42627
  }
42628
+ function unusedPaymentNote(found) {
42629
+ if (found.settling) {
42630
+ return `No new payment is needed for the next verification link: an earlier payment is still being settled, and request_ai_birthcert_verification follows that payment rather than paying again. Do not ask the user to agree to a fee for it. Its outcome is not known yet, so do not tell the user it succeeded; call check_ai_birthcert_verification to follow it (it was for the name "${found.agentName}").`;
42631
+ }
42632
+ const head = "No new payment is needed for the next verification link: an earlier payment has not been used yet (it is only used up once the credential is issued), and request_ai_birthcert_verification reuses it. Do not ask the user to agree to a fee for it, and do not count it as another payment. The fee below is what a link costs when there is no earlier payment.";
42633
+ return found.linkOpen ? `${head} Its link is still open: calling with the same name, "${found.agentName}", hands back that same link, and a different name is refused until it ends.` : `${head} Its link has ended, so the next link can use any name the user chooses.`;
42634
+ }
42415
42635
  function renderAmount(raw, balance) {
42416
- if ("error" in balance || balance.decimals === null) return raw;
42636
+ if ("error" in balance) return raw;
42637
+ if (balance.decimals === null) return `${raw} base units of ${balance.token}`;
42417
42638
  const d = BigInt(10) ** BigInt(balance.decimals);
42418
42639
  const whole = BigInt(raw) / d;
42419
42640
  const frac = (BigInt(raw) % d).toString().padStart(balance.decimals, "0").replace(/0+$/, "");
@@ -46180,6 +46401,15 @@ function defaultRevealFor(kind) {
46180
46401
  }
46181
46402
 
46182
46403
  // src/mcp-tools.ts
46404
+ function unclearBirthcertKind(name) {
46405
+ return `"${name}" does not say clearly which AI Birthcert it means: it has both Basic and Verified wording. Ask the user whether they want the Basic (self-declared) one or the Verified (MyDigital ID) one, then use "AI Birthcert" or "verified_ai_birthcert".`;
46406
+ }
46407
+ function unclearBirthcertIssue(name) {
46408
+ return { issued: false, reason: `${unclearBirthcertKind(name)} Nothing was paid.`, nextStep: "follow_message" };
46409
+ }
46410
+ function unclearBirthcertPreflight(name) {
46411
+ return { credential: name, ready: false, balances: [], blockers: [unclearBirthcertKind(name)], notChecked: [], nextStep: "resolve_blockers" };
46412
+ }
46183
46413
  async function loadValidCachedCredentials(cache) {
46184
46414
  if (!cache) return [];
46185
46415
  const all = await cache.list();
@@ -46313,6 +46543,13 @@ function createTools(deps) {
46313
46543
  return payAndFetch(deps.payer, input);
46314
46544
  },
46315
46545
  async subscribe_and_issue(input) {
46546
+ if (aiBirthcertKindOfName(input.templateId) === "unclear") return unclearBirthcertIssue(input.templateId);
46547
+ if (isVerifiedAiBirthcertName(input.templateId)) {
46548
+ return {
46549
+ issued: false,
46550
+ reason: `"${input.templateId}" is the Verified AI Birthcert, which this tool cannot issue: nothing was paid. Use request_ai_birthcert_verification for it. For the self-declared Basic AI Birthcert, use the name "AI Birthcert".`
46551
+ };
46552
+ }
46316
46553
  const resolved = resolveTemplateAlias(input.templateId, deps.config.network);
46317
46554
  const templateId = resolved ?? input.templateId;
46318
46555
  const attributes = deriveTemplateAttributes(templateId, deps.config.network, input.attributes ?? {});
@@ -46321,8 +46558,10 @@ function createTools(deps) {
46321
46558
  return { issued: false, reason: errors.join("; ") };
46322
46559
  }
46323
46560
  const passDesignId = resolvePassDesignId(templateId, deps.config.network);
46561
+ const basicId = resolveTemplateAlias("ai birthcert", deps.config.network);
46562
+ const credentialLabel = basicId !== void 0 && templateId === basicId ? "Basic AI Birthcert (self-declared, not identity-verified)" : void 0;
46324
46563
  const result = await subscribeAndIssue(
46325
- { ...deps.subscribeDeps, passDesignId },
46564
+ { ...deps.subscribeDeps, passDesignId, ...credentialLabel ? { credentialLabel } : {} },
46326
46565
  { ...input, templateId, attributes }
46327
46566
  );
46328
46567
  if (!result.schema) return result;
@@ -46393,7 +46632,9 @@ function createTools(deps) {
46393
46632
  * balances, the spending cap — into a single verdict, so the balance and the cap stop being two
46394
46633
  * sequential dead ends.
46395
46634
  */
46396
- async credential_preflight(input) {
46635
+ async credential_preflight(rawInput) {
46636
+ if (aiBirthcertKindOfName(rawInput.credential) === "unclear") return unclearBirthcertPreflight(rawInput.credential);
46637
+ const input = isVerifiedAiBirthcertName(rawInput.credential) ? { ...rawInput, credential: VERIFIED_AI_BIRTHCERT } : rawInput;
46397
46638
  if (input.credential === VERIFIED_AI_BIRTHCERT && !deps.verifyAiBirthcert) {
46398
46639
  return {
46399
46640
  credential: input.credential,
@@ -46426,7 +46667,8 @@ function createTools(deps) {
46426
46667
  queryTokenBalance: deps.queryTokenBalance,
46427
46668
  caps: deps.paymentCaps,
46428
46669
  policyGoverns: deps.policyGoverns,
46429
- ...deps.findHeldCredential ? { findHeld: deps.findHeldCredential } : {}
46670
+ ...deps.findHeldCredential ? { findHeld: deps.findHeldCredential } : {},
46671
+ ...deps.verifyAiBirthcert?.findUnusedPayment ? { findUnusedPayment: deps.verifyAiBirthcert.findUnusedPayment } : {}
46430
46672
  },
46431
46673
  input
46432
46674
  );
@@ -48079,6 +48321,14 @@ async function decidePriorSession(deps, agentName) {
48079
48321
  if (status.status === "issued") return { kind: "pay_fresh" };
48080
48322
  return { kind: "replay_receipt", receipt: stored.paymentReceipt, stored };
48081
48323
  }
48324
+ async function findUnusedVerificationPayment(deps) {
48325
+ const stored = await deps.sessionStore.get();
48326
+ if (!stored) return void 0;
48327
+ if (stored.sessionId === "") return { agentName: stored.agentName, linkOpen: false, settling: true };
48328
+ const status = await getConfirmedStatus(deps, stored.sessionId);
48329
+ if (status !== "gone" && status.status === "issued") return void 0;
48330
+ return { agentName: stored.agentName, linkOpen: status !== "gone" && status.status === "pending" };
48331
+ }
48082
48332
  var requestQueue = Promise.resolve();
48083
48333
  function withRequestLock(fn) {
48084
48334
  const run = requestQueue.then(fn, fn);
@@ -48198,6 +48448,8 @@ function ssivcAnswerNote(cause) {
48198
48448
  var TELL_USER_UNCONFIRMED = ` Tell the user: "Your payment was sent, but the credential service has not confirmed it yet, so I cannot say whether it went through. The receipt is saved \u2014 please do not pay again. Please message me again in a few minutes and I will check."`;
48199
48449
  var TELL_USER_QUEUED = ` Tell the user: "Your payment was sent and the settlement is still being processed. The receipt is saved \u2014 please do not pay again. Please message me again in a few minutes and I will check."`;
48200
48450
  var RECHECK_MESSAGE_URGENT_THRESHOLD_SECONDS = 300;
48451
+ var NO_NEW_PAYMENT_REPLAYED = "No new payment was made on this call: this new link uses a payment already made, which is only used up once the credential is issued. Do not tell the user they paid again, and do not count this link as another fee. ";
48452
+ var NO_NEW_PAYMENT_SAME_LINK = "No new payment was made on this call: this is the same link as before, still open, and already paid for. Do not tell the user they paid again. ";
48201
48453
  function noAutoRecheckMessage(expiresAt, now) {
48202
48454
  const expiry = sessionExpiryFields(expiresAt, now);
48203
48455
  if (expiry && expiry.expiresInSeconds === 0) {
@@ -48329,11 +48581,13 @@ async function attemptCandidate(deps, accept, buildBody, onQueued) {
48329
48581
  const initialOutcome = await deps.ssivc.createSessionSettle(await buildBody(), xPayment);
48330
48582
  return resolveSettlement(deps, buildBody, initialOutcome, onQueued);
48331
48583
  }
48332
- async function payAndCreateSession(deps, buildBody, onQueued) {
48584
+ async function payAndCreateSession(deps, buildBody, onQueued, confirmed) {
48333
48585
  const challenge = await deps.ssivc.createSessionChallenge(await buildBody());
48334
- const candidates = orderAccepts(challenge.accepts, deps.gasPreference ?? "sponsored");
48586
+ const candidates = orderAccepts(challenge.accepts, confirmed.gasModel);
48335
48587
  const primary = candidates[0];
48336
48588
  if (!primary) throw new NoPaymentOptionsError("SSIVC 402 returned no usable payment options");
48589
+ const difference = priceMismatch(confirmed, quoteOf(primary));
48590
+ if (difference) throw new PriceChangedError(verificationQuoteOf(primary), difference);
48337
48591
  try {
48338
48592
  return await attemptCandidate(deps, primary, buildBody, onQueued);
48339
48593
  } catch (err) {
@@ -48341,11 +48595,70 @@ async function payAndCreateSession(deps, buildBody, onQueued) {
48341
48595
  if (!fallback || !isSponsored(primary) || isSponsored(fallback) || !isDefinitiveSponsorshipFailure(err)) {
48342
48596
  throw await toFacilitatorInsufficientFundsError(err, primary, deps.formatAssetAmount);
48343
48597
  }
48344
- return await attemptCandidate(deps, fallback, buildBody, onQueued);
48598
+ throw new PriceChangedError(
48599
+ verificationQuoteOf(fallback),
48600
+ "the sponsored option was refused before anything was paid, and the only option left is paying the network gas yourself, at its own price"
48601
+ );
48345
48602
  }
48346
48603
  }
48604
+ function verificationQuoteOf(accept) {
48605
+ const q = quoteOf(accept);
48606
+ return { asset: q.asset, maxAmountRequired: q.maxAmountRequired, ...q.payTo ? { payTo: q.payTo } : {}, gasModel: q.gasModel };
48607
+ }
48608
+ var PriceChangedError = class extends Error {
48609
+ constructor(quote, difference) {
48610
+ super(`price changed: ${difference}`);
48611
+ this.quote = quote;
48612
+ this.difference = difference;
48613
+ this.name = "PriceChangedError";
48614
+ }
48615
+ };
48347
48616
  async function requestAiBirthcertVerification(deps, input) {
48348
- return withSessionExpiry(await requestAiBirthcertVerificationInner(deps, input), deps.now());
48617
+ return withNextStep(withSessionExpiry(await requestAiBirthcertVerificationInner(deps, input), deps.now()));
48618
+ }
48619
+ function withNextStep(result) {
48620
+ if ("needsConfirmation" in result) return result;
48621
+ if ("error" in result) return { ...result, nextStep: "follow_message" };
48622
+ if ("existingVerifiedVc" in result) {
48623
+ const until = result.existingVerifiedVc.validUntil ? `, valid until ${result.existingVerifiedVc.validUntil.slice(0, 10)}` : "";
48624
+ return {
48625
+ ...result,
48626
+ nextStep: "ask_user_whether_to_replace",
48627
+ askUser: `You already have a Verified AI Birthcert${until}. Do you want to replace it?`
48628
+ };
48629
+ }
48630
+ if ("settlementPending" in result) {
48631
+ return { ...result, nextStep: result.issuerRejected || result.paymentInvalid ? "follow_message" : "wait_then_check" };
48632
+ }
48633
+ if ("sessionId" in result) return { ...result, nextStep: "show_link_and_wait" };
48634
+ return result;
48635
+ }
48636
+ async function askToConfirmPayment2(deps, agentName, input, lead = "") {
48637
+ const quoted2 = await quoteVerification(deps, agentName, input);
48638
+ if (!("quote" in quoted2)) return quoted2;
48639
+ return needsConfirmationResult(deps, quoted2.quote, lead);
48640
+ }
48641
+ async function needsConfirmationResult(deps, quote, lead, afterDiscard = false) {
48642
+ const display = await renderPrice2(deps.formatAssetAmount, quote.asset, quote.maxAmountRequired) + gasNote(quote);
48643
+ const keep = afterDiscard ? `The stuck receipt was already discarded on this call, so call again WITHOUT discardStuckReceiptAndPayFresh, keeping every other argument the same.` : `Keep every other argument the same (confirmReplaceExistingVc and discardStuckReceiptAndPayFresh too, if you sent them).`;
48644
+ return {
48645
+ needsConfirmation: true,
48646
+ paidThisCall: false,
48647
+ quote,
48648
+ nextStep: "ask_user_to_confirm_payment",
48649
+ askUser: `Starting the Verified AI Birthcert verification costs ${display}. Shall I go ahead?`,
48650
+ message: `NOT PAID YET: ${lead}starting this verification costs ${display}, and nothing has been paid. Tell the user the price and ask. Only after their explicit yes to this payment, call again with confirm set to this exact quote object (asset, maxAmountRequired, payTo, gasModel, unchanged). ${keep} A yes to something else, such as the name, is not a yes to the payment; confirm: true is not accepted.`
48651
+ };
48652
+ }
48653
+ async function renderPrice2(formatAssetAmount, asset, raw) {
48654
+ if (raw === "") return `an amount the service did not state (asset "${asset}")`;
48655
+ if (formatAssetAmount) {
48656
+ try {
48657
+ return await formatAssetAmount(asset, raw);
48658
+ } catch {
48659
+ }
48660
+ }
48661
+ return `${raw} base units of asset "${asset}"`;
48349
48662
  }
48350
48663
  async function requestAiBirthcertVerificationInner(deps, input) {
48351
48664
  if (!input.agentName || !input.agentName.trim()) {
@@ -48399,7 +48712,7 @@ async function quoteVerification(deps, agentName, input) {
48399
48712
  }
48400
48713
  };
48401
48714
  }
48402
- async function discardStuckReceiptBeforePaying(deps, confirmReceiptId) {
48715
+ async function discardStuckReceiptBeforePaying(deps, confirmReceiptId, options = {}) {
48403
48716
  const stored = await deps.sessionStore.get();
48404
48717
  if (!stored) {
48405
48718
  return {
@@ -48434,6 +48747,7 @@ async function discardStuckReceiptBeforePaying(deps, confirmReceiptId) {
48434
48747
  }
48435
48748
  };
48436
48749
  }
48750
+ if (options.checkOnly) return { discarded: stored.paymentReceipt };
48437
48751
  await deps.sessionStore.clear();
48438
48752
  return { discarded: stored.paymentReceipt };
48439
48753
  }
@@ -48495,7 +48809,7 @@ async function checkExistingVerifiedVc(deps, input) {
48495
48809
  return { kind: "none" };
48496
48810
  }
48497
48811
  function canCarryReplacedExpiredVc(result) {
48498
- return typeof result === "object" && result !== null && !("error" in result);
48812
+ return typeof result === "object" && result !== null && !("error" in result) && !("needsConfirmation" in result);
48499
48813
  }
48500
48814
  async function requestAiBirthcertVerificationLocked(deps, agentName, input) {
48501
48815
  const existingVc = await checkExistingVerifiedVc(deps, input);
@@ -48510,6 +48824,16 @@ async function requestAiBirthcertVerificationLockedInner(deps, agentName, input)
48510
48824
  if (input.discardStuckReceiptAndPayFresh === void 0) {
48511
48825
  return payOrReplayLocked(deps, agentName, input);
48512
48826
  }
48827
+ if (!asPriceConfirmation(input.confirm)) {
48828
+ const check = await discardStuckReceiptBeforePaying(deps, input.discardStuckReceiptAndPayFresh, { checkOnly: true });
48829
+ if ("refusal" in check) return check.refusal;
48830
+ return askToConfirmPayment2(
48831
+ deps,
48832
+ agentName,
48833
+ input,
48834
+ `receipt ${input.discardStuckReceiptAndPayFresh} has NOT been discarded yet. Starting over forfeits it and `
48835
+ );
48836
+ }
48513
48837
  const outcome = await discardStuckReceiptBeforePaying(deps, input.discardStuckReceiptAndPayFresh);
48514
48838
  if ("refusal" in outcome) return outcome.refusal;
48515
48839
  let result;
@@ -48533,8 +48857,14 @@ async function requestAiBirthcertVerificationLockedInner(deps, agentName, input)
48533
48857
  async function payOrReplayLocked(deps, agentName, input) {
48534
48858
  const decision = await decidePriorSession(deps, agentName);
48535
48859
  if (decision.kind === "still_pending")
48536
- return { ...decision.result, message: noAutoRecheckMessage(decision.result.expiresAt, deps.now()) };
48860
+ return {
48861
+ ...decision.result,
48862
+ paidThisCall: false,
48863
+ message: NO_NEW_PAYMENT_SAME_LINK + noAutoRecheckMessage(decision.result.expiresAt, deps.now())
48864
+ };
48537
48865
  if (decision.kind === "blocked") return { error: decision.message };
48866
+ const confirmed = asPriceConfirmation(input.confirm);
48867
+ if (decision.kind === "pay_fresh" && !confirmed) return askToConfirmPayment2(deps, agentName, input);
48538
48868
  const effectiveOptionalFields = {
48539
48869
  ...decision.kind === "replay_receipt" ? optionalRequestFields(decision.stored) : {},
48540
48870
  ...optionalRequestFields(input)
@@ -48579,7 +48909,9 @@ async function payOrReplayLocked(deps, agentName, input) {
48579
48909
  paid = await payAndCreateSession(
48580
48910
  { ...deps, gasPreference: requestedGasPayer ?? deps.gasPreference },
48581
48911
  buildBody,
48582
- persistQueuedReceipt
48912
+ persistQueuedReceipt,
48913
+ // Set on this path: the gate above returned for an unconfirmed fresh payment.
48914
+ confirmed
48583
48915
  );
48584
48916
  }
48585
48917
  } catch (rawErr) {
@@ -48594,6 +48926,15 @@ async function payOrReplayLocked(deps, agentName, input) {
48594
48926
  };
48595
48927
  }
48596
48928
  if (err instanceof NoPaymentOptionsError) return { error: err.message };
48929
+ if (err instanceof PriceChangedError) {
48930
+ return needsConfirmationResult(
48931
+ deps,
48932
+ err.quote,
48933
+ `the payment request is not what the user agreed to (${err.difference}), so nothing was paid. The new price: `,
48934
+ // payOrReplayLocked runs with this set only after the receipt has been discarded (requestAiBirthcertVerificationLockedInner).
48935
+ input.discardStuckReceiptAndPayFresh !== void 0
48936
+ );
48937
+ }
48597
48938
  if (err instanceof FacilitatorInsufficientFundsError) return { error: err.message };
48598
48939
  if (err instanceof SettlementStillQueuedError) {
48599
48940
  try {
@@ -48612,7 +48953,7 @@ async function payOrReplayLocked(deps, agentName, input) {
48612
48953
  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.`
48613
48954
  };
48614
48955
  }
48615
- return { settlementPending: true, paymentReceipt: err.paymentReceipt, message: err.message };
48956
+ return { settlementPending: true, paymentReceipt: err.paymentReceipt, paidThisCall, message: err.message };
48616
48957
  }
48617
48958
  if (isAgentNameInUse(err)) {
48618
48959
  return { error: agentNameInUseMessage(err, agentName, paidThisCall) };
@@ -48644,6 +48985,7 @@ async function payOrReplayLocked(deps, agentName, input) {
48644
48985
  paymentReceipt: err.paymentReceipt,
48645
48986
  settlementPending: true,
48646
48987
  issuerRejected: true,
48988
+ paidThisCall,
48647
48989
  message: agentNameInUseMessage(err.cause, agentName, paidThisCall, err.paymentReceipt)
48648
48990
  };
48649
48991
  }
@@ -48652,6 +48994,7 @@ async function payOrReplayLocked(deps, agentName, input) {
48652
48994
  paymentReceipt: err.paymentReceipt,
48653
48995
  settlementPending: true,
48654
48996
  paymentInvalid: true,
48997
+ paidThisCall,
48655
48998
  // paidThisCall, because on the fresh-pay route deps.pay already ran on THIS call
48656
48999
  // before SSIVC ruled on the receipt. The builder's default wording denies that.
48657
49000
  message: paymentInvalidMessage(err.cause, err.paymentReceipt, age.humanAge, paidThisCall)
@@ -48662,6 +49005,7 @@ async function payOrReplayLocked(deps, agentName, input) {
48662
49005
  paymentReceipt: err.paymentReceipt,
48663
49006
  settlementPending: true,
48664
49007
  issuerRejected: true,
49008
+ paidThisCall,
48665
49009
  // See the paymentInvalidMessage call just above.
48666
49010
  message: issuerRejectionMessage(err.cause, err.paymentReceipt, age.humanAge, paidThisCall)
48667
49011
  };
@@ -48670,6 +49014,7 @@ async function payOrReplayLocked(deps, agentName, input) {
48670
49014
  return {
48671
49015
  paymentReceipt: err.paymentReceipt,
48672
49016
  settlementPending: true,
49017
+ paidThisCall,
48673
49018
  // The paidThisCall half does not open with "Nothing is lost". A fee was spent on
48674
49019
  // this very call with an outcome nobody knows yet, so "nothing is lost" sitting against that
48675
49020
  // admission reads as reassurance the wallet cannot give. The replay half keeps it: there
@@ -48698,7 +49043,11 @@ async function payOrReplayLocked(deps, agentName, input) {
48698
49043
  // queued writes, so it carries the fields for consistency.
48699
49044
  ...effectiveOptionalFields
48700
49045
  });
48701
- return { ...paid.session, message: noAutoRecheckMessage(paid.session.expiresAt, deps.now()) };
49046
+ return {
49047
+ ...paid.session,
49048
+ paidThisCall,
49049
+ message: (paidThisCall ? "" : NO_NEW_PAYMENT_REPLAYED) + noAutoRecheckMessage(paid.session.expiresAt, deps.now())
49050
+ };
48702
49051
  }
48703
49052
  async function advanceQueuedSettlement(deps, stored) {
48704
49053
  const buildBody = () => buildSessionBody(deps, stored.agentName, {
@@ -49463,17 +49812,17 @@ async function checkAffordability(deps, accept) {
49463
49812
  fee = { status: "unknown", required: required2 };
49464
49813
  problems.push(`Could not read the ${asset} balance (${clip4(String(feeBalance.error), 60)}) \u2014 the fee cannot be checked.`);
49465
49814
  } else if (BigInt(feeBalance.balance) < BigInt(required2)) {
49466
- fee = { status: "short", required: required2, balance: feeBalance.balance, display: feeBalance.display };
49815
+ fee = { status: "short", required: required2, balance: feeBalance.balance, display: renderAmount(feeBalance.balance, feeBalance) };
49467
49816
  problems.push(
49468
- `Not enough ${clip4(feeBalance.token, 40)}: the quote is ${renderAmount(required2, feeBalance)}, the balance is ${clip4(feeBalance.display, 60)}.` + (asset === NATIVE3 ? " The payer needs the amount plus the network fee from this one balance." : "")
49817
+ `Not enough ${clip4(feeBalance.token, 40)}: the quote is ${renderAmount(required2, feeBalance)}, the balance is ${clip4(renderAmount(feeBalance.balance, feeBalance), 60)}.` + (asset === NATIVE3 ? " The payer needs the amount plus the network fee from this one balance." : "")
49469
49818
  );
49470
49819
  } else if (asset === NATIVE3 && BigInt(feeBalance.balance) === BigInt(required2)) {
49471
- fee = { status: "short", required: required2, balance: feeBalance.balance, display: feeBalance.display };
49820
+ fee = { status: "short", required: required2, balance: feeBalance.balance, display: renderAmount(feeBalance.balance, feeBalance) };
49472
49821
  problems.push(
49473
49822
  `Not enough ZTX: the quote is ${renderAmount(required2, feeBalance)} and the balance is exactly that, leaving nothing for the network fee, which the payer needs on top of the amount.`
49474
49823
  );
49475
49824
  } else {
49476
- fee = { status: "enough", required: required2, balance: feeBalance.balance, display: feeBalance.display };
49825
+ fee = { status: "enough", required: required2, balance: feeBalance.balance, display: renderAmount(feeBalance.balance, feeBalance) };
49477
49826
  }
49478
49827
  let gas;
49479
49828
  if (!gasBalance) {
@@ -49511,17 +49860,24 @@ async function checkAffordability(deps, accept) {
49511
49860
  // src/index.ts
49512
49861
  var packageVersion = package_default.version;
49513
49862
  var POLICY_HTTP_TIMEOUT_MS = 6e4;
49863
+ var CONFIRM_QUOTE_PROPERTIES = {
49864
+ asset: { type: "string", description: "Exactly as the quote gave it." },
49865
+ maxAmountRequired: { type: "string", description: "Exactly as the quote gave it, in raw base units." },
49866
+ payTo: { type: "string", description: "Exactly as the quote gave it; leave it out only if the quote had none." },
49867
+ gasModel: { type: "string", enum: ["sponsored", "self"], description: "Exactly as the quote gave it." },
49868
+ assetSymbol: { type: "string", description: "For display only, when the quote has it; ignored when the price is checked." }
49869
+ };
49514
49870
  function buildToolList() {
49515
49871
  return [
49516
49872
  {
49517
49873
  name: "wallet_status",
49518
- description: "Report the holder DID/address/network and the client-supplied held credentials. With `token`, also returns that token's balance and its contract address (`tokenAddress`) \u2014 the value a policy's tokenAddress needs, so the user never has to be asked for it.",
49874
+ description: "Report the holder DID/address/network and the client-supplied held credentials. With `token`, also returns that token's balance and its contract address (`tokenAddress`) \u2014 the value a policy's tokenAddress needs, so the user never has to be asked for it. Report a balance to the user as its `display` amount only.",
49519
49875
  inputSchema: {
49520
49876
  type: "object",
49521
49877
  properties: {
49522
49878
  heldCredentials: { type: "array", items: { type: "object" }, description: "VCs the client holds. Omit to report whatever the wallet has cached locally from prior subscribe_and_issue calls instead." },
49523
- token: { type: "string", description: 'Optional token symbol (e.g. "ZTX", "JMYR") OR a ZTP20 contract address, to check its balance for the active network alongside the usual status fields. Returns { balance, decimals, display } \u2014 `balance` is in the asset\'s raw base units and `display` is the same amount in whole tokens with its symbol (e.g. balance "473999900", decimals 6, display "473.9999 JMYR"). Quote a `display` value to the user, never a bare `balance`. A failed lookup reports { error: "query_failed" } rather than a zero balance; an unrecognised name reports { error: "unknown_token" }.' },
49524
- tokens: { type: "array", items: { type: "string" }, description: 'Several tokens (symbols and/or ZTP20 contract addresses) in one call, returned as `tokenBalances` in the order asked. Prefer this over repeated single-token calls when checking affordability: a credential fee and the native ZTX needed for gas are separate balances, and asking one at a time is how "you hold the token but no gas" is discovered only after the first shortfall was already fixed. Each entry carries its own result or error, so one failure does not hide the rest.' }
49879
+ token: { type: "string", description: 'Optional token symbol (e.g. "ZTX", "JMYR") OR a ZTP20 contract address, to check its balance for the active network alongside the usual status fields. Returns { balance, decimals, display } \u2014 `balance` is in the asset\'s raw base units and `display` is the same amount in whole tokens with its symbol (e.g. balance "473999900", decimals 6, display "473.9999 JMYR"). Quote only the `display` value to the user, never a bare `balance`, and never add the base units or the decimals unless the user asks. When `decimals` is null, `display` is still in raw base units, so say the amount could not be converted to whole tokens rather than quoting it as a balance. A failed lookup reports { error: "query_failed" } rather than a zero balance; an unrecognised name reports { error: "unknown_token" }.' },
49880
+ tokens: { type: "array", items: { type: "string" }, description: 'Several tokens (symbols and/or ZTP20 contract addresses) in one call, returned as `tokenBalances` in the order asked. Prefer this over repeated single-token calls when checking affordability: a credential fee and the native ZTX needed for gas are separate balances, and asking one at a time is how "you hold the token but no gas" is discovered only after the first shortfall was already fixed. Each entry carries its own result or error, so one failure does not hide the rest. Report each entry as for `token`: quote only its `display`, and when its `decimals` is null say the amount could not be converted to whole tokens.' }
49525
49881
  }
49526
49882
  }
49527
49883
  },
@@ -49898,7 +50254,7 @@ function buildToolList() {
49898
50254
  },
49899
50255
  {
49900
50256
  name: "subscribe_and_issue",
49901
- description: "Obtain a VC from MBI: build the signed payload, pay x402, and return the issued credential. If a spending policy refuses the fee, the result has `policyDenied: true` (a decision: do not retry) or `policyCheckUnavailable: true` (transient: trying again shortly is right); either way nothing was paid and no credential was issued. If a still-valid credential for this templateId is already cached locally, it is returned directly with no payment (fromCache: true). Setting forceReissue:true alone does NOT pay again \u2014 it only asks to be shown that existing credential first: you get back { issued: false, vcId, vc, fromCache: true, reason } instead, and nothing is paid. Show that existing credential to the user and ask whether they actually want to replace it; only call this tool again, with BOTH forceReissue:true and confirmReplaceExistingVc set to exactly the vcId you were shown, if they explicitly say yes. Never pass confirmReplaceExistingVc on your own judgement. When nothing valid is cached, forceReissue has no effect either way \u2014 that case has always paid and issued fresh. When a call DOES render a full credential to the user (a fresh issuance, a plain cache hit, or a confirmed replace), show every claim under vc.credentialSubject as a table \u2014 not a cherry-picked subset \u2014 labelling each key by title-casing it (the exact claim set varies by template; use schema.required/optional to know what to expect), plus vcId and validUntil if present. If vcPassImagePaths is also present, the credential's own pass-design image(s) came back attached to this result \u2014 tell the user their credential's official pass design is shown below and display the image(s); do not silently drop them from your summary just because they are not text. Payment is asset-agnostic \u2014 MBI's 402 challenge may quote the native ZETRIX token or a ZTP20 token (e.g. JMYR); pass dryRun:true first to see the quoted asset/amount for free before committing to pay. What a call actually cost is reported precisely: paidAsset/amountPaid are set ONLY when this call paid, a cache hit reports the earlier charge under originalPayment instead (never as amountPaid, so summing spend cannot double-count), and any failure after the payment has settled on chain reports paymentAttempted: { asset, amount, paymentId }. Two such failures exist and mean different things: MBI 4006 is a definitive facilitator rejection, while 4012 (HTTP 502) means the outcome is INDETERMINATE \u2014 the payment may well have landed. On 4012 the wallet automatically polls MBI's recovery endpoint and reports recovery: { status, txHash?, vcId?, polls }, where status is ISSUED (the credential exists after all \u2014 fetch it by vcId, since recovery returns no VC body), FAILED, or REQUIRED/SETTLED (still unresolved) / UNKNOWN (recovery itself unreachable). NEVER retry a payment after either failure: the funds may already be gone, and a retry charges the full amount again \u2014 look the paymentId up instead. Every response also includes { schema: { required, optional } } whenever the template schema could be read from chain \u2014 the template's full declared attribute list, so you see the complete field list, not just what went wrong. For the AI Birthcert specifically: this issues the BASIC one \u2014 self-declared by the agent, agent-paid via x402, owner identity NOT identity-verified. If the user asked for a \"verified\" AI birthcert (owner identity confirmed via MyDigital ID), use request_ai_birthcert_verification instead \u2014 this tool cannot produce that credential. If the user did not say which kind, ask first: see the `choice` note in credential_preflight.",
50257
+ description: "Obtain a VC from MBI: build the signed payload, pay x402, and return the issued credential. If a spending policy refuses the fee, the result has `policyDenied: true` (a decision: do not retry) or `policyCheckUnavailable: true` (transient: trying again shortly is right); either way nothing was paid and no credential was issued. If a still-valid credential for this templateId is already cached locally, it is returned with no payment as { issued: false, alreadyHeld: true, fromCache: true, reason }: the user already has it, so never tell the user it was just issued. If it carries different values from the ones asked for (a different username, say), `differsFromRequest` lists them: tell the user and ask whether to keep it or replace it. Setting forceReissue:true alone does NOT pay again \u2014 it only asks to be shown that existing credential first: you get back { issued: false, vcId, vc, fromCache: true, reason } instead, and nothing is paid. Show that existing credential to the user and ask whether they actually want to replace it; only call this tool again, with BOTH forceReissue:true and confirmReplaceExistingVc set to exactly the vcId you were shown, if they explicitly say yes. Never pass confirmReplaceExistingVc on your own judgement. When nothing valid is cached, forceReissue has no effect either way \u2014 that case has always paid and issued fresh. When a call DOES render a full credential to the user (a fresh issuance, a plain cache hit, or a confirmed replace), show every claim under vc.credentialSubject as a table \u2014 not a cherry-picked subset \u2014 labelling each key by title-casing it (the exact claim set varies by template; use schema.required/optional to know what to expect), plus vcId and validUntil if present. If vcPassImagePaths is also present, the credential's own pass-design image(s) came back attached to this result \u2014 tell the user their credential's official pass design is shown below and display the image(s); do not silently drop them from your summary just because they are not text. Payment is asset-agnostic \u2014 MBI's 402 challenge may quote the native ZETRIX token or a ZTP20 token (e.g. JMYR); pass dryRun:true first to see the quoted asset/amount for free before committing to pay. What a call actually cost is reported precisely: paidAsset/amountPaid are set ONLY when this call paid, a cache hit reports the earlier charge under originalPayment instead (never as amountPaid, so summing spend cannot double-count), and any failure after the payment has settled on chain reports paymentAttempted: { asset, amount, paymentId }. Two such failures exist and mean different things: MBI 4006 is a definitive facilitator rejection, while 4012 (HTTP 502) means the outcome is INDETERMINATE \u2014 the payment may well have landed. On 4012 the wallet automatically polls MBI's recovery endpoint and reports recovery: { status, txHash?, vcId?, polls }, where status is ISSUED (the credential exists after all \u2014 fetch it by vcId, since recovery returns no VC body), FAILED, or REQUIRED/SETTLED (still unresolved) / UNKNOWN (recovery itself unreachable). NEVER retry a payment after either failure: the funds may already be gone, and a retry charges the full amount again \u2014 look the paymentId up instead. Every response also includes { schema: { required, optional } } whenever the template schema could be read from chain \u2014 the template's full declared attribute list, so you see the complete field list, not just what went wrong. For the AI Birthcert specifically: this issues the BASIC one \u2014 self-declared by the agent, agent-paid via x402, owner identity NOT identity-verified. If the user asked for a \"verified\" AI birthcert (owner identity confirmed via MyDigital ID), use request_ai_birthcert_verification instead \u2014 this tool cannot produce that credential. If the user did not say which kind, ask first: see the `choice` note in credential_preflight. Without a confirmed price a chargeable issuance stops at the price and signs, pays and issues nothing: you get { issued: false, needsConfirmation: true, quote, askUser, reason }. Put the askUser question to the user, and only after their explicit yes call again with confirm set to that quote object, unchanged (confirm: true is not accepted). If the issuer's payment request then differs from it in token, amount, payee or gas model, nothing is paid and you get needsConfirmation again with the new price. A free issuance, and a credential already held, need no confirm. Results may carry nextStep (what to do next) and askUser (the question to put to the user).",
49902
50258
  inputSchema: {
49903
50259
  type: "object",
49904
50260
  properties: {
@@ -49919,9 +50275,15 @@ function buildToolList() {
49919
50275
  type: "boolean",
49920
50276
  description: "Alone, this does NOT pay again \u2014 if a valid credential is already cached it only returns that credential (issued: false, fromCache: true, reason) so it can be shown to the user first. Pair it with confirmReplaceExistingVc to actually pay and reissue over a still-valid one. Has no effect when nothing valid is cached \u2014 that case always pays and issues fresh regardless."
49921
50277
  },
50278
+ confirm: {
50279
+ type: "object",
50280
+ properties: CONFIRM_QUOTE_PROPERTIES,
50281
+ required: ["asset", "maxAmountRequired", "gasModel"],
50282
+ description: "The user's explicit yes to the price: the quote object from a needsConfirmation result, passed back unchanged. Anything else, including true, stops at the price (needsConfirmation: true) and nothing is signed, paid or issued. Pass it only after the user has seen that price and said yes to it, never on your own judgement: a yes to something else, such as the name, is not a yes to the payment."
50283
+ },
49922
50284
  confirmReplaceExistingVc: {
49923
50285
  type: "string",
49924
- description: "Only for replacing a cached credential that is STILL VALID (not expired). Pass the exact vcId from a prior forceReissue response \u2014 never a guess, never made up. Only set this after the user has SEEN that existing credential and explicitly asked to replace it; omit it otherwise, and never set it without forceReissue also being true. A mismatched value is ignored: nothing is paid and you get the same { issued: false, reason } response again, naming the id to confirm. Setting it without forceReissue does nothing at all either way \u2014 that case is a plain cache hit ({ issued: true }) regardless of what this field is set to."
50286
+ description: "Only for replacing a cached credential that is STILL VALID (not expired). Pass the exact vcId from a prior forceReissue response \u2014 never a guess, never made up. Only set this after the user has SEEN that existing credential and explicitly asked to replace it; omit it otherwise, and never set it without forceReissue also being true. A mismatched value is ignored: nothing is paid and you get the same { issued: false, reason } response again, naming the id to confirm. Setting it without forceReissue does nothing at all either way \u2014 that case is a plain cache hit ({ issued: false, alreadyHeld: true }) regardless of what this field is set to."
49925
50287
  }
49926
50288
  },
49927
50289
  required: ["templateId", "attributes"]
@@ -49929,7 +50291,7 @@ function buildToolList() {
49929
50291
  },
49930
50292
  {
49931
50293
  name: "request_ai_birthcert_verification",
49932
- 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. If a spending policy refuses the session fee, the result is { error } with `policyDenied: true` (a decision: do not retry) or `policyCheckUnavailable: true` (transient: trying again shortly is right); either way nothing was paid and no session was created. Returns { sessionId, verificationUrl, expiresAt, expiresIn, expiresInSeconds, message } \u2014 show verificationUrl to the human owner and ask them to open it and complete MyDigital ID verification (typically finishes in seconds). Tell them how long the link is good for by quoting `expiresIn` exactly as given: the wallet works it out from its own clock, so never calculate the time remaining yourself from `expiresAt` \u2014 your clock and timezone may differ from the server\'s. `message` here contains a "Tell the user:" sentence about following up \u2014 relay it rather than composing your own reassurance. Once they confirm they are done, call check_ai_birthcert_verification to see whether the credential was issued. Nothing keeps running after this call returns \u2014 there is no background polling, webhook, or timer, so you can only check status when the user sends you a new message. NEVER tell the user you have "set up an automation" or that you will "alert them" when it completes \u2014 you cannot act on your own between messages, and promising that leaves them waiting for a notification that will never come. Instead, tell them how long the link is good for and ask them to message you back once done (or after a few minutes) so you can check_ai_birthcert_verification again. ALWAYS ask the human owner directly what agentName they want to use for a NEW session \u2014 NEVER invent, guess, or silently choose one yourself. (Calling again with the SAME name to resume an already-pending session, as described below, is not "inventing" one \u2014 that is reusing the exact name the owner already gave.) 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, which by default allows exactly 1 JMYR per call. Set MAX_PAYMENT_AMOUNT to override it; a spending policy that governs the asset replaces the default cap. It can return { error: "..." } instead of a session. Read `message` before deciding what to tell the user or whether retrying is safe \u2014 it does NOT always mean nothing was paid. For insufficient funds or a payment-cap block specifically, nothing is created and nothing was paid, so once the underlying problem is fixed (e.g. the user tops up), calling this tool again is the right next step. Several OTHER { error } shapes mean the opposite: a payment may already have been sent on a prior or even this call (blob_already_settled, a receipt that could not be saved locally, RECEIPT VOID, OUTCOME UNKNOWN) \u2014 for any of those, calling this tool again pays the fee AGAIN, so do NOT retry without explicit agreement from the user, exactly as `message` itself will say. It can also return { settlementPending: true, paymentReceipt, message } instead \u2014 a payment WAS SENT and no session exists yet. That is NOT a failure and NOT an error, but do NOT pay again and do NOT call this tool again to retry it \u2014 call check_ai_birthcert_verification to follow it through instead. It covers three different situations, told apart by the `issuerRejected` and `paymentInvalid` fields. With neither set, do NOT assume the payment is known to have succeeded \u2014 this shape covers TWO different states that look identical from these fields alone: usually the settlement is genuinely still clearing (the message says "still being processed"), but sometimes the outcome could not be determined at all yet (the message says "has not been confirmed yet" and does not say "still being processed") \u2014 an indeterminate state, not a confirmed one, even though neither flag is set. Either way the receipt is saved and you must not pay again, so report it as "payment sent, still settling" without promising the user it definitely succeeded. When the message contains a "Tell the user:" sentence, relay that sentence rather than composing your own reassurance, and if asked what the service said, quote only what the message says it answered. With issuerRejected: true the credential service was reached and refused the request \u2014 the settlement is NOT what failed, but do NOT describe this as the payment having succeeded either: `message` quotes its reason, and you must relay that reason rather than describing it as still settling or as a success. With paymentInvalid: true the service has specifically ruled the payment or receipt invalid \u2014 do NOT say "the settlement is not what failed" for this one, since this verdict IS about the payment; relay what it said instead, and do NOT describe it as a success either. Neither this case nor issuerRejected tells you whether the fee was taken, in either direction \u2014 never tell the user they were not charged, and just as much, never tell them they WERE charged either. That no-claim rule covers issuerRejected and paymentInvalid specifically. The plain no-flag case above is different again, and NOT simply "known to have succeeded" either \u2014 see the note on it above: it is two states, only one of which is confirmed, so read `message` there too rather than assuming success from the shape alone. If the request does not say which kind of AI Birthcert the user wants, do not start this: run credential_preflight for both kinds and ask which they prefer (see its `choice` note), and use this tool only for the Verified kind; if they want the self-declared, non-verified Basic one, use subscribe_and_issue for that. BEFORE this pays or starts a session, it checks whether this holder already has a Verified AI Birthcert \u2014 found in the local cache, or (if the cache has not seen it yet) resolved from a previously-issued session. If one exists and is STILL VALID, nothing is paid and no session is started \u2014 you get back { existingVerifiedVc: { vcId, validUntil }, message } instead. Show that existing credential to the user and ask whether they actually want to replace it; only call this tool again, with confirmReplaceExistingVc set to exactly existingVerifiedVc.vcId, if they explicitly say yes. Never pass confirmReplaceExistingVc on your own judgement. If the existing VC has already EXPIRED, this proceeds automatically \u2014 no confirmation needed \u2014 and the result carries replacedExpiredVc: { vcId, validUntil } naming the one it replaced. That field can appear on a settled session, on a payment still settling, or on an EARLIER call\'s still-pending session simply being returned unchanged \u2014 so treat it only as "the holder\'s old VC had expired", never as proof that THIS call itself just spent money; read the rest of the result (an error, a settled session, or settlementPending) to know what this call actually did. It can also return { error } specifically because an already-issued VC exists but could not be confirmed as valid or expired (a resolution problem, not a payment problem) \u2014 nothing is paid on that path either; relay `error` as given, since it names the concrete next step (retry check_ai_birthcert_verification, or as a last resort clear_stuck_payment_receipt with the user\'s explicit agreement).',
50294
+ 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. If a spending policy refuses the session fee, the result is { error } with `policyDenied: true` (a decision: do not retry) or `policyCheckUnavailable: true` (transient: trying again shortly is right); either way nothing was paid and no session was created. Returns { sessionId, verificationUrl, expiresAt, expiresIn, expiresInSeconds, paidThisCall, message } \u2014 show verificationUrl to the human owner and ask them to open it and complete MyDigital ID verification (typically finishes in seconds). Tell them how long the link is good for by quoting `expiresIn` exactly as given: the wallet works it out from its own clock, so never calculate the time remaining yourself from `expiresAt` \u2014 your clock and timezone may differ from the server\'s. `message` here contains a "Tell the user:" sentence about following up \u2014 relay it rather than composing your own reassurance. Once they confirm they are done, call check_ai_birthcert_verification to see whether the credential was issued. Nothing keeps running after this call returns \u2014 there is no background polling, webhook, or timer, so you can only check status when the user sends you a new message. NEVER tell the user you have "set up an automation" or that you will "alert them" when it completes \u2014 you cannot act on your own between messages, and promising that leaves them waiting for a notification that will never come. Instead, tell them how long the link is good for and ask them to message you back once done (or after a few minutes) so you can check_ai_birthcert_verification again. ALWAYS ask the human owner directly what agentName they want to use for a NEW session \u2014 NEVER invent, guess, or silently choose one yourself. (Calling again with the SAME name to resume an already-pending session, as described below, is not "inventing" one \u2014 that is reusing the exact name the owner already gave.) 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. A new link after an expired one does not pay again either: it reuses the payment already made, which is only used up once the credential is issued. Every session result says so in `paidThisCall` (true only when THIS call sent a payment), and `message` opens with "No new payment" when it did not. Count a fee only when paidThisCall is true: never tell the user they paid once per link. This tool spends real funds: it self-pays an x402 challenge, subject to the same credential-issuance payment cap as subscribe_and_issue, which by default allows at most 1 JMYR for one payment. Set MAX_PAYMENT_AMOUNT to override it; a spending policy that governs the asset replaces the default cap. It can return { error: "..." } instead of a session. Read `message` before deciding what to tell the user or whether retrying is safe \u2014 it does NOT always mean nothing was paid. For insufficient funds or a payment-cap block specifically, nothing is created and nothing was paid, so once the underlying problem is fixed (e.g. the user tops up), calling this tool again is the right next step. Several OTHER { error } shapes mean the opposite: a payment may already have been sent on a prior or even this call (blob_already_settled, a receipt that could not be saved locally, RECEIPT VOID, OUTCOME UNKNOWN) \u2014 for any of those, calling this tool again pays the fee AGAIN, so do NOT retry without explicit agreement from the user, exactly as `message` itself will say. It can also return { settlementPending: true, paymentReceipt, message } instead \u2014 a payment WAS SENT and no session exists yet. That is NOT a failure and NOT an error, but do NOT pay again and do NOT call this tool again to retry it \u2014 call check_ai_birthcert_verification to follow it through instead. It covers three different situations, told apart by the `issuerRejected` and `paymentInvalid` fields. With neither set, do NOT assume the payment is known to have succeeded \u2014 this shape covers TWO different states that look identical from these fields alone: usually the settlement is genuinely still clearing (the message says "still being processed"), but sometimes the outcome could not be determined at all yet (the message says "has not been confirmed yet" and does not say "still being processed") \u2014 an indeterminate state, not a confirmed one, even though neither flag is set. Either way the receipt is saved and you must not pay again, so report it as "payment sent, still settling" without promising the user it definitely succeeded. When the message contains a "Tell the user:" sentence, relay that sentence rather than composing your own reassurance, and if asked what the service said, quote only what the message says it answered. With issuerRejected: true the credential service was reached and refused the request \u2014 the settlement is NOT what failed, but do NOT describe this as the payment having succeeded either: `message` quotes its reason, and you must relay that reason rather than describing it as still settling or as a success. With paymentInvalid: true the service has specifically ruled the payment or receipt invalid \u2014 do NOT say "the settlement is not what failed" for this one, since this verdict IS about the payment; relay what it said instead, and do NOT describe it as a success either. Neither this case nor issuerRejected tells you whether the fee was taken, in either direction \u2014 never tell the user they were not charged, and just as much, never tell them they WERE charged either. That no-claim rule covers issuerRejected and paymentInvalid specifically. The plain no-flag case above is different again, and NOT simply "known to have succeeded" either \u2014 see the note on it above: it is two states, only one of which is confirmed, so read `message` there too rather than assuming success from the shape alone. If the request does not say which kind of AI Birthcert the user wants, do not start this: run credential_preflight for both kinds and ask which they prefer (see its `choice` note), and use this tool only for the Verified kind; if they want the self-declared, non-verified Basic one, use subscribe_and_issue for that. Without a confirmed price a call that would pay stops at the price and pays nothing: you get { needsConfirmation: true, paidThisCall: false, quote, askUser, message }. Put the askUser question to the user, and only after their explicit yes call again with confirm set to that quote object, unchanged (confirm: true is not accepted). If the payment request then differs from it in token, amount, payee or gas model, nothing is paid and you get needsConfirmation again with the new price; the quote\'s gasModel decides which option is paid, so if the sponsored option is refused you are given the self-pay price to ask about, never a silent switch. A call that pays nothing (the same open link, or a new link after an expired one) needs no confirm. Results may carry nextStep (what to do next) and askUser (the question to put to the user). BEFORE this pays or starts a session, it checks whether this holder already has a Verified AI Birthcert \u2014 found in the local cache, or (if the cache has not seen it yet) resolved from a previously-issued session. If one exists and is STILL VALID, nothing is paid and no session is started \u2014 you get back { existingVerifiedVc: { vcId, validUntil }, message } instead. Show that existing credential to the user and ask whether they actually want to replace it; only call this tool again, with confirmReplaceExistingVc set to exactly existingVerifiedVc.vcId, if they explicitly say yes. Never pass confirmReplaceExistingVc on your own judgement. If the existing VC has already EXPIRED, it needs no confirmReplaceExistingVc, but the payment still stops at the price like any other, and a result that went ahead carries replacedExpiredVc: { vcId, validUntil } naming the one it replaced. That field can appear on a settled session, on a payment still settling, or on an EARLIER call\'s still-pending session simply being returned unchanged \u2014 so treat it only as "the holder\'s old VC had expired", never as proof that THIS call itself just spent money; read the rest of the result (an error, a settled session, or settlementPending) to know what this call actually did. It can also return { error } specifically because an already-issued VC exists but could not be confirmed as valid or expired (a resolution problem, not a payment problem) \u2014 nothing is paid on that path either; relay `error` as given, since it names the concrete next step (retry check_ai_birthcert_verification, or as a last resort clear_stuck_payment_receipt with the user\'s explicit agreement).',
49933
50295
  inputSchema: {
49934
50296
  type: "object",
49935
50297
  properties: {
@@ -49954,6 +50316,12 @@ function buildToolList() {
49954
50316
  type: "string",
49955
50317
  description: 'DESTRUCTIVE, and it SPENDS. Only for a payment that is genuinely stuck (outcome still unresolved). 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. Not needed for a VOID receipt (settlement ruled expired/failed): the wallet discards that one itself, with no flag and no confirmation, so the next call is already an ordinary fresh purchase. 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. Normally the result carries the id you confirmed as discardedPaymentReceipt \u2014 keep it, support needs it to trace the lost payment. But if this call ALSO threw away a void receipt of its own (the fresh payment it just made was then ruled void), discardedPaymentReceipt holds THAT receipt instead, and the id the user confirmed is named at the end of the error text ("ALSO DISCARDED earlier on this same call") \u2014 so on that path quote BOTH ids to support, not just the field. 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 REFUSES this parameter outright \u2014 nothing is discarded and nothing is paid \u2014 so do not offer the user this option for a receipt that is not stuck yet. A bare "retry" or "yes" from the user is not agreement to pay again.'
49956
50318
  },
50319
+ confirm: {
50320
+ type: "object",
50321
+ properties: CONFIRM_QUOTE_PROPERTIES,
50322
+ required: ["asset", "maxAmountRequired", "gasModel"],
50323
+ description: "The user's explicit yes to the price: the quote object from a needsConfirmation result, passed back unchanged. Anything else, including true, stops at the price (needsConfirmation: true) and nothing is paid. Pass it only after the user has seen that price and said yes to it, never on your own judgement: a yes to something else, such as the name, is not a yes to the payment."
50324
+ },
49957
50325
  confirmReplaceExistingVc: {
49958
50326
  type: "string",
49959
50327
  description: "Only for replacing a Verified AI Birthcert VC that is STILL VALID (not expired). Pass the exact vcId from a prior existingVerifiedVc response \u2014 never a guess, never made up. Only set this after the user has SEEN that existing credential and explicitly asked to replace it; omit it otherwise. A mismatched or unconfirmed value is ignored: nothing is paid and no session is started, and you get the same existingVerifiedVc block again. Not needed at all when the existing VC has already expired \u2014 that case replaces itself automatically."
@@ -49964,13 +50332,13 @@ function buildToolList() {
49964
50332
  },
49965
50333
  {
49966
50334
  name: "credential_preflight",
49967
- description: "FREE readiness check \u2014 call this FIRST, before collecting ANY application detail from the user, whenever they ask for a credential. Spends nothing, signs no transaction, creates no session. Returns { ready, fee, balances, cap, schema?, alreadyHeld?, blockers, notChecked }: the live fee and which side pays gas, the balances that matter, whether the spending limit permits it, and \u2014 for a template credential \u2014 the attributes it requires. `blockers` lists EVERY reason it is not ready at once (a low balance and a too-low spending limit are different problems and both appear together), so one round of fixes is enough rather than discovering them one failed payment at a time. ALWAYS relay `notChecked` too: a clean result is not a guarantee. In particular it does NOT check whether an agent name is free \u2014 myid decides that at issuance, after payment. Report `fee.display` and each balance's `display` to the user, never the raw base-unit numbers. `fee.paymentRequired: false` means issuance is currently FREE \u2014 `fee.display` is then only what it WOULD cost if payment were switched back on, so do not ask the user to fund it and do not present that amount as a charge. Tell the user in plain words that it is free or chargeable; never quote JSON field names such as paymentRequired to them. A free credential sends no transaction, so no gas is spent and a missing ZTX balance is not a blocker on it: never ask the user to fund ZTX for a free credential, and if notChecked mentions ZTX, pass it on as a note only. When the field is ABSENT the cost is unknown (an older MBI), and it is treated as chargeable \u2014 never report absent as free. If the result has `alreadyHeld`, this wallet already holds a valid copy of that credential: tell the user so, name it, and stop \u2014 do not ask for an agent name or any other detail, and do not begin an issuance. A new one would replace it. That stop applies to that credential only: the `choice` note still applies to the other kind. Only when the user has explicitly said they want a replacement, run this check with replacing: true. When a user asks for an AI Birthcert and the request does not say which kind they want (Basic, which is self-declared, or Verified, through MyDigital ID), do not pick one: follow the `choice` note in the result, which is to run this check for both kinds, tell the user the difference in plain words, and ask which they prefer.",
50335
+ description: "FREE readiness check \u2014 call this FIRST, before collecting ANY application detail from the user, whenever they ask for a credential. Spends nothing, signs no transaction, creates no session. Returns { ready, fee, balances, cap, schema?, alreadyHeld?, unusedPayment?, blockers, notChecked }: the live fee and which side pays gas, the balances that matter, whether the spending limit permits it, and \u2014 for a template credential \u2014 the attributes it requires. `blockers` lists EVERY reason it is not ready at once (a low balance and a too-low spending limit are different problems and both appear together), so one round of fixes is enough rather than discovering them one failed payment at a time. ALWAYS relay `notChecked` too: a clean result is not a guarantee. In particular it does NOT check whether an agent name is free \u2014 myid decides that at issuance, after payment. Report `fee.display` and each balance's `display` to the user, never the raw base-unit numbers. If a balance's `decimals` is null, its `display` is still in raw base units, so say the amount could not be converted to whole tokens rather than quoting it. `fee.paymentRequired: false` means issuance is currently FREE \u2014 `fee.display` is then only what it WOULD cost if payment were switched back on, so do not ask the user to fund it and do not present that amount as a charge. Tell the user in plain words that it is free or chargeable; never quote JSON field names such as paymentRequired to them. A free credential sends no transaction, so no gas is spent and a missing ZTX balance is not a blocker on it: never ask the user to fund ZTX for a free credential, and if notChecked mentions ZTX, pass it on as a note only. When the field is ABSENT the cost is unknown (an older MBI), and it is treated as chargeable \u2014 never report absent as free. If the result has `alreadyHeld`, this wallet already holds a valid copy of that credential: tell the user so, name it, and stop \u2014 do not ask for an agent name or any other detail, and do not begin an issuance. A new one would replace it. That stop applies to that credential only: the `choice` note still applies to the other kind. Only when the user has explicitly said they want a replacement, run this check with replacing: true. If a Verified result has `unusedPayment`, an earlier payment has not been used yet and the next link reuses it: no new payment is needed, so do not ask the user to agree to a fee for it, and follow its `note`. When a user asks for an AI Birthcert and the request does not say which kind they want (Basic, which is self-declared, or Verified, through MyDigital ID), do not pick one: follow the `choice` note in the result, which is to run this check for both kinds, tell the user the difference in plain words, and ask which they prefer. The result also says what to do next in nextStep: confirm_kind_then_collect_details (an AI Birthcert: ask Basic or Verified unless the user already said, then collect the details), collect_details, resolve_blockers or tell_user_already_held.",
49968
50336
  inputSchema: {
49969
50337
  type: "object",
49970
50338
  properties: {
49971
50339
  credential: {
49972
50340
  type: "string",
49973
- description: 'Which credential to price: "verified_ai_birthcert" for the VERIFIED (MyDigital-ID-verified) AI Birthcert, or a template id (did:zid:...) / known template name for a template-issued one \u2014 "AI Birthcert" is the BASIC, self-declared one. For the Verified one use exactly "verified_ai_birthcert": any name containing "birthcert" is priced as the Basic one.'
50341
+ description: 'Which credential to price: "verified_ai_birthcert" for the VERIFIED (MyDigital-ID-verified) AI Birthcert, or a template id (did:zid:...) / known template name for a template-issued one \u2014 "AI Birthcert" is the BASIC, self-declared one. For the Verified one use exactly "verified_ai_birthcert". A name that says verified (such as "Verified AI Birthcert") is also treated as the Verified one; any other name containing "birthcert" is priced as the Basic one.'
49974
50342
  },
49975
50343
  agentName: {
49976
50344
  type: "string",
@@ -50098,8 +50466,9 @@ function buildPolicyWriteDeps(input) {
50098
50466
  };
50099
50467
  }
50100
50468
  async function describeAssetAmount(asset, raw, query) {
50101
- const { symbol, decimals } = await resolveAssetInfo(asset, query);
50469
+ const { symbol, decimals, decimalsReadable } = await resolveAssetInfo(asset, query);
50102
50470
  const label = symbol || "(unknown asset)";
50471
+ if (!decimalsReadable) return `${raw} base units of ${label}`;
50103
50472
  const human2 = formatHumanAmount(raw, decimals);
50104
50473
  return human2 === raw ? `${raw} ${label}` : `${raw} (${human2} ${label})`;
50105
50474
  }
@@ -50351,7 +50720,8 @@ async function main() {
50351
50720
  return {
50352
50721
  request: (input) => requestAiBirthcertVerification(verifyAiBirthcertDeps, input),
50353
50722
  check: () => checkAiBirthcertVerification(verifyAiBirthcertDeps),
50354
- clearStuckReceipt: (input) => clearStuckPaymentReceipt(verifyAiBirthcertDeps, input)
50723
+ clearStuckReceipt: (input) => clearStuckPaymentReceipt(verifyAiBirthcertDeps, input),
50724
+ findUnusedPayment: () => findUnusedVerificationPayment(verifyAiBirthcertDeps)
50355
50725
  };
50356
50726
  })() : void 0;
50357
50727
  const payer = createPayer({
@@ -50415,6 +50785,7 @@ async function main() {
50415
50785
  sign: subscribeSign,
50416
50786
  pay: payForCredential,
50417
50787
  resolveSymbol,
50788
+ formatAssetAmount,
50418
50789
  holderDid,
50419
50790
  resolveTemplateFields,
50420
50791
  cache: vcCache,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-wallet-mcp",
3
- "version": "0.14.2",
3
+ "version": "0.15.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",