feat(ecash): adopt the reference NUT-02 resolver + real/test network switch
Demo images / Build & push demo images (push) Failing after 2m26s

Executes steps 1-3 of docs/cashu-cdk-migration-plan.md, plus the test-coin
switch needed to exercise these routes without spending real sats.

Protocol layer: depend on `cashu` 0.17.5 (MIT, the crate CDK is built on,
default-features off, `wallet` only). Keyset ids now go through upstream's
`Id::from_short_keyset_id` / `ShortKeysetId` instead of the prefix match
hand-rolled in 2277fc46 — same repair, but implemented by the reference
code that defines the rule, so the next spec turn is a version bump rather
than another incident. `MintClient` feeds it the mint's `/v1/keysets` in
upstream's own `KeySetInfo` shape, parsing entries individually so one
keyset in an unmodelled unit can't block resolving the id we need.

Adding the crate required relaxing `bip39 = "=2.1.0"` to `"2.1"` (resolves
2.2.2): the exact pin held `unicode-normalization` at 0.1.22 and no
resolution existed otherwise. The pin carried no recorded rationale; seed
tests cover the bump.

Network switch: `wallet.ecash-network` / `wallet.ecash-set-network`, with a
Test mode toggle in Wallet Settings → Cashu. Cashu has no testnet, so this
points the wallet at the public `testnut` mint — but crucially each network
gets its OWN wallet and accepted-mints file, because test and real proofs
in one purse would be spendable interchangeably and the balance would be a
lie. Mainnet keeps the original filenames, so existing funds files are
untouched and switching is reversible: tests assert a real balance survives
a round trip through test mode.

Headless coverage: scripts/test-ecash-routes.sh drives every ecash RPC over
the real HTTP path (network get/set, balance, history, mint quote + claim,
send, receive, double-redeem refusal, garbage input, melt quote), restores
the node's original network on exit, and exits non-zero with the failure
count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-17 05:04:25 -04:00
co-authored by Claude Fable 5
parent 03cf74696d
commit be2cfb8293
9 changed files with 917 additions and 83 deletions
@@ -24,6 +24,35 @@
<!-- ===================== Cashu Mints ===================== -->
<div v-show="activeTab === 'cashu'">
<!-- Real vs test ecash. Each network keeps its own wallet, so switching
parks one balance and reveals the other it never merges them. -->
<div class="mb-4 rounded-lg border border-white/10 bg-white/[0.04] p-3">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-white/80 text-sm font-medium">{{ ecashIsTest ? 'Test ecash' : 'Real ecash' }}</p>
<p class="text-white/40 text-xs mt-0.5">
{{ ecashIsTest
? 'Practice coins with no value, from a public test mint.'
: 'Real, spendable sats.' }}
</p>
</div>
<label class="flex items-center gap-2 cursor-pointer shrink-0">
<span class="text-white/60 text-xs">Test mode</span>
<input
type="checkbox"
:checked="ecashIsTest"
:disabled="switchingNetwork"
class="h-4 w-4 accent-orange-500"
@change="setEcashNetwork(($event.target as HTMLInputElement).checked)"
/>
</label>
</div>
<p v-if="ecashIsTest" class="mt-2 text-xs text-orange-200">
Your real ecash balance is safe and untouched it reappears when you turn test mode off.
</p>
<p v-if="networkError" class="mt-2 text-xs text-red-300">{{ networkError }}</p>
</div>
<p class="text-white/60 text-sm mb-4">
Cashu ecash tokens can only be received from mints in this list. Add a mint's URL to accept tokens issued by it.
</p>
@@ -359,12 +388,48 @@ watch(
(open) => {
if (open) {
loadMints()
loadEcashNetwork()
if (fedimintBackendReady) loadFederations()
loadArk()
}
},
)
// ---- Ecash network (real vs test coins) ----
const ecashIsTest = ref(false)
const switchingNetwork = ref(false)
const networkError = ref('')
async function loadEcashNetwork() {
try {
const res = await rpcClient.call<{ is_test: boolean }>({ method: 'wallet.ecash-network' })
ecashIsTest.value = !!res.is_test
} catch {
// Older node without the setting: treat as real ecash, which is the
// safe reading — never imply a real balance is "test".
ecashIsTest.value = false
}
}
async function setEcashNetwork(test: boolean) {
switchingNetwork.value = true
networkError.value = ''
try {
await rpcClient.call({
method: 'wallet.ecash-set-network',
params: { network: test ? 'testnet' : 'mainnet' },
})
ecashIsTest.value = test
// The mint list is per-network, so it must be re-read after a switch.
await loadMints()
} catch (err: unknown) {
networkError.value = err instanceof Error ? err.message : 'Failed to switch network'
await loadEcashNetwork()
} finally {
switchingNetwork.value = false
}
}
async function loadMints() {
loadingMints.value = true
mintError.value = ''