From x402fhe
Two-stage async unwrap of confidential cUSDC (ERC-7984) back into plain USDC (ERC-20) via the Zama KMS async gateway. Covers the `unwrap` command (burns cUSDC and may auto-finalize) and the `finalize-unwrap` command (completes the second stage using a burn handle or legacy request/proof params). Use when the user asks to unwrap, withdraw, redeem, convert back, exit, cash out, or release cUSDC into plain USDC, or when they need to resume a previously requested unwrap using a burnHandle. Do NOT use for wrap, pay, balance, escrow, identity, delegation, or HTTP-402 flows — those are handled by sibling skills (fhe-payment-basics, fhe-escrow, fhe-agent-identity, fhe-delegation, x402-demo-orchestrators).
How this skill is triggered — by the user, by Claude, or both
Slash command
/x402fhe:fhe-payment-unwrapThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
Two OpenClaw commands for redeeming confidential cUSDC back into plain USDC: `unwrap` and `finalize-unwrap`. The Zama canonical ERC-7984 wrapper exits through an **async KMS gateway**: step 1 burns the cUSDC and registers a decryption request; step 2 finalizes the burn by submitting a decryption proof that releases the underlying USDC. `unwrap.ts` attempts to do both in one invocation via `sess...
Two OpenClaw commands for redeeming confidential cUSDC back into plain USDC: unwrap and finalize-unwrap. The Zama canonical ERC-7984 wrapper exits through an async KMS gateway: step 1 burns the cUSDC and registers a decryption request; step 2 finalizes the burn by submitting a decryption proof that releases the underlying USDC. unwrap.ts attempts to do both in one invocation via session.publicDecrypt([burnHandle]) + finalizeUnwrap(), but if the KMS has not yet processed the request (typically 1-2 minutes on Sepolia) it returns an unwrap_requested response carrying a burnHandle that you must preserve and later feed to finalize-unwrap --handle.
Both scripts live in packages/openclaw-skill/scripts/ and are invoked via pnpm + tsx from anywhere in the monorepo. Each emits a single JSON line to stdout and never throws to the shell — parse it and branch on action, not on exit code.
unwrap_requestedWhen unwrap returns action: "unwrap_requested", the burnHandle field is the ONLY resume token for step 2. You MUST surface it to the user verbatim in the same turn, in a code span, with the literal follow-up command.
unwrap_requested is an ok: true successful step-1 response, NOT a failure. The cUSDC has already been burned on-chain. The underlying USDC is held by the wrapper contract, waiting for a decryption proof. Without the burnHandle, neither you nor the user can finalize — the handle is only in the stdout JSON of this one invocation and (in deep form) in the raw logs of the unwrap transaction. Once your buffer scrolls, it is effectively gone.
Rules:
burnHandle verbatim in your reply when action is unwrap_requested, inside a code span, plus the literal next-step command: `pnpm --filter @x402fhe/openclaw-skill exec tsx scripts/finalize-unwrap.ts --handle <burnHandle>`. Paraphrasing is not enough. The user needs to be able to copy-paste.unwrap_requested as a failure or error. It is the documented step-1 success state of a two-stage async flow. Telling the user "the unwrap failed" when it is actually pending finalization is a correctness violation — they will panic about stuck funds.unwrap to "fix" an unwrap_requested response. Running unwrap.ts a second time burns more cUSDC for a second unwrap request — it does NOT finalize the first one. See the next CRITICAL block.finalize-unwrap --handle hoping the KMS catches up. Each retry is a real RPC call and a signed transaction; chaining 20 retries in 90 seconds wastes gas, floods the KMS, and (worst of all) buries the burnHandle under a wall of retry output where the user can no longer find it. Bounded retry only — see the retry budget below.Rationalizations that do NOT override these rules:
| Rationalization | Why it is wrong |
|---|---|
| "The user is waiting — I should just keep retrying until it works" | The user is waiting for an ACCURATE status, not a stuck agent burning API calls. Surfacing the burnHandle with "wait 1-2 min then run finalize-unwrap --handle" unblocks them immediately — they can leave and come back. |
"autoFinalizeError literally says retry in the message — so I should retry" | The error text is advice for a human operator over minutes, not a signal for a tight loop over seconds. Respect the retry budget below. |
| "Tight-loop retries are normal for async work in other systems" | Not here. Each attempt is a signed transaction on a live network. The KMS gateway has a ~1-2 minute latency floor — retrying inside that window guarantees waste and guarantees the burnHandle scrolls off your context. |
| "The burnHandle looks like internal state — the user does not need it" | The burnHandle IS the user's claim ticket on the locked USDC. It is the only way to resume. Treat it with the same seriousness as the pay nonce in fhe-payment-basics. |
"I will remember the burnHandle and run finalize-unwrap myself on the next turn" | You might not get a next turn in the same context. Model turns can be truncated, agents swapped, windows compacted. The burnHandle must live in the chat transcript, not in your head. |
"I will suppress unwrap_requested and only report if final finalize also fails" | You are then hiding a successful step-1 from the user. If their network drops or your process restarts between your "hidden" attempts, they lose the handle forever. |
Red-flag self-check: if your drafted reply to the user contains the word "failed" or "error" after an unwrap_requested, or does NOT contain the raw burnHandle hex inside a code span, delete the draft and rewrite.
unwrap automaticallyThe cUSDC is burned on the first call. unwrap.ts calls cUSDC.unwrap(signer, signer, encryptedAmount, inputProof) which atomically burns the caller's cUSDC and queues a decryption request. Running unwrap.ts a second time for "the same" amount creates a SECOND burn for a SECOND unwrap request — the user now has two pending burnHandles, double the locked underlying, and no clean recovery path.
Rules:
unwrap.ts a second time in the same session. If you are about to invoke unwrap.ts and there is already an unwrap_requested or unwrap_complete earlier in the conversation for this wallet, STOP.finalize-unwrap --handle <burnHandle>, never unwrap again. The two commands are NOT interchangeable.unwrap.ts has three outcome branches, and the transaction-hash field names differ between them. Do not grep for txHash blindly — branch on action first, then read the correct field.
| Branch | action | TX hash field(s) | Meaning |
|---|---|---|---|
| A (happy path) | unwrap_complete | unwrapTxHash AND finalizeTxHash | Both step 1 and step 2 landed in this invocation. Underlying USDC is released. No follow-up needed. |
| B (publicDecrypt threw) | unwrap_requested | txHash (step 1 only) | Step 1 landed. Auto-finalize hit an exception (usually KMS not ready). burnHandle is set. Run finalize-unwrap --handle after 1-2 min. |
| C (stub mode OR handle not parseable) | unwrap_requested | txHash (step 1 only) | Step 1 landed. Either session.isStub is true (auto-finalize skipped entirely) or the burn event did not parse. burnHandle may be a real hex string OR the literal string "not found — check TX receipt for UnwrapRequested event". |
When reporting a tx hash to the user after unwrap:
action === "unwrap_complete": report both unwrapTxHash and finalizeTxHash. There is no txHash field on this branch.action === "unwrap_requested": report txHash. There is no unwrapTxHash or finalizeTxHash on this branch.When parsing burnHandle on Branch C: sanity-check that it starts with 0x before treating it as a bytes32 handle. If it does not, the parser did not find the burn event and the user must inspect the unwrap transaction receipt manually (look for UnwrapRequested or ConfidentialTransfer to the zero address on the cUSDC contract).
unwrap.ts may run for up to 90 seconds in one invocation. The happy path is: encrypt + send unwrap tx + wait for receipt + call publicDecrypt (round-trip to the Zama KMS, slow) + send finalize tx + wait for receipt. On Sepolia this is typically 30-60 seconds end-to-end; on a slow KMS day it can push past 90 seconds. Budget accordingly. The script is stateful per-invocation — do not interrupt it and re-run, and do not try to split it into smaller calls. If the bash command takes a while, that is expected.
unwrap_requested retry budgetAfter an unwrap_requested, the allowed approach is:
burnHandle verbatim plus the literal follow-up command. Stop. Do NOT call finalize-unwrap in the same turn.finalize-unwrap --handle <burnHandle> exactly once. If it returns "The KMS may not have processed...", report that back and tell the user to wait another minute.finalize-unwrap --handle attempts per burnHandle across the whole session, spaced at least 60 seconds apart. If 3 attempts have already been made, stop retrying and escalate — tell the user the KMS gateway appears slow or down.The baseline instinct "1 attempt immediately, then 2-3 retries" is for a human operator over minutes, not an agent over seconds. Never loop faster than one attempt per 60 seconds, and never exceed the session cap.
Both commands need one wallet mode plus an RPC URL. See ../../references/wallet-setup.md for the three modes (user, dfns, ledger-bridge), ../../references/env-vars.md for the full variable list, and ../../references/output-schema.md for the canonical ok/fail wrapper shape. Minimum working dev setup:
export WALLET_MODE=user
export USER_PRIVATE_KEY=0x...
export RPC_URL=https://sepolia.infura.io/v3/YOUR_KEY
When RPC_URL points to Sepolia, contract addresses auto-select — do not override CUSDC_ADDRESS unless you are targeting a non-default deployment. Both commands run from anywhere in the monorepo via the workspace filter:
pnpm --filter @x402fhe/openclaw-skill exec tsx scripts/<name>.ts [--flag value]
Do NOT use node scripts/..., npx tsx, or ts-node — the _wallet.ts singleton resolves imports via pnpm workspace paths and fails otherwise.
The unwrap auto-finalize path (Branch A) and the finalize-unwrap --handle path both require a live @zama-fhe/sdk install (not the config-only stub). In stub mode you can still run step 1, but you will always land in Branch C and must use the legacy --requestId/--cleartextAmount/--proof path to finalize.
When to use: the user asks to unwrap, withdraw, redeem, convert back, exit, cash out, or release cUSDC into plain USDC. This is the only command that performs the burn — it is always the first step. Do NOT use it to resume a previously pending unwrap (use finalize-unwrap --handle for that).
Invocation:
pnpm --filter @x402fhe/openclaw-skill exec tsx scripts/unwrap.ts --amount 2
--amount is a human-readable decimal string in cUSDC units ("2", "0.5", "100.123456"). Internally parseAmount converts it to 6-decimal micro-USDC. Do NOT pass micro-USDC directly.
Required env: RPC_URL, one wallet mode, enough cUSDC balance (run balance --decrypt true from fhe-payment-basics first if unsure), and enough ETH for gas on two transactions (~2.5M gas total).
Three possible return shapes — branch on action:
Branch A — auto-finalized (happy path, live SDK, KMS ready):
Returns:
{
"ok": true,
"action": "unwrap_complete",
"amount": "2",
"cleartextAmount": "2000000",
"unwrapTxHash": "0xabc1...",
"finalizeTxHash": "0xdef2...",
"burnEventType": "ConfidentialTransfer(burn)",
"note": "Auto-finalized via publicDecrypt. Underlying tokens released."
}
Underlying USDC is now in the user's wallet. No follow-up needed. Report BOTH unwrapTxHash and finalizeTxHash to the user.
Branch B — publicDecrypt threw, KMS not ready:
Returns:
{
"ok": true,
"action": "unwrap_requested",
"amount": "2",
"txHash": "0xabc1...",
"burnHandle": "0x9f3e...",
"burnEventType": "ConfidentialTransfer(burn)",
"autoFinalizeError": "publicDecrypt: KMS has not yet processed the request",
"note": "Step 1 complete. Auto-finalize failed — use finalize-unwrap --handle to complete manually after KMS processes the request."
}
Step 1 landed. Surface burnHandle verbatim per the CRITICAL block. Do NOT retry unwrap. Do NOT immediately tight-loop finalize-unwrap. Tell the user to wait 1-2 minutes and then run the finalize command.
Branch C — stub mode or burn event not parseable:
Returns:
{
"ok": true,
"action": "unwrap_requested",
"amount": "2",
"txHash": "0xabc1...",
"burnHandle": "0x9f3e...",
"burnEventType": "UnwrapRequested",
"note": "Step 1 complete. Run finalize-unwrap --handle <burnHandle> after KMS processes the decryption request (1-2 min)."
}
If session.isStub, auto-finalize was skipped — the --handle path to finalize-unwrap will also fail with "publicDecrypt requires live Zama SDK", so the user must take the legacy path (--requestId/--cleartextAmount/--proof). Tell them this.
If burnHandle equals the literal string "not found — check TX receipt for UnwrapRequested event", the burn event parser did not find a matching log. This is rare but possible with custom deployments. Tell the user to inspect the txHash receipt manually for a UnwrapRequested or ConfidentialTransfer (to address 0) event on the cUSDC contract, extract the handle, and pass it to finalize-unwrap --handle later. Always check burnHandle.startsWith("0x") before treating it as a hex handle.
Common errors (returned as {"ok":false,"error":"..."}):
--amount is required — forgot the flag.Invalid amount "..." — non-numeric input. Use 2, not two.execution reverted (bare) — most commonly insufficient cUSDC balance, or FHE clamping reduced the effective amount to zero. Run balance --decrypt true from fhe-payment-basics to confirm.insufficient funds for gas — need more Sepolia ETH.Missing USER_PRIVATE_KEY / could not detect network — env setup problem; see troubleshooting.When to use: ONLY to complete a previously-pending unwrap. You know you need this when a prior unwrap invocation returned action: "unwrap_requested" (Branch B or Branch C). Never run finalize-unwrap without a prior unwrap_requested in the conversation — there is nothing to finalize.
Two modes:
Mode 1 (recommended) — --handle: the script calls session.publicDecrypt([handle]) to obtain the cleartext amount + decryption proof from the Zama KMS, then submits finalizeUnwrap. Requires a live @zama-fhe/sdk (not stub mode).
pnpm --filter @x402fhe/openclaw-skill exec tsx scripts/finalize-unwrap.ts --handle 0x9f3e...
Mode 2 (legacy fallback) — --requestId + --cleartextAmount + --proof: skip the KMS round-trip and submit a pre-obtained proof directly. Use this ONLY if Mode 1 fails with "publicDecrypt requires live Zama SDK" (stub mode) or if you have out-of-band proof delivery. All three flags are required.
pnpm --filter @x402fhe/openclaw-skill exec tsx scripts/finalize-unwrap.ts \
--requestId 0x9f3e... \
--cleartextAmount 2000000 \
--proof 0x....
Required env: RPC_URL, one wallet mode, enough ETH for gas on one transaction (~500k gas).
Returns:
{
"ok": true,
"action": "unwrap_finalized",
"handle": "0x9f3e...",
"cleartextAmount": "2000000",
"txHash": "0x7788...",
"method": "publicDecrypt"
}
method is "publicDecrypt" for Mode 1 and "manual" for Mode 2; on Mode 2 the handle field is omitted. txHash here is the finalize transaction hash, which is the second leg of the unwrap flow (the first leg's hash is still the one from the original unwrap_requested response).
What to do if KMS still not ready: the script returns fail(...) with a message ending in "The KMS may not have processed the request yet. Wait 1-2 minutes and retry, or use --requestId + --cleartextAmount + --proof for manual finalization." Treat this as the documented pending state. Per the retry budget: wait at least 60 seconds, attempt once more, and escalate to the user after at most 3 attempts.
Common errors:
Either --handle (recommended) or --requestId + --cleartextAmount + --proof is required — no mode was selected.publicDecrypt requires live Zama SDK — not available in stub mode. Use --requestId + --cleartextAmount + --proof instead. — Mode 1 is unavailable. Switch to Mode 2.publicDecrypt returned no value for handle. The KMS may not have processed the request yet — wait 1-2 minutes and retry. — KMS gateway latency. Respect the retry budget.publicDecrypt failed: ... — generic KMS error (network, rate limit, upstream). Wait and retry once.execution reverted — the handle is invalid, already finalized, or the proof does not match the on-chain request. Do NOT retry blindly; re-examine the handle and escalate to the user.User: "Unwrap 2 cUSDC back to USDC."
pnpm --filter @x402fhe/openclaw-skill exec tsx scripts/unwrap.ts --amount 2
Parse stdout. If ok: true and action === "unwrap_complete", reply:
Unwrapped 2 cUSDC. Step 1 (burn) landed in
0xabc1…and step 2 (finalize) landed in0xdef2…— auto-finalized via the Zama KMS in one pass. Your underlying USDC is now back in your wallet. No follow-up needed.
Both tx hashes are surfaced. No mention of "unwrap_requested". No retry attempts.
User: "Unwrap 2 cUSDC back to USDC."
pnpm --filter @x402fhe/openclaw-skill exec tsx scripts/unwrap.ts --amount 2
Parse stdout. If ok: true and action === "unwrap_requested", reply:
Step 1 of unwrap complete — your 2 cUSDC has been burned and the Zama KMS has been asked to release the underlying USDC. The unwrap request landed in tx
0xabc1….The KMS needs about 1-2 minutes to process. After that, finalize step 2 by running:
pnpm --filter @x402fhe/openclaw-skill exec tsx scripts/finalize-unwrap.ts --handle 0x9f3e...Save the burnHandle
0x9f3e…— it is the only resume token for this unwrap and is not recoverable from chain state once this chat scrolls. I am NOT going to retry automatically: if the KMS is not ready yet, we will just waste gas and bury the handle. Ping me when you want me to finalize, or run the command yourself.
Do NOT call finalize-unwrap in the same turn. Do NOT call unwrap again.
User: "OK, finalize that unwrap now — the burnHandle is 0x9f3e..."
pnpm --filter @x402fhe/openclaw-skill exec tsx scripts/finalize-unwrap.ts --handle 0x9f3e...
If ok: true and action === "unwrap_finalized", reply with txHash and cleartextAmount / 1e6 as the USDC amount released. If the response contains "The KMS may not have processed...", report that back and tell the user to wait another minute — do not retry in the same turn without their go-ahead.
User: "Unwrap 2 cUSDC." (SDK is not installed; session.isStub === true.)
unwrap.ts runs step 1, skips auto-finalize, returns Branch C. Subsequent finalize-unwrap --handle 0x... fails with "publicDecrypt requires live Zama SDK — not available in stub mode." Escalate to the user: they must obtain requestId, cleartextAmount, and proof out-of-band (via the Zama gateway or a separate tool) and re-run finalize-unwrap with all three flags, OR install @zama-fhe/sdk and re-run finalize-unwrap --handle. The underlying USDC is locked in the wrapper until one of these completes.
| Error text (regex) | Cause | Remediation |
|---|---|---|
publicDecrypt requires live Zama SDK | Stub mode — @zama-fhe/sdk is not installed or not loaded | Install the SDK, or use finalize-unwrap --requestId --cleartextAmount --proof with out-of-band proof data |
The KMS may not have processed the request yet. Wait 1-2 minutes | KMS gateway latency (normal) | Wait at least 60 seconds, retry at most 3 times per session, escalate to user after that |
publicDecrypt returned no value for handle | KMS has not processed this handle yet | Same as above — wait and retry once, not a loop |
burnHandle: "not found — check TX receipt for UnwrapRequested event" | Branch C parser fallback — no matching log on the cUSDC contract | Inspect the txHash receipt manually for UnwrapRequested or ConfidentialTransfer(from, 0x0, handle); extract handle; feed to finalize-unwrap --handle |
execution reverted on unwrap | Usually insufficient cUSDC balance, or FHE clamping reduced amount to zero | Run balance --decrypt true from fhe-payment-basics first to confirm a positive encrypted balance |
execution reverted on finalize-unwrap | Handle already finalized, invalid, or proof/request mismatch | Do NOT retry blindly; re-check the handle and escalate to the user |
insufficient funds for gas | No Sepolia ETH | Fund the wallet (see info in fhe-payment-basics for the address) |
Missing USER_PRIVATE_KEY / Missing DFNS_\w+ | Wallet env vars not set | See ../../references/wallet-setup.md |
could not detect network | Bad RPC_URL, rate-limited, or endpoint down | Check key, quota, URL |
Bash exit ≠ 0 with stack trace (NOT a JSON line) | Script crashed before returning — usually _wallet.ts init error | Check env vars, check RPC connectivity by running info from fhe-payment-basics first |
This skill covers ONLY unwrap and finalize-unwrap. Do not use it for:
info / balance / wrap / pay — see fhe-payment-basicscreate-job / fund-job / submit / complete-job — see fhe-escrowregister-agent / identity / metadata — see fhe-agent-identitygrant-view / revoke-view / view-as / balance --of — see fhe-delegationresearch-and-visualize / review-and-rate / HTTP-402 orchestration — see x402-demo-orchestratorsProvides CDSS development patterns for drug interaction checking, dose validation, clinical scoring (NEWS2, qSOFA), and alert classification integrated into EMR workflows.
npx claudepluginhub zama-ai/confidential-agentic-payment-stack --plugin x402fhe