Skip to content

Wallet provider abstraction, selection, and lifecycle commands (ENG-1974, ENG-1975)#12

Closed
AkasshP wants to merge 8 commits into
mainfrom
eng-1974/wallet-provider-abstraction
Closed

Wallet provider abstraction, selection, and lifecycle commands (ENG-1974, ENG-1975)#12
AkasshP wants to merge 8 commits into
mainfrom
eng-1974/wallet-provider-abstraction

Conversation

@AkasshP

@AkasshP AkasshP commented Jun 3, 2026

Copy link
Copy Markdown

Summary

Lands both ENG-1974 and ENG-1975 (supersedes #10, which this branch was originally stacked on).

Provider abstraction (ENG-1974)

  • New src/lib/providers/types.ts with the WalletProvider interface: getAccount, getAddress, optional login/logout/exportPrivateKey, status
  • Keystore account logic moved from src/lib/account.ts into src/lib/providers/keystore.ts; requireAccount/getOwnAddress are thin shims that delegate to the active provider (--private-key still overrides any provider)
  • wallet export routes through provider.exportPrivateKey — providers without it get a clear error (remote key material is not exportable)
  • Widened PrivateKeyAccountLocalAccount in eip3009.ts so remote providers (viem toAccount(...)) plug into the x402 path
  • x402 challenge parsing accepts amount alongside maxAmountRequired (v2 compat); parse errors report whichever field was actually the source
  • x402 v2 payment envelope (accepted echo + resource, string validity bounds, flat scheme/network kept per the Radius skill); v1 output unchanged
  • x402 settlement now dispatches on what the challenge advertises: Permit2 dual-signature (extra.assetTransferMethod: "permit2", official Radius flavor) → EIP-2612 permit (extra.settlementMethod: "permit-transferFrom", verified live on mainnet) → EIP-3009 (standard x402); SBC has no EIP-3009 so the prior path could never settle against it
  • x402 transport: challenge read from PAYMENT-REQUIRED header (body fallback), payment sent in PAYMENT-SIGNATURE + X-Payment, receipts from PAYMENT-RESPONSE first
  • wallet balance no longer double-counts SBC: eth_getBalance now returns the aggregate (token_balance × rate + raw_native, June 2026 RPC change), so it is treated as the total and the raw-native RUSD line is derived as the remainder (src/lib/balance.ts)

Provider selection + lifecycle (ENG-1975)

  • --wallet <keystore|cdp|para|privy> global flag; resolution order: CLI flag → RADIUS_WALLET env → ~/.radius/config.json → default keystore
  • wallet login, wallet logout, wallet status commands dispatching to the active provider
  • Keystore lifecycle: login points to wallet new/wallet import, logout is a no-op, status reports the cached address
  • Remote providers (cdp, para, privy) stubbed — getAccount/getAddress hard-error with "not yet implemented" instead of falling back to the local keystore
  • Invalid provider names rejected with a clear error listing valid options

Tests (64 total, was 32)

  • tests/providers.test.ts (new): stub provider rejection, keystore auto-create/reuse/export round-trip, explicit-password create + wrong-password rejection, shim dispatch, --private-key override, 0x-prefix normalization
  • tests/x402-protocol.test.ts: amount fallback, maxAmountRequired precedence, neither-present rejection, amount-sourced error label

Manual test plan

  • radius-cli wallet address — returns address without password prompt
  • radius-cli wallet sign "hello" — signs through provider
  • radius-cli --network testnet wallet send <addr> 0.001 RUSD — real tx on testnet
  • radius-cli wallet x402 get <url> — full x402 flow
  • radius-cli wallet login|logout|status (+ --json) — keystore lifecycle
  • radius-cli --wallet cdp wallet address|sign — "not yet implemented" error, no keystore fallback
  • radius-cli --wallet privy wallet export — "remote key material is not exportable"
  • radius-cli --wallet metamask wallet status — invalid provider error
  • RADIUS_WALLET=para radius-cli wallet status — env override works
  • radius-cli --private-key <key> wallet address — override bypasses provider
  • npm test — 64/64 pass

@AkasshP AkasshP requested a review from erikzrekz June 3, 2026 17:38
- tests/providers.test.ts: stub providers (cdp/para/privy) reject with
  "not yet implemented" and expose no exportPrivateKey; keystore provider
  auto-creates on first use, round-trips export, honors explicit password,
  rejects wrong password; requireAccount/getOwnAddress dispatch to the
  selected provider and --private-key overrides it
- tests/x402-protocol.test.ts: lock in the `amount` fallback (x402 v2),
  precedence of maxAmountRequired, and rejection when both are absent

Covers the test gap flagged in the PR #10 review. 50/50 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@erikzrekz

Copy link
Copy Markdown

Pushed 5eb741c onto this branch — test coverage for the new behavior (the gap flagged in the PR #10 review). No source changes, 50/50 passing.

tests/providers.test.ts (new):

  • Stub providers (cdp/para/privy): getAccount/getAddress reject with "not yet implemented", and exportPrivateKey is absent (so the wallet export guard trips)
  • Keystore provider: auto-creates on first getAddress and reuses it for getAccount, exportPrivateKey round-trips to the keystore address, explicit-password create/reload works, wrong password rejects
  • Shims: requireAccount/getOwnAddress dispatch to the selected provider; --private-key overrides it; missing 0x prefix is normalized

tests/x402-protocol.test.ts (+3):

  • Locks in the v2 amount fallback, maxAmountRequired precedence when both are present, and rejection when neither is present

One question on the v2 fallback while you're in there: v1's maxAmountRequired is a ceiling — is v2's amount an exact charge or also a max? Aliasing seems fine for signing an authorization up to that value, but worth confirming against the server you tested. Tiny nit: when amount is the source, a parse failure still reports accepts[i].maxAmountRequired in the error message.

🤖 Generated with Claude Code

@AkasshP AkasshP changed the base branch from eng-1975/wallet-provider-lifecycle to main June 3, 2026 18:14
Exercises the branch added in fc1173d: an invalid `amount` with no
`maxAmountRequired` reports accepts[i].amount in the error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@erikzrekz erikzrekz changed the title Refactor keystore behind WalletProvider abstraction (ENG-1974) Wallet provider abstraction, selection, and lifecycle commands (ENG-1974, ENG-1975) Jun 3, 2026
… change

The Radius RPC change (2026-06) made eth_getBalance return
token_balance × per_unit_exchange_rate + raw_native, so the SBC token
balance is already included in the native balance. The balance command
was summing eth_getBalance + SBC balanceOf on top, double-counting SBC
(e.g. reporting $0.20 for a wallet holding only 0.1 SBC).

eth_getBalance is now treated as the total; the raw-native (RUSD) line
is derived as the remainder via splitAggregateBalance (clamped at zero),
assuming the 1 SBC = 1 native unit peg. JSON output gains totalWei and
rusdWei now reports the derived native remainder.

Note: per the same RPC change, the returned balance may exceed what the
network will provision for a single transaction — the CLI does not gate
sends on balance (viem's eth_estimateGas path validates instead).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@erikzrekz

Copy link
Copy Markdown

Heads-up: pushed two more commits while testing this branch against testnet (with Eriks).

698c967 — one more test covering the accepts[i].amount error label you added in fc1173d.

c56c71d — fixes wallet balance double-counting SBC. The June 2026 RPC change made eth_getBalance return the aggregate (token_balance × per_unit_exchange_rate + raw_native), so SBC is already included in the native balance — summing balanceOf on top reported $0.20 for a wallet holding only 0.1 SBC (dashboard correctly shows $0.10). Verified against testnet: CLI and dashboard now agree.

  • eth_getBalance is treated as the total; the RUSD line is the derived remainder (src/lib/balance.ts, clamped at zero), with 5 unit tests
  • JSON output: added totalWei; rusdWei now means the derived raw-native remainder
  • send is unaffected by the other half of that changelog warning — it never gates on balance, and viem's eth_estimateGas path is the recommended validation anyway

⚠️ One assumption to be aware of: the remainder math relies on the 1 SBC = 1 native unit peg. If per_unit_exchange_rate ever floats, the breakdown needs to read the rate instead of assuming it. Worth a quick confirm with the RPC team.

Suite is 56/56. PR description updated to match.

🤖 Generated with Claude Code

erikzrekz and others added 2 commits June 3, 2026 15:09
Two interop fixes verified end-to-end against a live Radius x402 server
(rad-read.replit.app — paid 0.001 SBC on mainnet, content delivered):

1. v2 payment header envelope. The CLI emitted the v1 shape (flat
   scheme/network, numeric validity bounds) regardless of challenge
   version. Per coinbase/x402 specs/schemes/exact/scheme_exact_evm.md,
   v2 echoes the chosen accepts entry verbatim as `accepted`, carries
   the challenge's `resource`, and stringifies validAfter/validBefore.
   parseChallenge now retains the raw accepts entry and resource for
   the echo. v1 output is unchanged.

2. EIP-2612 permit settlement (Radius flavor). SBC does not implement
   EIP-3009 transferWithAuthorization, so signTransferAuthorization
   payloads can never settle against it. Radius servers advertise
   `extra.settlementMethod: "permit-transferFrom"` plus
   `extra.settlementSpender` in the challenge; the CLI now detects
   that, reads the owner's permit nonce on-chain, signs an EIP-2612
   Permit for the settlement spender (deadline = now +
   maxTimeoutSeconds), and sends the documented flat envelope with a
   `kind: "permit-eip2612"` payload (v/r/s split signature). Servers
   without the extra keep the EIP-3009 path.

Tests: v2 envelope round-trip + v1 stability, permit signature
recovery to owner, permit header shape. 60/60 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per docs.radiustech.xyz/skills/x402.md and the reference client in
radiustechsystems/skills (plugins/radius/skills/x402):

- Permit2 dual-signature settlement when the challenge advertises
  extra.assetTransferMethod: "permit2": an EIP-2612 Permit for the
  canonical Permit2 contract (sequential nonce from token.nonces, goes
  in extensions.eip2612GasSponsoring for gas-free allowance setup) plus
  a Permit2 PermitWitnessTransferFrom for the x402 proxy (random
  128-bit nonce, witness binds payTo), sharing one deadline. Typed data
  taken verbatim from references/permit2-typed-data.template.json.
- Challenge detection: official v2 servers carry the challenge in a
  base64 PAYMENT-REQUIRED response header; body JSON remains the
  fallback for older servers.
- Payment is sent in both PAYMENT-SIGNATURE (official v2) and X-Payment
  (legacy) headers; receipts read from PAYMENT-RESPONSE before
  x-payment-response.
- v2 envelope keeps flat scheme/network alongside the accepted echo,
  matching the skill's payload example.

Settlement dispatch is now: permit2 (assetTransferMethod) →
permit-eip2612 (settlementMethod, verified live against rad-read) →
EIP-3009 (standard x402 default).

Tests: permit2 signature recovery under the skill typed data, payload
structure field-for-field vs the skill example, nonce randomness,
canonical addresses. 64/64 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@erikzrekz

Copy link
Copy Markdown

Two more commits pushed after exercising the full x402 flow against a live server (rad-read.replit.app, with Eriks):

2ae67dc — x402 v2 payment envelope + EIP-2612 permit settlement. Two things the amount parse fallback alone didn't cover:

  1. The payment header was still v1-shaped (flat envelope, numeric validity bounds), so v2 servers rejected it as malformed. The CLI now emits the v2 envelope (accepted echo of the chosen accepts entry, challenge resource, string validAfter/validBefore) when the challenge is v2; v1 output is byte-identical to before.
  2. SBC doesn't implement EIP-3009 transferWithAuthorization, so the existing signing path can't settle against it. Servers advertising extra.settlementMethod: "permit-transferFrom" + extra.settlementSpender now get an EIP-2612 permit payload (kind: "permit-eip2612", on-chain nonce, v/r/s) instead. Verified end-to-end: paid 0.001 SBC on mainnet, content delivered, balance 0.100 → 0.099.

44540c3 — alignment with the official Radius x402 skill (docs.radiustech.xyz/skills/x402.md):

  • Permit2 dual-signature settlement when the challenge advertises extra.assetTransferMethod: "permit2" — EIP-2612 permit for canonical Permit2 (goes in extensions.eip2612GasSponsoring) + PermitWitnessTransferFrom for the x402 proxy, typed data taken verbatim from radiustechsystems/skills references/permit2-typed-data.template.json
  • Challenge read from the PAYMENT-REQUIRED response header (body JSON kept as fallback); payment sent in both PAYMENT-SIGNATURE and X-Payment; receipts read from PAYMENT-RESPONSE first
  • Settlement dispatch: permit2 → permit-eip2612 → EIP-3009

Suite is 64/64. The permit2 path is spec-verified (payload matches the skill's reference client x402-pay.mjs field-for-field) but not yet live-verified — Eriks is building a Radius Endpoint Hub on Replit with 50+ x402 test scenarios (header variants, settlement methods, failure modes), which will give this branch a proper conformance run before merge.

Known follow-ups deliberately left out of this PR: the pay-confirmation prompt doesn't name the network (mainnet vs testnet), and X-PAYMENT will go out over plain http without warning. Both small, both deserve their own PR.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants