agentic-wallet-mcp 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,307 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
  > Entries for 0.5.0 and earlier were reconstructed from commit history when this file was
9
9
  > introduced in 0.6.0, so they summarise each release rather than being exhaustive.
10
10
 
11
+ ## [Unreleased]
12
+
13
+ ## [0.14.1] — 12 October 2026
14
+
15
+ ### Fixed
16
+
17
+ - **`create_verification_qr` gives a chat channel a QR file to attach.** Asked for the QR code over WhatsApp, the agent sent the credential's pass-design image captioned as the QR code and said "QR code sent!": the QR existed only as an MCP image block, which a channel that sends files cannot forward, so the agent attached the one image file it did have. The wallet now also writes the QR as a PNG file and returns its path (`qrCodePath`); a file is removed when the wallet starts and the next time a QR is made, once its link has expired, and a file that cannot be written is reported (`qrFileError`) without losing the link. The tool text and result message tell the agent to attach that file, never to send another image as the QR code, and to give the link and say plainly that no QR was sent when it cannot attach one. The result also carries the credential's saved pass-design image(s) (`vcPassImagePaths`, only files that still exist) when the wallet chose or recognised the credential, so the agent can show the QR code and the pass design together, each captioned for what it is.
18
+
19
+ - **`credential_preflight` no longer blocks a free credential on a missing ZTX balance.** A user asked for an AI Birthcert, was told issuance was free, and was in the same answer told "No ZTX for network gas" was a blocker and asked to fund the wallet. The fee and spending-limit checks already stood aside when MBI reports `paymentRequired: false`; the gas check did not, and it also treated an absent gas model as self-pay. A free issuance makes no payment, so no transaction is sent and no gas is spent. On a free credential a wallet with no ZTX (or an unreadable ZTX balance) is now `ready: true`, with a note in `notChecked` that ZTX would be needed if issuance becomes chargeable and the wallet pays its own gas. A chargeable credential on a self-pay gas model still blocks on missing ZTX, as before. The tool text no longer says gas can block a free credential, and tells the agent never to ask for ZTX for one.
20
+
21
+ ### Changed
22
+
23
+ - **A self-paid send whose outcome is not settled is remembered.** The transaction now carries an expiry (`ceilLedgerSeq`, 60 ledgers past the current one), so after that ledger the node can never accept it. A small note (owner, policy key, operation, nonce, transaction hash, expiry, send time; no secret) is written, fsynced and renamed into place owner-only, kept per network, after signing and before the submit, and kept until the outcome is settled: a transaction found on chain (landed or failed) or a ledger past its expiry clears it. While it could still land, a later write or update for the same key is refused with nothing sent, saying when it settles, and so are a sponsored write, the collect of an already-paid write and `remove_policy` (the self-paid transaction replaces the whole policy when it lands); only its own transaction removes a note, and 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, then refuses with nothing done, so a call never acts after its client could have given up. A request to the policy write service times out after 60 seconds, so a hung one cannot hold a key. Only a missing file means "no note": one that cannot be read, is empty or is damaged (what a crash can leave) stops the call, names the file and sends nothing; the note is deleted by hand only after at least 15 minutes and with the chain producing ledgers, or once `get_my_policy` shows the write. A note whose write failed part-way (a directory fsync error) is removed again, since nothing was sent. A send is refused, with nothing signed, if the ledger number cannot be read, and with nothing sent if the note cannot be written. Previously a repeat call could only look at the chain, leaving a narrow window in which a transaction that landed just afterwards could be followed by a second one.
24
+ - **A self-paid write is bounded by the gas figure the user was shown.** `write_policy` and `update_policy` take `maxGasRaw` for `gasPayer "self"`: the `requiredRaw` of the self option (the estimate plus a fifth) that the quote or the choice showed. A self-paid send is refused, free and with nothing signed, without it or when the wallet's own figure at the time of signing is above it; the refusal gives the current figure to show and what to call with. The fee limit signed is the wallet's own ceiling, which is checked against the balance and can never be above what the user was shown. A `maxGasRaw` that is not a positive whole number (digits, or a safe-integer number) is refused before the chain is read. The quote, a dry run and the choice say what to pass; the sponsored path does not use it. An operator-configured ceiling was left out: its value is a decision for the team.
25
+
26
+ ## [0.14.0] — 12 October 2026
27
+
28
+ ### Fixed
29
+
30
+ - **`credential_preflight` now says when the wallet already holds the credential, before the agent asks the user for anything.** A user with a
31
+ valid Verified AI Birthcert asked for one, was told "no blockers, 1 JMYR", and was asked to choose an agent name: the only check for an existing
32
+ credential was in the paid `request_ai_birthcert_verification` call (the free quote path skips it on purpose, so a price can be read while a
33
+ session is in flight), and preflight never looked at what the wallet holds. Preflight now looks, locally and for free, at the credentials the
34
+ wallet has saved; if a valid copy is held it stops with `ready: false` and `alreadyHeld` (what it is, its id, its expiry), prices nothing, and
35
+ tells the agent not to ask for a name or start an issuance. A new one would replace it and cost the fee, so only when the user explicitly wants a
36
+ replacement does the agent run it again with `replacing: true`. Works for the Basic AI Birthcert and any template credential too. An expired
37
+ credential does not count; a lookup that fails is reported in `notChecked`, never as "nothing is held". It does not see a credential that was
38
+ paid for and issued but never collected with `check_ai_birthcert_verification` — the paid call's own guard still covers that.
39
+
40
+ ### Changed
41
+
42
+ - **The UAT host moved to `ssivc-api-uat.myegdev2.com`.** Both testnet defaults that used `ssivc-api-uat.myegdev.com` now use the new host: `SSIVC_BASE_URL` (the Verified AI Birthcert flow) and `MYID_VERIFY_LINK_TEMPLATE` (the `create_verification_qr` universal link, `https://ssivc-api-uat.myegdev2.com/api/agentic-verify?referenceId={referenceId}`). The new host serves the same `apple-app-site-association` (`/api/agentic-verify` with a `referenceId` query), the same `assetlinks.json` (`com.zetrix.myid.uat`) and the same "open on your phone" page. Anyone who set either variable to the old host explicitly should update it; the old host is no longer the default.
43
+
44
+ - **`create_verification_qr` has a built-in link on testnet (UAT), and it is the one the MyID app is registered for.** `MYID_VERIFY_LINK_TEMPLATE`
45
+ now defaults to `https://ssivc-api-uat.myegdev2.com/api/agentic-verify?referenceId={referenceId}` on testnet, so nobody has to set it. It was read from
46
+ the host's own `apple-app-site-association`, which claims exactly `/api/agentic-verify` with a `referenceId` query (plus an `assetlinks.json` for
47
+ `com.zetrix.myid.uat`), and that address serves an "open this on your phone" page. The first link the wallet was given,
48
+ `/v1/agent-verification/{referenceId}`, is claimed by neither: a phone would not hand it to the app, and the server answers 404. **Mainnet has
49
+ no default** until MyID's production side is verified (MyID stated `https://myid-verifier.zetrix.com/api/agentic-verify?referenceId={referenceId}`, but it was not deployed and its host sits behind a Cloudflare browser
50
+ check, so the association files could not be read): the tool refuses before it creates anything on MBI, as for any unverified mainnet value, and the stated link is
51
+ kept as `UNVERIFIED_MAINNET_MYID_VERIFY_LINK_TEMPLATE`. Set `MYID_VERIFY_LINK_TEMPLATE` to opt in. The variable still overrides the default on either
52
+ network, and only a value someone set is warned about at startup.
53
+
54
+ ### Added
55
+
56
+ - **`write_policy` and `update_policy` take a `gasPayer` (`"sponsored"` or `"self"`), and the choice is the user's (phase 1).** `"sponsored"` is what both tools do today: the fee in JMYR, the gas covered. `"self"` is for a wallet that builds and signs the contract call itself: no fee, only the network gas in ZTX. The wallet never picks and has no default: when self-pay is on offer and a call names no `gasPayer`, it stops at the new state `needs_choice` (nothing paid or written, even with `confirm: true`) and lays out both options in `gasOptions`: the JMYR fee and whether the wallet holds it, and the estimated ZTX gas (the estimate plus a fifth is what the wallet asks to hold) and whether the ZTX balance covers it, each unknown rather than a yes when it cannot be read. A `gasPayer` that is neither value is refused before the service is contacted. **Self-pay (phase 2).** Where the policy contracts are deployed (testnet today) the wallet makes the owner's own `createOrUpdatePolicy` call on the Policy Registry itself: it builds the call, signs it through Wallet BE and submits it, paying only network gas in ZTX, with no service fee and no call to the policy service. Before sending it reads the chain (an existing policy, or one that changed since it was read, is refused for free), asks the node to simulate the exact call (a contract rejection costs nothing), and refuses unless the ZTX balance covers the estimated gas plus a reserve. It reports `written` only when the chain then holds exactly what was sent (same attributes, template and validity window; for an update `updatedAtBlock` moved), and `submitted` with the `txHash` otherwise. A list attribute (written as a JSON-array string) goes on chain as an array of strings, an attribute with no `attributeType` is refused rather than guessed, and nothing is retried after a transaction may have been sent. Wallet BE refusing to sign is reported as a decision (`policyDenied`), its check failing as transient (`policyCheckUnavailable`). Where the policy contracts are not deployed nothing changes. **Not yet run against a live chain:** whether the fee is charged on the gas used rather than on the fee limit (the transaction is signed with the ceiling the balance was checked against), how a failed transaction is charged, and whether a base reserve applies on top of the fee limit are confirmed only by a testnet run. **What the testnet run settled:** an older Wallet BE refuses any contract call that is not a ZTP20 `transfer` from an owner who already has a policy contract (`UNSUPPORTED_OPERATION`, decided before any policy is consulted); a later Wallet BE removes that refusal and was verified on the sandbox (the owner's `createOrUpdatePolicy` blob is signed). **The wallet no longer holds self-pay back for a wallet that already has a policy** (the check that asked the Registry whether the owner has a policy contract is removed, so an update can be self-paid again); a refusal from an older Wallet BE is still reported as final, matched on the refusal kind as well as its text, not about the policy's limits, with no invitation to retry. **After review (round 2):** the transaction lookup now reads the SDK as it really answers (every nested number is a string, so a code of "151" is a rejection and "0" a success, and a transaction the node names differently is not the one asked about), which is what makes the on-chain `write_failed` reachable at all; a receipt the service said failed no longer holds a self-paid write back; a wallet with no vocabulary source does not self-pay; when neither way of paying is available the answer is `unavailable` with both reasons; the lookup is bounded like the reads; and the wording for a transaction that succeeded on chain no longer says it may have failed. After review: the transaction is signed with the fee ceiling the balance was checked against (a fresh estimate above it, or a malformed one, is refused with nothing signed); `gasPayer "sponsored"` only ever uses a quote that really covers the gas; a transaction that was included and rejected by the contract is reported as `write_failed` (the gas is spent and nothing was written) instead of "not a failure", by looking it up by its hash; a pending paid write for the same key, or an attribute vocabulary that could not be read, blocks a self-paid write; an estimate of zero, a balance that is not ZTX in 6 decimals, or a null from either is `unknown`; the refusal reason is carried through; a list attribute is capped at 500 entries and a policy at 64 attributes; self-pay needs the attribute vocabulary, which comes from the policy service, so with no service URL the tools are not built at all (nothing there could write), and with a service but no vocabulary reader self-pay is refused with its own reason; and the agent-facing text now says that a self-paid result has no `paymentReceipt`, that `check_policy_write` does not apply to it, that its gas is spent even when the write fails.
57
+
58
+ - **`valueHuman`: give one amount in whole tokens, and the wallet does the multiplication.** On any attribute of `policy_preflight`,
59
+ `write_policy` and `update_policy`, `valueHuman: "100"` stands in for `value` and means 100 whole tokens. The wallet converts it with
60
+ the asset's own on-chain decimals (native ZTX, or the token named by `tokenAddress`), returns the raw value in `convertedAmounts`, shows
61
+ both forms in `interpretation` ("perTransactionMax: 100 JMYR is written as 100000000"), and never sends `valueHuman` to the service.
62
+ What counts as an amount comes from the service's served vocabulary (`unit: SMALLEST_UNIT`) when it can be read, and from the built-in
63
+ list when it cannot; an attribute the service calls a COUNT or a DURATION is never scaled, whatever the built-in list believes
64
+ (`maxTransactionCount` and the windows are refused). Anything it cannot convert exactly is refused with nothing converted: unknown
65
+ asset, unreadable decimals, more decimal places than the token has, an exponent, a sign, a separator. A `value` given alongside must agree.
66
+ It is applied BEFORE `amountUnit`, which then leaves it alone, so a human amount is never converted twice. The tool descriptions now
67
+ also say that `maxTransactionCount` is a count, not an amount.
68
+
69
+ - **`update_policy` and `remove_policy`: users manage a policy through the agent after it is deployed.** `write_policy` is create-only
70
+ (a second call with the same key answers `already_exists`); these add the other two on top of ms-zetrix's x402 routes.
71
+ - **`update_policy`** replaces a policy's attributes and validity window and **pays the update fee** (through the same payment cap and
72
+ `confirm: true` gate as `write_policy`, with the same `dryRun`). It takes the policy's `expectedUpdatedAtBlock` from
73
+ `get_my_policy` (new `forUpdate.expectedUpdatedAtBlock`, already a string — the chain returns a number and the service wants a string),
74
+ carries the policy's template forward rather than taking one, and keeps its validity window unless a new one is given (the service does
75
+ not default an omitted bound, so sending nothing would have stripped an existing expiry). It reads the policy first and checks the draft
76
+ against the policy's own template. Every refusal that can be known first is free and says nothing was paid: `not_found`, `modified`
77
+ (the policy changed since it was read), `template_unavailable`, `refused`. A paid update that the chain then rejects is `write_failed`
78
+ and says the policy is **unchanged** and a payment may have been taken.
79
+ - **`remove_policy`** is free but lifts every limit the policy set, so nothing is sent without `confirm: true`; the unconfirmed call
80
+ returns what the policy currently limits. It is reported as `removed` **only when a chain read shows the policy gone** — a submitted
81
+ transaction is `submitted`, never "removed" — and repeating it never submits a second removal.
82
+ - **`check_policy_write`** now collects an update receipt on the update route: the receipt bookmark records which write it pays for
83
+ (`operation`), and one saved before this change reads as a create.
84
+ - **`write_policy`'s default `requestKey`** is now a sha256 hex string. The old `owner-policyKey-timestamp` overflowed the service's
85
+ 128-character column for a policy key longer than about 65 characters.
86
+ - A write the service says is already paid for is collected on the route **its own operation** names, which may not be the write just
87
+ asked for (previously it was always collected as a create).
88
+ - **The service's own description of every policy attribute.** ms-zetrix now publishes the vocabulary at
89
+ `GET /policy/vocabulary` (version `v1`, sixteen attributes, each with a description, unit, role, what it
90
+ pairs with and what an empty list or a use outside its scope means). `get_policy_template_schema` returns
91
+ it beside `declared` as `attributeMeanings`, so an agent can say what a rule means instead of guessing, and
92
+ `policy_preflight` (and the check `write_policy` runs before paying) uses it to refuse what the built-in
93
+ rules cannot know: an attribute the service does not recognise (`unknownAttributePolicy` defaults to
94
+ deny, so it refuses every transfer) and an attribute used outside the scope it applies to
95
+ (`allowedMethods` or `approvalPolicy` on a native policy). When the service and the built-in rules
96
+ disagree about a role or about what a missing window means, that is reported in `notChecked` and the
97
+ STRICTER of the two answers is used, so a service label can tighten the "enforceable" refusal but never loosen
98
+ it. The read is strict (one malformed attribute fails the whole read, and an unknown version is refused),
99
+ bounded (descriptions are flattened to one line, stripped of invisible formatting characters such as bidi
100
+ overrides, cut at 600 characters, and shown as quoted text from ms-zetrix that describes and does not
101
+ instruct; "bounded, not scrubbed"), never follows a redirect, refuses a declared oversize body before reading
102
+ it, cached (ten minutes for a good answer, one for a failure) and never throws. **If it
103
+ cannot be read, nothing else changes**: every existing preflight result is as before plus one `notChecked`
104
+ line. Known limit: the route is behind a Cloudflare bot challenge that, as observed on 2026-10-06, some
105
+ non-browser clients (Node `fetch`, Git's OpenSSL curl) receive as an HTML page with HTTP 403 and
106
+ `cf-mitigated: challenge`; that is reported as `challenged` and the built-in rules are used until it is
107
+ exempted.
108
+
109
+ - `create_verification_qr`: gives a human a link and a QR code that open the agent's credential in the MyID app. The wallet presents the credential to MBI, and the link holds only the reference id — which grants access to the revealed attributes until it expires, so by default only a standard minimal set is revealed — Verified AI Birthcert: `agentName`, `evidenceProvider`, `ownerVerified`; Basic AI Birthcert: `agentUsername` — never the owner's name, id or date of birth; other attributes need `revealAttribute`, and everything needs `revealAll: true`, which the agent uses only when the user explicitly asks. A Basic credential's result says it does not mean the owner was verified. The link is built from `MYID_VERIFY_LINK_TEMPLATE` (MyID's `https` universal link, with `{referenceId}` in the path or query); with none set, or one that breaks those rules, the tool refuses before anything is created on MBI, and a bad template is warned about at startup. A credential whose subject is not this wallet is refused locally. The link stays openable for 5 minutes by default, up to 60. With no `vc` given it presents the Verified AI Birthcert if the wallet holds one, otherwise the Basic AI Birthcert (the result says which, in `credentialUsed`), and if it holds neither it answers `created: false` and says to create one first; no other credential is picked for the caller. A refusal for a path the credential does not have lists the attribute names it does have. The OpenClaw plugin gains a `myidVerifyLinkTemplate` setting that is forwarded to the wallet. The named paths must each be a single attribute of the credential: a typo or a parent path is refused, and so is any path the wallet cannot check — a credential that is not an object, has no plain `credentialSubject`, or has an attribute name containing a dot — in which case the caller passes the credential object or uses `revealAll`. They are sent de-duplicated and in the credential's own order, and the result states what was revealed (`revealed`). The template is judged as a browser would read it: only printable ASCII, no backslash, no `@` before the host, and not containing the text `refidmarker`.
110
+
111
+ - **A unit guard, and `amountUnit`, for policy amounts.** A real policy went on chain with
112
+ `perTransactionMax: 1` and `cumulativeMax: 100` for a 6-decimal token (JMYR) — 0.000001 and 0.0001 of
113
+ it, a million times tighter than the "1 JMYR" and "100 JMYR" the user meant. Amounts are raw base units,
114
+ and nothing made that visible before the payment. Now a non-zero `perTransactionMax`, `cumulativeMax` or
115
+ `velocityCap` under one whole token is refused by `policy_preflight` and `write_policy` unless
116
+ `amountUnit` says what is meant: `"whole"` gives whole-token amounts (`"1"`, `"0.5"`) which the wallet
117
+ converts by the token's decimals, returns in `convertedAmounts` and states in `interpretation` — and
118
+ `write_policy` then writes the converted raw values, so what is paid for is what the user saw; `"base"`
119
+ confirms a tiny raw value is intended. Silence is not an acknowledgement. Conversion refuses to guess:
120
+ unreadable decimals, a missing scale, more decimal places than the token has, or anything that is not a
121
+ plain number all refuse the draft. Counts (`maxTransactionCount`) are not amounts and are untouched.
122
+ A `"whole"` draft in which every amount is already 10^decimals tokens or more is refused too, because
123
+ that is what an already-converted value looks like and converting it again would write a cap a million
124
+ times looser; `convertedAmounts` is for showing the user and must never be sent back as the values.
125
+ Because a genuine cap that large looks identical, the refusal names BOTH readings with the exact raw value
126
+ for each (resend a copied raw value unchanged with `"base"`; send a real large cap as the value times
127
+ 10^decimals with `"base"`). It needs every amount to look raw, so a copied draft that also contains a zero
128
+ or sub-token amount is not caught. Raw amounts are also bounded to 77 digits, the most a 256-bit number has.
129
+ Token decimals are now typed strictly and bounded (0–36) at the read: `null`, an empty string, `false`
130
+ or an array used to read as a genuine 0-decimal token, and an absurd value made preflight throw or
131
+ stall. The guard cannot catch a raw value that is already one whole token or more (`interpretation` states
132
+ what such an amount means), and does nothing when the decimals cannot be read (`notChecked` says so).
133
+ - **`policy_preflight` no longer leaves a token policy to guesswork.** A real transcript ("limit 1 JMYR
134
+ per transaction, 100 a week") exposed three gaps. (1) The agent asked the user for the JMYR contract
135
+ address, which the wallet already holds: `wallet_status({ token })` now returns it as `tokenAddress`,
136
+ a `ztp20` draft with no `tokenAddress` names the registered ones, and a token symbol written where
137
+ the address belongs is refused with the address to use (the generic "ask the user, do not correct it"
138
+ advice is suppressed for that case, since correcting a registered symbol is the right move). (2) The
139
+ amounts were never stated in whole tokens, so `1` of a 6-decimal token — 0.000001 of it — read as
140
+ "1 JMYR"; `interpretation` now says what each amount means, and says so plainly when the decimals
141
+ cannot be read instead of assuming a scale. (3) Preflight accepted `cumulativeWindow: "week"`, which
142
+ the write service refuses: every `*Window` must be a duration such as `7d`, `12h` or `30m` (or ISO
143
+ `P7D`) — not a word, not a bare number (which would be read as milliseconds), and not padded with spaces,
144
+ and `interpretation` states each window's period in words, because `1M` is one minute, not one month. The window grammar is
145
+ restated from the service, so it is a tripwire rather than a guarantee; a window over the default 30d
146
+ retention is noted, not refused, because that limit is environment-specific.
147
+
148
+ - **The wallet can now find a policy template on its own.** Asking it to "show me the policy
149
+ template" on a wallet with no deployed policy used to go nowhere: the tool described a template id
150
+ as something found "inside a deployed policy", a first-time user has none, and the only way to get
151
+ one is to write a policy — which needs the id. The agent ended up asking the user for a publisher
152
+ and a template contract address, neither of which a user can be expected to know.
153
+
154
+ `get_policy_template_schema` with no arguments now lists the default publisher's templates, each
155
+ with its declared attributes and the `templateId` that `write_policy` needs. A single template can
156
+ be read by key alone, and another publisher's listed by naming it. An id is only ever returned
157
+ once the chain has confirmed it — it is derived from the publisher and key, and one that does not
158
+ resolve is withheld. The listing is bounded and says what it left out.
159
+
160
+ New optional setting `POLICY_TEMPLATE_PUBLISHER`. The default is derived per network, and there is
161
+ none where no policy module is deployed.
162
+
163
+ - **`policy_preflight` no longer approves drafts the write service refuses.** A real transcript
164
+ drafted "a 10 JMYR per-transaction max" as `assetScope: "JMYR"` on the native template and got
165
+ `ready: true` and "limited to JMYR" — for a draft the service rejects twice over. Preflight now
166
+ applies the service's scope rules: `assetScope` must be exactly `native` or `ztp20` (never a token
167
+ symbol), a `ztp20` policy needs a `tokenAddress` and says plainly when the chosen template cannot
168
+ express one, and an amount or count cap needs an `assetScope`. No meaning is offered for a scope it
169
+ is refusing. These are restated from the service, so they are a tripwire rather than a guarantee.
170
+
171
+ - **`policy_preflight` refuses a repeated attribute name.** The scope and token rules read the first
172
+ attribute with a given name, so `assetScope: "native"` followed by `assetScope: "JMYR"` was read as
173
+ ready. Which value the write service would take is not knowable from the wallet, so a repeat is refused
174
+ rather than guessed at.
175
+
176
+ - **`write_policy` can quote its price first.** `dryRun` runs every free check and returns what the
177
+ service asked for, paying, collecting and writing no policy — including never collecting a write that
178
+ has already been paid for (it only keeps a local bookmark so `check_policy_write` can finish it). Previously the price could only be learned by paying it.
179
+
180
+ - **`write_policy` `dryRun` now says whether the wallet can afford the quote.** (The network fee is not
181
+ estimated, and a payment made in ZTX needs the amount plus that fee from one balance, so a ZTX balance
182
+ equal to the quote is short and `feeNotEstimated` says when "affordable" means only "holds the amount".) It reads the fee-asset
183
+ balance, ZTX for gas (only when the payer would check it) and the payment cap the policy payer
184
+ carries, and reports each shortfall separately under `affordability`. A balance that cannot be read
185
+ is `unknown`, never `affordable`. Nothing is paid, collected or written, and the check is not run on
186
+ a real deploy.
187
+
188
+ ### Fixed
189
+
190
+ - **A collect that answers with an error no longer leaves a landed write as "unknown".** On staging an `update_policy` was paid and the service finished the collect, but it took 12.7 seconds and something in front of it answered HTTP 500 at about 10; the wallet could only say "unknown, ask again later", and asking again spends one of the service's few collect retries, while the chain already held the update. Now, when the collect step ends with an error that says nothing about the write (a 5xx, a gateway page, a status this wallet does not recognise, or the service's own "unknown"), the wallet reads the chain for up to 45 seconds, which is free and spends no retry, and reports `written` when the chain holds **exactly** the attributes that were submitted (for an update, with `updatedAtBlock` moved from the value it had before). A policy that holds the same attributes but did not move, one with an extra or missing or repeated attribute, an unreadable chain, and the service's own "the chain rejected it" are all left as they were: the wallet says `written` from a chain read only on exactly that (and, for a create, under the same template). The answers that start the read include every status the wallet does not recognise, a 4xx too. `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 once the service has said its write failed, or once a newer paid write for the same `policyKey` is made (so a later, separately paid write of the same values is never credited to an older receipt), and a receipt bought by another owner is never checked against this owner's policy. The read gets what is left of the poll budget (at least 10 s, at most 45 s) and each read is cut off at 10 s. The bookmark now records the submitted attributes and the prior `updatedAtBlock` (public policy rules, no credential; a malformed record is dropped on read and the receipt stays usable). The result also carries `upstream: { status, detail }`, the service's own answer kept apart from the wallet's wording (control characters and line breaks replaced, at most 200 characters; the service's own "unknown" carries it too), and a `written` inferred from the chain carries `paymentReceipt`. **Not changed here, and not the wallet's to change:** the 500 itself. It is a gateway or ingress timeout on a collect that takes longer than it allows; raising that timeout for `/collect` (or making collect return before it finishes) is a server-side fix.
191
+
192
+ - **`valueHuman` review round 1.** A native policy that also names a `tokenAddress` is refused (the service ignores it for native payments, so it would not limit ZTX) and is never read as an amount of ZTX: before, a `valueHuman` on it was converted with ZTX decimals while the cap applied to the token, up to a million times off. An attribute with neither `value` nor `valueHuman` is refused for every type: before, a STRING attribute could reach the wire with no value. An attribute entry that is not an object is refused cleanly instead of with a raw error. An attribute the service lists with a unit other than the smallest unit is never scaled. A numeric `valueHuman` is accepted only as a safe whole number (write `"0.5"` as text), and a `value` that is not text beside a `valueHuman` is refused. The descriptions say never to put `convertedAmounts` into `valueHuman`.
193
+
194
+ - **`update_policy` / `remove_policy` review round 1.** Recovering a write that was already paid for now collects on the
195
+ receipt's OWN route: a receipt the wallet holds decides it, the server's `operation` must agree or nothing is collected, and an
196
+ update whose operation neither side names is refused instead of being collected as a create (which could lose the receipt and
197
+ invite a second charge). `validFromBlock: null` / `validToBlock: null` are treated as "not named" and the current bound is carried
198
+ forward, and a malformed bound is refused for free, so an agent can no longer strip an expiry by sending `null`. A malformed error
199
+ envelope (`messages` that is not an array) can no longer throw out of a payment refusal. A paid create that is still being
200
+ completed (409 with `errorCode` 461529) is reported as in progress, not as "the policy already exists, nothing was paid", and
201
+ the messages no longer promise that asking again will hand back a receipt. An unexpected 2xx from remove, and a 5xx on the update
202
+ pre-check, are reported as unknown or unavailable instead of "nothing was removed" / "refused"; a `getPolicy` reply with no
203
+ boolean `found` is a failed read, not "removed". The idempotence of `remove_policy` is described as the server's, not the
204
+ wallet's. Not done here, deliberately: saving the receipt before the phase-2 payment, which the server flag for owner-password
205
+ collection depends on; that flag must stay off until it ships.
206
+
207
+ - **Follow-ups from the vocabulary review.** The quoting around a service description can no longer be closed by a
208
+ double quote in the description (it is JSON-escaped, so it stays one quoted span). A pairing the service reports with a window the built-in rules do not name (a renamed
209
+ window) is always reported in `notChecked`, and refused when the service says that without it the cap is unenforceable or not
210
+ a limit at all, so a rename is never silent.
211
+ The `maxTransactionCount` blocker states its reading as ms-zetrix's published vocabulary as read on 2026-10-07 rather
212
+ than a hard-coded claim, `get_policy_template_schema` says the meanings are data from ms-zetrix and not instructions, and
213
+ `attributeMeaningsSource` is listed in the README.
214
+
215
+ - **A `maxTransactionCount` with no `countWindow` is refused.** It was a quiet note on a `ready: true` result, so the
216
+ owner paid for a policy with no count limit: the wallet's own rule says a count with no period is not requested at
217
+ all, and ms-zetrix describes it as refusing every transfer, and neither gives the limit the owner wrote. The fix
218
+ (add `countWindow`) is free, so it is now a blocker, as a `velocityCap` without its window already was. A pairing
219
+ the service calls unenforceable and the built-in rules call something looser is refused too. Which reading is
220
+ right still needs confirming with ms-zetrix.
221
+ - **An attribute the service does not know restricts nothing when `unknownAttributePolicy` is `"ignore"`**, so it no
222
+ longer counts as the policy's enforceable constraint.
223
+
224
+ - **`tokenAddress` counted as an enforceable constraint.** The service's vocabulary calls it a QUALIFIER (it
225
+ says which token a policy governs and caps nothing), and the wallet's built-in set did not, so a policy of
226
+ `assetScope` plus `tokenAddress` alone passed preflight although the service would answer
227
+ `NO_ENFORCEABLE_CONSTRAINTS` and refuse every transfer. It is now a qualifier.
228
+
229
+ - **The paying tools report a Wallet BE spending-policy refusal as what it is.** `transfer_token` told a policy denial
230
+ (`1000033`, a decision) from a check that could not complete (`1000034`, transient) from a signing failure; the four x402
231
+ paths did not, and Wallet BE signs the same way for all of them. `pay_and_fetch`, `subscribe_and_issue`, the Verified AI
232
+ Birthcert session fee and `write_policy`'s fee surfaced the raw `Wallet BE /wallet/hsm/sign-blob errorCode 1000033: …`
233
+ as a thrown error, with nothing to tell an agent that one is final and the other worth retrying. They now return
234
+ `policyDenied: true` or `policyCheckUnavailable: true` with a message that says plainly what was and was not done: the
235
+ refusal arrives at the signature, before the X-PAYMENT header exists, so nothing was paid, and each path adds what it
236
+ had already done (a quote, the free pre-check, the issuer's payment request — none moves money). Matched on the numeric
237
+ `errorCode` only, never on message text, and the failure text is bounded like `transfer_token`'s. A refusal is never
238
+ retried on the other gas option. `write_policy` reports a denial as state `refused` and an unavailable check as
239
+ `unavailable`, with `paid: false`. Every other failure still throws, as before. The matching moved to a shared module
240
+ (`policy-refusal.ts`) and `pay_and_fetch`'s payer to `orchestrator/pay.ts`, where it can be tested.
241
+
242
+ - **`transfer_token` now tells a policy refusal from a signing failure.** Wallet BE enforces the user's
243
+ policy at signing and answers a refusal as HTTP 200 with a numeric `errorCode`: `1000033` when
244
+ the policy decision service said DENY (a decision, with the reason code in the message) and `1000034`
245
+ when the policy check could not complete (it fails closed; transient). The wallet only recognised an HTTP
246
+ 403 or a `policyCode` field, neither of which Wallet BE sends, so both surfaced as "signing failed" — the
247
+ same words as the signer being down, with nothing to tell an agent that one is final and the other is
248
+ worth retrying. `1000033` now yields `policyDenied: true` ("a decision, retrying will not help", with the
249
+ reason shown) and `1000034` yields `policyCheckUnavailable: true` ("transient, trying again shortly is
250
+ right"). They are matched on the numeric field only, never on message text, so a lookalike message or a
251
+ string code does not classify, and every other Wallet BE error is still "signing failed". The failure text
252
+ is bounded on every path, including an ordinary "signing failed" — bounded and flattened to one line (whitespace and
253
+ control characters collapse, so a remote message cannot lay out fake lines), cut by character so a surrogate pair is never
254
+ split, and total (it never throws), but NOT scrubbed: markup in a remote message is still shown. A bare HTTP 403 no longer counts as a policy
255
+ denial: it can come from a proxy or WAF, and reporting that as a final decision would be the inverse of this
256
+ bug; a structured `policyCode` and `errorCode` 1000033 still do. Nothing changes for an environment where
257
+ Wallet BE enforcement is off (the shipped default
258
+ for test, UAT and prod). Only `transfer_token` is changed; the x402 payment paths surface the Wallet BE
259
+ message as before.
260
+
261
+ - Existing tests used `cumulativeWindow: "43200"` — a bare number the service refuses — as a valid
262
+ window, encoding the same false assurance. They now use `12h`.
263
+
264
+ - **A gateway's 5xx on `collect` is no longer reported as the chain rejecting the write.** A real run got a
265
+ Spring `500` from `/collect` and then Cloudflare's "origin returned an invalid response" page as a `502`;
266
+ the wallet read the second as the service's own `WRITE_FAILED` and told the user the chain had rejected a
267
+ paid write, when nothing was known about it. The service's own 502 and 504 always carry JSON with
268
+ `state: "WRITE_FAILED"` / `"UNKNOWN"`, so a 502/504 now counts as the service's only when its body says so.
269
+ Any other 5xx is a new `server_error` outcome: state `unknown`, receipt kept, never retried (a failed
270
+ collect uses one of a small number of retries), no claim about the chain, and one later `check_policy_write`.
271
+ A gateway page is quoted as bounded plain text, and a 5xx carrying a state the wallet does not know stays on
272
+ the unrecognised path. The `500` itself is an unhandled exception in ms-zetrix and needs fixing there.
273
+
274
+ ### Changed
275
+
276
+ - **A spending policy for an asset replaces the default wallet cap for that asset; mainnet gets the same default cap as testnet.**
277
+ A payment had to pass the wallet's own per-payment cap and then Wallet BE's policy, so a policy allowing 2 JMYR did not
278
+ raise the default 1 JMYR cap: a 1.5 JMYR payment was refused by the wallet before the policy was consulted. Now, when the
279
+ owner has a policy that governs the asset being paid (`assetScope` native for ZTX, or ztp20 with that `tokenAddress`, a
280
+ `perTransactionMax`, and no block range), the DEFAULT cap is not applied on any paying path (`pay_and_fetch`,
281
+ `subscribe_and_issue`, the AI Birthcert fee, `write_policy`'s fee, `transfer_token`) nor in the cap shown by
282
+ `credential_preflight` and the `write_policy` quote, which then say the policy governs. It fails closed: no policy
283
+ registry (mainnet today), a failed or timed-out read of the policy list, a policy for another asset, one with no
284
+ `perTransactionMax`, one that repeats `assetScope`, `tokenAddress` or `perTransactionMax`, a native policy that also names a
285
+ token (the service ignores it for native payments), or a policy bounded in time all leave the cap in force, and an explicit `MAX_PAYMENT_AMOUNT` is never bypassed. A good reading is cached for 30
286
+ seconds, so a removed policy can still stand the cap aside for up to that long. Only the cap stands aside: a malformed amount in a payment challenge is still refused. **`write_policy` now needs
287
+ `confirm: true` to pay and write** (without it the call returns the price, `needsConfirmation: true`, and nothing is paid),
288
+ because the agent could otherwise write a high `perTransactionMax` and lift its own cap in two calls; the wallet cannot verify
289
+ that a person said yes, it is an instruction to the agent like `transfer_token` `confirm`. The gate also covers collecting a write that was paid for elsewhere (a "already in flight" answer is neither saved nor collected without `confirm: true`, so `check_policy_write` has nothing to collect), and `pay_and_fetch` refuses the policy-write service's paid paths. A read of the policy list that times out is never cached when it finally answers, and no further read starts while it runs. **Two things this accepts, agreed with the
290
+ reviewer:** the general default cap on mainnet is now 1 JMYR per call (it was refuse-all, deliberately, so `pay_and_fetch`
291
+ could not auto-pay an arbitrary URL), with no running total; and the bypass trusts Wallet BE to enforce the policy, which it
292
+ does not in prod as shipped.
293
+
294
+ - **Corrected the `notChecked` text on preflight results.** It said the deploy path "is not built
295
+ yet", which stopped being true when `write_policy` shipped, and stated as fact that nothing consults
296
+ the decision service — something this wallet cannot see.
297
+
298
+ - **`write_policy` no longer asks for `templateContractAddress`.** The wallet already knows the one
299
+ Template contract it trusts and refuses any other before any payment, so requiring the caller to
300
+ supply it could only invite a wrong value. Leave it out; one that is supplied and differs is still
301
+ refused.
302
+
303
+ - **The wallet now signs each request it makes to MBI's `/ext` endpoints, instead of signing its own
304
+ address once.** Creating and submitting a presentation, and downloading the Verified AI Birthcert,
305
+ used to log in with a signature over the wallet's own address. That value never changes, so one
306
+ captured set of headers stayed valid for that address indefinitely, and MBI has marked the scheme
307
+ deprecated. Each call now signs its method, path, a hash of the exact body sent, and a fresh
308
+ timestamp, and the signature is good for that one request. MBI has accepted this since 31 July 2026.
309
+ If a Verified AI Birthcert download fails at the signing step, `check_ai_birthcert_verification`
310
+ now reports it as a retryable fetch error instead of failing outright.
311
+
11
312
  ## [0.13.0] — 29 September 2026
12
313
 
13
314
  ### Added
package/README.md CHANGED
@@ -20,24 +20,39 @@ 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 | `{ 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 | `{ credential, templateId? }` | `{ credential, ready, fee?, balances, cap?, schema?, blockers, notChecked }` |
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" | `{ credential, agentName?, replacing? }` | `{ credential, ready, fee?, balances, cap?, schema?, alreadyHeld?, blockers, notChecked }` |
25
25
  | `prove_identity` | Answer an x401 `PROOF-REQUEST` → return the `PROOF-RESPONSE` header to replay | `{ proofRequest, vc?, revealAttribute?, issuerKeys? }` | `{ proofResponseHeader, verified, presentationId }` |
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 }` |
26
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 }` |
27
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
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 }` |
29
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 }` |
30
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 }` |
31
- | `get_policy_template_schema` | **Free** read of a POLICY template's declared attribute vocabulary. This is the only vocabulary that means anything on chain — the policy contract validates nothing, so an attribute outside this list deploys cleanly and then enforces nothing at all. Accepts `{ publisher, policyKey }` or `{ templateId }`. A template that cannot be read reports `{ error }` rather than `{ found: false }`, so "no such template" is never confused with "could not look it up" | `{ templateId?, publisher?, policyKey? }` | `{ found, attributes?, error? }` |
32
- | `get_my_policy` | **Free** read of the spending policies this owner has deployed on chain; defaults to this wallet's own address. Costs 2 + N chain calls and warns above 50 keys. An owner who has never deployed one is a normal absence, **not** an error — the policy contract is created lazily on first write — and a failed lookup keeps its own error state, so "we could not list your policies" is never presented as "you have none" | `{ owner? }` | `{ owner, policies?, warning?, error? }` |
33
- | `policy_preflight` | **Free** check of a draft policy *before* it is deployed. Two questions: is it well-formed (`blockers` lists every problem at once, so one round of fixes is enough — on a hostile draft with hundreds of faults the list is capped, and it says so on the last line), and — more important — does it MEAN what the user thinks (`interpretation` states in plain words what it actually does). Always show `interpretation`, **including when `ready` is true**: a valid policy can still mean something unintended — a spending cap with no window is a LIFETIME cap, not a monthly one. `notChecked` lists what could not be verified, including whether the policy would be enforced at all | `{ policyKey, attributes, validFromBlock?, validToBlock?, templateId?, publisher? }` | `{ policyKey, ready, declared?, blockers, interpretation, notChecked }` — `declared` is the template's own attribute names, present only when the template resolved; long results are bounded, and anything omitted is stated in `notChecked` rather than dropped silently |
32
+ | `get_policy_template_schema` | **Free** read of POLICY templates — and the place to START when writing a policy. With **no arguments** it lists the templates on offer (on testnet `native-v1` and `ztp20-v1`), each with its declared attributes and the `templateId` that `write_policy` needs, so nobody has to supply a publisher or a template contract address. `{ policyKey }` reads one template of the default publisher, `{ publisher }` lists a different publisher’s, and `{ publisher, policyKey }` and `{ templateId }` still work. An id is only ever returned once the chain has confirmed it: it is derived from the publisher and key, and an id that does not resolve is withheld rather than handed over. The listing is bounded (20 templates, 40 attributes each) and says what it left out. This is the only vocabulary that means anything on chain — the policy contract validates nothing, so an attribute outside it deploys cleanly and then enforces nothing at all. A template that cannot be read reports `{ error }` rather than `{ found: false }`, so "no such template" is never confused with "could not look it up". Beside `declared` it returns `attributeMeanings` — the description each declared attribute has in the service (`GET /policy/vocabulary`), including what an empty list means and what an attribute applies to or needs, labelled by `attributeMeaningsSource` as text from ms-zetrix that describes and does not instruct — and `attributesUnknownToService` for a declared name the service does not recognise (which refuses every transfer). If the vocabulary cannot be read the result carries `attributeMeaningsNote` saying so and `declared` is unchanged | `{ templateId?, publisher?, policyKey? }` | `{ found, publisher?, publisherSource?, templates?, templateId?, templateIdConfirmed?, declared?, attributeMeanings?, attributeMeaningsSource?, attributeMeaningsNote?, attributesUnknownToService?, error? }` |
33
+ | `get_my_policy` | **Free** read of the spending policies this owner has deployed on chain; defaults to this wallet's own address. Costs 2 + N chain calls and warns above 50 keys. An owner who has never deployed one is a normal absence, **not** an error — the policy contract is created lazily on first write — and a failed lookup keeps its own error state, so "we could not list your policies" is never presented as "you have none". Each policy that was read carries `forUpdate.expectedUpdatedAtBlock`, the value `update_policy` needs, already as a string | `{ owner? }` | `{ owner, policies?, warning?, error? }` |
34
+ | `policy_preflight` | **Free** check of a draft policy *before* it is deployed. 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`. Two questions: is it well-formed (`blockers` lists every problem at once, so one round of fixes is enough — on a hostile draft with hundreds of faults the list is capped, and it says so on the last line), and — more important — does it MEAN what the user thinks (`interpretation` states in plain words what it actually does). Always show `interpretation`, **including when `ready` is true**: a valid policy can still mean something unintended — a spending cap with no window is a LIFETIME cap, not a monthly one. It also applies the write service’s scope rules, so a draft the service would refuse is refused here for free: `assetScope` must be exactly `native` or `ztp20` (never a token symbol such as JMYR), a `ztp20` policy needs a `tokenAddress`, and any amount or count cap needs an `assetScope`. A token symbol written where `tokenAddress` belongs is refused with the registered address to use, and a `ztp20` draft with no `tokenAddress` names the registered ones. Every `*Window` must be a duration such as `7d`, `12h` or `30m` (or ISO `P7D`) — `week`, a bare number or a value with spaces around it is refused, and `interpretation` states each window's period in words (`1M` is one minute, not one month) — and `interpretation` states what each amount means in whole tokens, because amounts are raw base units. A non-zero amount cap (`perTransactionMax`, `cumulativeMax`, `velocityCap`) under one whole token is refused unless `amountUnit` says what is meant: `"whole"` gives whole-token amounts (`"1"`, `"0.5"`) that the wallet converts by the token's decimals and returns in `convertedAmounts`, and `"base"` confirms a tiny raw value is intended. `convertedAmounts` is for showing the user — never send it back as the values; a `"whole"` draft whose every amount already looks raw is refused with both readings and the exact raw value for each. `notChecked` lists what could not be verified, including whether the policy would be enforced at all | `{ policyKey, attributes, validFromBlock?, validToBlock?, templateId?, publisher?, amountUnit? }` | `{ policyKey, ready, declared?, blockers, interpretation, notChecked, convertedAmounts? }` — `declared` is the template's own attribute names, present only when the template resolved; long results are bounded, and anything omitted is stated in `notChecked` rather than dropped silently |
34
35
  | `check_policy_decision` | **Free** — but NOT free of consequence. Ask whether a specific spend is permitted RIGHT NOW: the one policy question no chain read can answer, because a cap is measured against cumulative spend held off-chain. Three outcomes — `permitted`, `refused`, `undetermined`. **`undetermined` is neither a refusal nor permission**: it means nothing was evaluated (service unreachable, ledger stale, or none configured), and it is what every failure produces — no transport error, bad envelope or unknown verdict string can ever yield `permitted`. There is **no step-up verdict**; the decision service answers only allow or deny, so the wallet never reports that a human must approve a spend. **A permitted answer reserves the owner’s budget for 15 minutes**, with no way to release it early — so do not poll it, do not probe amounts (every allow along the way reserves again, and the owner’s next real payment can be refused by their own agent), and **never retry automatically after a timeout**: the call may have succeeded and reserved already. To find an amount that fits, read `remaining` from one answer and compute locally. `requestKey` is **correlation only, not idempotency**: repeating it does not deduplicate and each permitted answer reserves again. While the spend-ledger crawl is off, every decision answers `undetermined` / `EVALUATION_UNAVAILABLE`, which is correct behaviour rather than an outage. `ignored` is surfaced on a permitted answer too — it lists constraints the policy carries that the service could not enforce | `{ ownerAddress, asset, amount, policyKey?, recipientAddress?, method?, payTo?, paymentNonce?, requestKey? }` | `{ outcome, summary, reasonCode?, policyKey?, enforced?, ignored?, remaining?, capacityReturnsAt?, reservationId?, notChecked }` |
35
- | `write_policy` | **Pays a real fee.** Deploy a spending policy on chain over x402. Three steps: a free pre-check, a payment that writes **nothing**, and a collect that finishes the write once the payment settles. Between the payment and the settlement a payment has been made and no policy exists — **that window is normal, not a failure**. `settling` and `submitted` both mean a payment has been made: pass `paymentReceipt` to `check_policy_write`, never call this again. `submitted` carries a `txHash` and still is **not** a written policy — the block has not confirmed it. `written` is the only state where the policy exists. `already_exists` / `refused` come from the free pre-check, so nothing was paid; `payment_refused` says nothing either way about whether the fee was taken. `receipt_void` is the one state where paying again is right (`payFresh`). The fee goes through the same payment cap as every other paying tool, and the wallet never builds a blob, permit digest or canonical JSON — ms-zetrix signs with the owner’s own key | `{ policyKey, attributes, templateContractAddress, templateId, validFromBlock?, validToBlock?, requestKey?, pollBudgetMs? }` | `{ state, message, paymentReceipt?, policyKey?, txHash?, paid?, payFresh? }` |
36
- | `check_policy_write` | **Free — never pays, on any path.** Finish a policy write 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 | `{ paymentReceipt?, pollBudgetMs? }` | `{ state, message, paymentReceipt?, policyKey?, txHash?, paid?, payFresh? }` |
36
+ | `write_policy` | **Pays a real fee, and only with `confirm: true`** (the user must have seen the interpretation and the price; without it the call returns the price and pays nothing). Deploy a spending policy on chain over x402. 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`. Three steps: a free pre-check, a payment that writes **nothing**, and a collect that finishes the write once the payment settles. Between the payment and the settlement a payment has been made and no policy exists — **that window is normal, not a failure**. On the sponsored path `settling` and `submitted` both mean a payment has been made: pass `paymentReceipt` to `check_policy_write`, never call this again. `submitted` carries a `txHash` and still is **not** a written policy — the block has not confirmed it. `written` is the only state where the policy exists. `already_exists` / `refused` come from the free pre-check, so nothing was paid; `payment_refused` says nothing either way about whether the fee was taken. `receipt_void` is the one state where paying again is right (`payFresh`). The fee goes through the same payment cap as every other paying tool, and the wallet never builds a blob, permit digest or canonical JSON — ms-zetrix signs with the owner’s own key. `templateContractAddress` is optional and best left out: the wallet uses the one Template contract it is configured for and refuses any other before any payment, so there is nothing for a user to supply. **`dryRun: true` returns the price without paying** — it runs every free check and stops at the quote (`quoted`): nothing is paid, collected or written to chain, and it never collects an already-paid write, because collecting signs and writes (it only keeps a local bookmark so `check_policy_write` can finish it). The quote also carries `affordability` — whether the wallet holds the quoted amount of the fee asset (plus some ZTX for gas, when the payer needs it) and whether the payment cap would refuse it, each shortfall named separately and a failed balance read reported as `unknown`, never as a yes. It does not estimate the network fee: a payment made in ZTX needs the amount plus that fee from one balance, so `feeNotEstimated` says when "affordable" means only "holds the amount". A quote is what the service asked for just now, not a promise the payment will be allowed: balances move and the cap is enforced again when paying. **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, templateId, templateContractAddress?, validFromBlock?, validToBlock?, requestKey?, pollBudgetMs?, amountUnit?, dryRun?, confirm?, gasPayer?, maxGasRaw? }` | `{ state, message, paymentReceipt?, policyKey?, txHash?, paid?, payFresh?, quote?, affordability?, gasOptions?, needsConfirmation?, policyDenied?, policyCheckUnavailable?, upstream? }` |
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
+ | `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
+ | `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? }` |
37
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 |
38
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 |
39
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? }` |
40
- | `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: a decision, not a failure, so retrying will not help | `{ token, to, amountHuman?, amount?, confirm?, dryRun? }` | `{ sent, txHash?, nonce, token, asset, amount, amountHuman, decimals, fee, needsConfirmation?, needsTokenAddress?, outcomeUnknown?, policyDenied? }` |
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? }` |
44
+
45
+ > **A spending-policy refusal on a paying tool is a result, not an error.** Wallet BE checks the owner's spending policy
46
+ > before it signs any transaction that spends the wallet's assets (a message, a nonce or a presentation is not checked, and in current versions neither is a contract call that moves no asset, such as the self-paid policy write), so
47
+ > `pay_and_fetch`, `subscribe_and_issue`, `request_ai_birthcert_verification`, `write_policy` and `update_policy` can each be refused at the
48
+ > signature. The result then carries `policyDenied: true` (the policy said no: a decision, retrying will not help) or
49
+ > `policyCheckUnavailable: true` (the check could not complete and Wallet BE failed closed: transient, trying again shortly is
50
+ > right), never both. The signature is the last step before the X-PAYMENT header exists, so in both cases **nothing was
51
+ > paid**, and the message says what the call had already done (a quote, the free pre-check, the issuer's payment request),
52
+ > none of which moves money. `pay_and_fetch` returns it as `{ status: 402, paymentMade: false, policyDenied | policyCheckUnavailable,
53
+ > reason }`; `subscribe_and_issue` as `{ issued: false, … }`; `request_ai_birthcert_verification` as `{ error, … }`;
54
+ > `write_policy` and `update_policy` as state `refused` (a decision) or `unavailable` (transient), with `paid: false`. A refusal is never retried on
55
+ > the other gas option. Every other failure is unchanged. `transfer_token` reports the same two outcomes.
41
56
 
42
57
  > **VCs are cached locally**, keyed by `templateId`, under `~/.agentic-wallet-mcp/vc-cache/`
43
58
  > (scoped per network + holder — different identities or networks never share a cache).
@@ -183,15 +198,17 @@ Node ≥ 18 required (built-in `fetch`).
183
198
  | `ZETRIX_ADDRESS` | no | Holder Zetrix address (the HSM account). Omit on first run — see "Onboarding" below |
184
199
  | `HOLDER_DID` | no | Holder DID. Omit and the MCP derives it automatically — see "Onboarding" below |
185
200
  | `WALLET_BE_URL` | no | Wallet BE base URL override (HSM `/wallet/hsm/sign-blob`) — auto-derived from `ZETRIX_NETWORK` when not set |
186
- | `MBI_BASE_URL` | no | MBI RS base URL override (`/v1/vc/pay/apply`) — auto-derived from `ZETRIX_NETWORK` when not set |
201
+ | `MBI_BASE_URL` | no | MBI RS base URL override (`/v1/vc/pay/apply`; `/v1/*/ext/*` calls are signed over the API path `/v1/...` only; a path prefix here is sent but not signed, so a gateway must strip it before MBI verifies the request) — auto-derived from `ZETRIX_NETWORK` when not set |
187
202
  | `OID4VP_BASE_URL` | no | OID4VP verifier base URL override — auto-derived from `ZETRIX_NETWORK` by the x401 SDK when not set |
188
203
  | `ZETRIX_NODE_HOST` / `ZETRIX_NODE_PORT` | no | RPC node override (auto-derived from network) |
189
204
  | `ZID_RESOLVER_BASE_URL` | no | ZID resolver override (auto-derived from network: sandbox for testnet, prod for mainnet) |
190
- | `MAX_PAYMENT_AMOUNT` | no** | Per-asset x402 auto-pay cap — JSON `{ "<asset>": "<maxRawUnits>", "*": "<fallback>" }`. `pay_and_fetch`/`subscribe_and_issue` are asset-agnostic: the resource server's 402 challenge may quote the native ZETRIX token (asset code `ZTX`) **or** a ZTP20 token (e.g. `JMYR`) — cap whichever assets you expect. **Either the ticker or the contract address works** — the challenge identifies a ZTP20 token by its contract address, and the wallet resolves a known ticker (e.g. `JMYR`) to it. If both are written for the same asset, the contract address wins. An unrecognised ticker still matches nothing and falls through to `"*"`. e.g. `{"ZTX":"1000000000","ZTX3WeinXtt28YMyr4vUZ14ddTgEMGeuc1e6b":"5000000","*":"0"}`. **Default when unset:** both networks allow exactly the AI Birthcert fee (1 JMYR), keyed to that network's JMYR contract, and refuse everything else. **The cap is per call, not cumulative** — so an unconfigured wallet, mainnet included, can pay that fee once per call with no overall ceiling. Set this explicitly to lock a wallet down. |
205
+ | `MAX_PAYMENT_AMOUNT` | no** | Per-asset x402 auto-pay cap — JSON `{ "<asset>": "<maxRawUnits>", "*": "<fallback>" }`. `pay_and_fetch`/`subscribe_and_issue` are asset-agnostic: the resource server's 402 challenge may quote the native ZETRIX token (asset code `ZTX`) **or** a ZTP20 token (e.g. `JMYR`) — cap whichever assets you expect. **Either the ticker or the contract address works** — the challenge identifies a ZTP20 token by its contract address, and the wallet resolves a known ticker (e.g. `JMYR`) to it. If both are written for the same asset, the contract address wins. An unrecognised ticker still matches nothing and falls through to `"*"`. e.g. `{"ZTX":"1000000000","ZTX3WeinXtt28YMyr4vUZ14ddTgEMGeuc1e6b":"5000000","*":"0"}`. **Default when unset:** both networks allow exactly the AI Birthcert fee (1 JMYR), keyed to that network's JMYR contract, and refuse everything else. **The cap is per call, not cumulative** — so an unconfigured wallet, mainnet included, can pay that fee once per call with no overall ceiling. Set this explicitly to lock a wallet down. **A spending policy stands the DEFAULT cap aside for an asset it governs** (an `assetScope` for that asset, a `perTransactionMax`, and no block range), so the owner's own limit applies instead of a 1 JMYR default underneath it; an explicit `MAX_PAYMENT_AMOUNT` is never bypassed, and a failed read of the policy list leaves the cap in force. The policy is only as strong as Wallet BE's enforcement of it, which is off in prod as shipped. **The agent could write such a policy itself**, so `write_policy` pays and writes only with `confirm: true`, which the user must have given after seeing the interpretation and the price; the wallet cannot verify a person said yes, it is the same instruction-level gate `transfer_token` uses. Collecting a write that was paid for elsewhere needs the same confirmation, and `pay_and_fetch` refuses the policy-write service's paid paths. |
191
206
  | `ZETRIX_WALLET_STATE_DIR` | no | Where the wallet keeps `account.json` and its VC cache. Defaults to `~/.agentic-wallet-mcp` |
192
- | `SSIVC_BASE_URL` | no | myid's SSIVC API base URL, for the Verified AI Birthcert flow (`request_ai_birthcert_verification`/`check_ai_birthcert_verification`). **Auto-derived per network** — testnet `https://ssivc-api-uat.myegdev.com/api`, mainnet `https://verifyid-api.zetrix.com/api`. Override only if either changes |
207
+ | `SSIVC_BASE_URL` | no | myid's SSIVC API base URL, for the Verified AI Birthcert flow (`request_ai_birthcert_verification`/`check_ai_birthcert_verification`). **Auto-derived per network** — testnet `https://ssivc-api-uat.myegdev2.com/api`, mainnet `https://verifyid-api.zetrix.com/api`. Override only if either changes |
208
+ | `MYID_VERIFY_LINK_TEMPLATE` | no | The link `create_verification_qr` gives the user, with `{referenceId}` where the MBI reference id goes — MyID's universal link, e.g. `https://<MyID link domain>/<path>?referenceId={referenceId}` — must be `https`, with the placeholder in the path or query (not the host); a template that breaks this is warned about at startup and refused by the tool. **Defaults on testnet** to the UAT link the MyID app is registered for, `https://ssivc-api-uat.myegdev2.com/api/agentic-verify?referenceId={referenceId}` (read from that host's `apple-app-site-association`, which claims exactly that path and query). **No default on mainnet** until MyID's production side is verified: MyID has stated `https://myid-verifier.zetrix.com/api/agentic-verify?referenceId={referenceId}`, but it was not deployed when this was written, so the tool refuses before it creates anything rather than hand out a link that may not open. Set this variable to use it (or any other link) there; it also overrides the testnet default |
193
209
  | `POLICY_DECISION_URL` | no | Base URL of the policy DECISION service, for `check_policy_decision`. **No default**: the endpoint sits behind an authenticated path this wallet has no credential for, so guessing a host would turn "nobody has wired this up" into a connection error. With none configured the tool answers `undetermined` and says why |
194
210
  | `POLICY_DECISION_AUTH` | no* | `Authorization` header value for the policy decision service (e.g. `Bearer …`). Without it the service answers 401 and the tool reports that the wallet is not authorised — not that the request was wrong |
211
+ | `POLICY_TEMPLATE_PUBLISHER` | no | The publisher whose policy templates the wallet offers when the caller names none. **Auto-derived per network** — on testnet `ZTX3QFo5oc3Ep8rdJZKgfPDFNN29qjxn5ofED`, and none elsewhere: no policy module is deployed on mainnet, so a guessed publisher would read as "this publisher has no templates" instead of "there is no policy system here". Override only to point at a different publisher |
195
212
  | `AI_BIRTHCERT_VERIFIED_TEMPLATE_ID` | no | The Verified AI Birthcert's on-chain `did:zid:...` template id. Auto-derived per network by default — **the mainnet default is unverified**, so override this explicitly once the mainnet template id is confirmed |
196
213
  | `GAS_PREFERENCE` | no | `sponsored` or `self` — the default gas payer for the Verified AI Birthcert flow (`request_ai_birthcert_verification`). **Defaults to `sponsored`; any unrecognised value also falls back to `sponsored`** rather than throwing. Sponsorship only actually engages when the resource server's x402 quote offers a sponsored option (`extra.gasModel: "facilitator"`) — a server that only quotes self-pay behaves unchanged. **Sponsorship is testnet-only today**; override per-call with the tool's `gasPayer` parameter |
197
214
  | `MAX_SETTLEMENT_ATTEMPTS` | no | Cap on retry attempts while polling a sponsored payment for settlement after SSIVC returns `202 Accepted` (queued). **Defaults to `20`; a non-positive or unparseable value also falls back to `20`** |