feat: fixed swap amount correctly

This commit is contained in:
venuswhispers
2026-05-28 04:41:55 +02:00
parent 21020685d9
commit 4dd61fedf6
11 changed files with 1039 additions and 16 deletions
BIN
View File
Binary file not shown.
+19
View File
@@ -43,6 +43,16 @@ type API struct {
mchain *mchain.Client // optional; when set, swap creation with use_deposit_address=true mints an MPC address
depcheck *depositcheck.Client // optional; powers the /v1/bridge/check-deposit diagnostic endpoint
// releaseStore is the per-destination-network release-wallet
// registry. Optional: when set, swapsCreateNative stamps the new
// swap with the long-lived MPC wallet that will pay out the
// destination-chain settlement. When nil, the signing driver
// falls back to the per-swap deposit wallet (the legacy path —
// works only if the operator pre-funded each per-swap address,
// which is impractical for testnet/prod and is the bug this
// store fixes).
releaseStore mchain.ReleaseWalletStore
// Native swap CRUD. The Go binary owns these — BridgeVM is the
// LP-333 signer-set manager, not a swap API (see
// architecture_go_bridge_stack memory). When both store and
@@ -96,6 +106,15 @@ func (a *API) SetProfile(p *bridge.BridgeProfile) {
}
}
// SetReleaseStore wires the per-destination-network release-wallet
// registry. Called from main.go after the mchain client + persistence
// path are resolved. Kept as a setter (not a NewAPI parameter) so the
// 20+ existing test callers of NewAPI don't need to change signature
// when they don't exercise the release-wallet path.
func (a *API) SetReleaseStore(rs mchain.ReleaseWalletStore) {
a.releaseStore = rs
}
// Register mounts handlers on the given zip.App. The /v1/bridge prefix
// matches what the SPA fetches and what hanzo/ingress routes externally.
func (a *API) Register(app *zip.App) {
+39
View File
@@ -32,6 +32,7 @@ import (
"fmt"
"os"
"os/signal"
"path/filepath"
"sort"
"strings"
"syscall"
@@ -129,6 +130,8 @@ func main() {
staticDir := flag.String("static", envOr("BRIDGE_STATIC_DIR", ""), "override embedded SPA from disk")
dataDir := flag.String("data-dir", envOr("BRIDGE_DATA_DIR", ""),
"persistent data directory for the swap store (zapdb). When empty, swaps are stored in-process and lost on restart — only use the in-memory mode for tests + first deploys. In prod, mount a PersistentVolume and point this at it (e.g. /var/lib/lux-bridge).")
releaseFile := flag.String("release-wallets-file", envOr("BRIDGE_RELEASE_WALLETS_FILE", ""),
"path to a JSON file that persists per-destination-network release wallets (the long-lived MPC addresses that pay out settlements). When set, the bridge mints a release wallet on first need and reuses it across restarts. When empty AND --data-dir is set, defaults to <data-dir>/release-wallets.json. When empty AND --data-dir is empty, runs in-memory — every restart re-mints and any liquidity at the old address is stranded. Required for prod.")
profileFlag := flag.String("profile", envOr("BRIDGE_PROFILE", "classical-compat"),
"bridge security profile: strict-pq | classical-compat")
showVersion := flag.Bool("version", false, "print version and exit")
@@ -324,6 +327,42 @@ func main() {
api := NewAPI(cfg, *backend, bchainClient, mchainClient, depCheckClient, swapStore, quoteEngine)
api.SetProfile(profile)
// Per-destination-network release wallets. One MPC wallet per
// destination chain, minted lazily on first need and reused across
// every swap to that chain. The operator pre-funds each release
// wallet's destination-chain address with native gas + bridged
// liquidity; the signing driver targets THIS wallet (not the
// per-swap deposit wallet) when producing the release tx, because
// only this wallet's destination-chain address holds the operator-
// funded balance the payout needs.
//
// Path resolution:
// --release-wallets-file set → use it verbatim
// --release-wallets-file unset AND
// --data-dir set → <data-dir>/release-wallets.json
// both unset → in-memory (lossy on restart)
if mchainClient != nil {
releasePath := *releaseFile
if releasePath == "" && *dataDir != "" {
releasePath = filepath.Join(*dataDir, "release-wallets.json")
}
releaseStore, err := mchain.NewFileReleaseStore(mchainClient, releasePath)
if err != nil {
logger.Error("release wallet store init", "err", err, "path", releasePath)
os.Exit(1)
}
api.SetReleaseStore(releaseStore)
if releasePath == "" {
logger.Warn("release wallet store is in-memory — every restart re-mints per-network release wallets; set --release-wallets-file (or --data-dir) for durability")
} else {
logger.Info("release wallet store enabled",
"path", releasePath,
)
}
} else {
logger.Info("release wallet store skipped — no --mpc-url configured")
}
// Deposit watcher: background goroutine that polls the source chains
// for confirmed deposits and advances pending swaps. Only meaningful
// when there's something to check against — disable when no
+38 -3
View File
@@ -224,7 +224,7 @@ func (d *SigningDriver) tick(ctx context.Context) {
// SwapStatusSigning before the request fires so a restart can detect
// "in-flight" state separately from "ready to sign."
func (d *SigningDriver) signOne(ctx context.Context, sw *Swap) {
walletID := extractWalletID(sw.DepositAddress)
walletID, senderAddr := resolveReleaseSigning(sw)
if walletID == "" {
// Swap was created without a minted MPC wallet (likely
// use_deposit_address=false). The signing flow doesn't apply
@@ -258,13 +258,12 @@ func (d *SigningDriver) signOne(ctx context.Context, sw *Swap) {
// broadcast as a real tx.
var msgHex string
var unsigned *txassembler.Unsigned
senderAddr := extractDepositAddress(sw.DepositAddress)
if d.assembler != nil && senderAddr != "" {
var aerr error
unsigned, aerr = d.assembler.PreSign(ctx, txassembler.SwapIntent{
DestinationNetwork: sw.DestinationNetwork,
DestinationAddress: sw.DestinationAddress,
Amount: sw.Amount,
Amount: releaseAmount(sw),
SenderAddress: senderAddr,
})
if aerr != nil {
@@ -393,6 +392,42 @@ func (d *SigningDriver) signOne(ctx context.Context, sw *Swap) {
}
}
// resolveReleaseSigning picks the MPC wallet that signs (and pays for)
// the destination-chain release tx. Prefers the long-lived per-network
// ReleaseWalletID/Address stored on the swap (added 2026-05 with the
// release-wallet split). Falls back to the per-swap deposit wallet for
// swaps created before the split landed AND for bridges running
// without --release-wallets-file. Fallback swaps will broadcast-fail
// with "insufficient funds in release address" unless the operator
// pre-funded the per-swap address — that's the bug the release wallet
// fixes, and the fallback only exists so legacy in-flight swaps don't
// stall the driver loop.
func resolveReleaseSigning(sw *Swap) (walletID, address string) {
if sw.ReleaseWalletID != "" && sw.ReleaseAddress != "" {
return sw.ReleaseWalletID, sw.ReleaseAddress
}
return extractWalletID(sw.DepositAddress), extractDepositAddress(sw.DepositAddress)
}
// releaseAmount picks the destination-asset amount the release tx
// should carry. Prefers the quote-snapshot ReceiveAmount (added
// 2026-05 alongside the release-wallet split — see swap_store.go
// docs) which is what the user was promised at swap-create time.
// Falls back to the raw input amount only when ReceiveAmount is zero
// — i.e. legacy swap rows created before the snapshot was wired, or
// swaps created against a bridge running without a quote engine.
//
// The fallback is a safety net for in-flight legacy rows. New swaps
// always have ReceiveAmount populated (swapsCreateNative now fails
// loudly if pricing isn't available), so the fallback path should
// rarely fire after the migration.
func releaseAmount(sw *Swap) float64 {
if sw.ReceiveAmount > 0 {
return sw.ReceiveAmount
}
return sw.Amount
}
// extractWalletID pulls the wallet-id half from the "wallet_name###address"
// envelope mchain returns. Returns "" when the envelope marker is missing
// or the input is empty.
+203
View File
@@ -375,6 +375,209 @@ func TestSigning_BadSignatureFromMPC_RollsBack(t *testing.T) {
}
}
// =============================================================================
// releaseAmount — input-vs-output amount plumbing
// =============================================================================
func TestReleaseAmount_PrefersReceiveAmount(t *testing.T) {
sw := &Swap{Amount: 0.01, ReceiveAmount: 14}
if got := releaseAmount(sw); got != 14 {
t.Errorf("releaseAmount=%v, want 14 (the destination-asset output)", got)
}
}
func TestReleaseAmount_FallsBackToInputAmount(t *testing.T) {
// Legacy row with no ReceiveAmount snapshot — fall back to the
// raw input amount so in-flight pre-fix swaps don't ship a
// zero-value release tx.
sw := &Swap{Amount: 0.01}
if got := releaseAmount(sw); got != 0.01 {
t.Errorf("releaseAmount=%v, want 0.01 (input-amount fallback)", got)
}
}
// TestSigning_SendsReceiveAmountToAssembler verifies the assembler is
// fed the destination-asset OUTPUT amount, not the input. This is the
// regression test for the "swap completed but I got 0.01 LUX instead
// of 14" bug — caught when the user's wallet balance didn't move as
// expected after the release-wallet split fix.
func TestSigning_SendsReceiveAmountToAssembler(t *testing.T) {
store := NewInMemoryStore()
signer := newFakeSigner()
const (
releaseWalletID = "wallet-release"
releaseAddr = "0x5050505050505050505050505050505050505050"
)
sw := &Swap{
Status: SwapStatusBridgeTransferPending,
Amount: 0.01, // input ETH
ReceiveAmount: 14, // output LUX (the user-facing committed amount)
SourceNetwork: "ETHEREUM_SEPOLIA",
SourceAsset: "ETH",
DestinationNetwork: "LUX_TESTNET",
DestinationAsset: "LUX",
DestinationAddress: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
ReleaseWalletID: releaseWalletID,
ReleaseAddress: releaseAddr,
UseDepositAddress: true,
}
if err := store.Create(t.Context(), sw); err != nil {
t.Fatal(err)
}
prov := &txassembler.StaticProvider{
Nonces: map[string]uint64{
"LUX_TESTNET|" + strings.ToLower(strings.TrimPrefix(releaseAddr, "0x")): 0,
},
GasPrice: map[string]*big.Int{"LUX_TESTNET": big.NewInt(25_000_000_000)},
}
asm := txassembler.New(prov)
asm.SetNetwork("LUX_TESTNET", txassembler.PerNetwork{
ChainID: big.NewInt(96368), DefaultGasLimit: 21000, NativeDecimals: 18,
})
sigHex := "0x" + strings.Repeat("01", 32) + strings.Repeat("02", 32) + "00"
signer.ok(releaseWalletID, sigHex, "sess-amt")
d := NewSigningDriver(store, signer, time.Hour, nil)
d.SetAssembler(asm)
d.Tick(t.Context())
got, _ := store.Get(t.Context(), sw.ID)
if got.Status != SwapStatusBroadcasting {
t.Fatalf("status = %q, want broadcasting; last_error=%q", got.Status, got.LastError)
}
rawTx := strings.TrimPrefix(got.DestRawTx, "0x")
if rawTx == "" {
t.Fatal("DestRawTx empty")
}
// Decode the RLP and inspect the value field. 14 LUX with 18
// decimals = 14 * 1e18 wei = 0xc249fdd327780000.
rlpBytes, err := hex.DecodeString(rawTx)
if err != nil {
t.Fatalf("rawtx not hex: %v", err)
}
const wantValueHex = "c249fdd327780000" // 14 * 1e18 wei
if !strings.Contains(strings.ToLower(hex.EncodeToString(rlpBytes)), wantValueHex) {
t.Errorf("release tx does not carry 14 LUX (0x%s) — bridge sent the input amount instead.\nrlp=%s",
wantValueHex, hex.EncodeToString(rlpBytes))
}
}
// =============================================================================
// resolveReleaseSigning — the release-wallet vs deposit-wallet split
// =============================================================================
func TestResolveReleaseSigning_PrefersReleaseWallet(t *testing.T) {
sw := &Swap{
DepositAddress: "wallet-deposit###0xDEP",
ReleaseWalletID: "wallet-release",
ReleaseAddress: "0xREL",
}
id, addr := resolveReleaseSigning(sw)
if id != "wallet-release" || addr != "0xREL" {
t.Errorf("expected release wallet to win, got id=%q addr=%q", id, addr)
}
}
func TestResolveReleaseSigning_FallsBackToDeposit(t *testing.T) {
// Legacy swap row: ReleaseWalletID/Address empty. Must fall back
// to the deposit-address envelope so in-flight pre-split swaps
// still drain through the signing driver.
sw := &Swap{DepositAddress: "wallet-legacy###0xLEGACY"}
id, addr := resolveReleaseSigning(sw)
if id != "wallet-legacy" || addr != "0xLEGACY" {
t.Errorf("expected deposit fallback, got id=%q addr=%q", id, addr)
}
}
func TestResolveReleaseSigning_PartialReleaseFallsBack(t *testing.T) {
// Defensive: a swap with only one of the release fields populated
// shouldn't half-sign with a half-set wallet. Fall back to the
// deposit envelope.
sw := &Swap{
DepositAddress: "wallet-legacy###0xLEGACY",
ReleaseAddress: "0xREL", // ReleaseWalletID missing
}
id, addr := resolveReleaseSigning(sw)
if id != "wallet-legacy" || addr != "0xLEGACY" {
t.Errorf("expected deposit fallback when ReleaseWalletID is empty, got id=%q addr=%q", id, addr)
}
}
// TestSigning_AdvancesUsingReleaseWallet end-to-ends the signing driver
// against a swap row carrying the release-wallet fields. Proves the
// driver calls the signer with the RELEASE wallet ID (not the deposit
// one) and feeds the RELEASE address into the assembler as
// SenderAddress.
func TestSigning_AdvancesUsingReleaseWallet(t *testing.T) {
store := NewInMemoryStore()
signer := newFakeSigner()
const (
depositWalletID = "wallet-deposit"
depositAddr = "0x4040404040404040404040404040404040404040"
releaseWalletID = "wallet-release"
releaseAddr = "0x5050505050505050505050505050505050505050"
)
sw := &Swap{
Status: SwapStatusBridgeTransferPending,
Amount: 0.1,
SourceNetwork: "ETHEREUM_SEPOLIA",
SourceAsset: "ETH",
DestinationNetwork: "LUX_TESTNET",
DestinationAsset: "LUX",
DestinationAddress: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
DepositAddress: depositWalletID + "###" + depositAddr,
ReleaseWalletID: releaseWalletID,
ReleaseAddress: releaseAddr,
UseDepositAddress: true,
}
if err := store.Create(t.Context(), sw); err != nil {
t.Fatal(err)
}
// Provider keyed by RELEASE address (the driver should pass the
// release address as SenderAddress to PendingNonce). If the driver
// accidentally fed the deposit address instead, the lookup misses
// and PreSign fails — making this an executable assertion.
prov := &txassembler.StaticProvider{
Nonces: map[string]uint64{
"LUX_TESTNET|" + strings.ToLower(strings.TrimPrefix(releaseAddr, "0x")): 0,
},
GasPrice: map[string]*big.Int{"LUX_TESTNET": big.NewInt(25_000_000_000)},
}
asm := txassembler.New(prov)
asm.SetNetwork("LUX_TESTNET", txassembler.PerNetwork{
ChainID: big.NewInt(96368), DefaultGasLimit: 21000, NativeDecimals: 18,
})
sigHex := "0x" + strings.Repeat("01", 32) + strings.Repeat("02", 32) + "00"
signer.ok(releaseWalletID, sigHex, "sess-release")
d := NewSigningDriver(store, signer, time.Hour, nil)
d.SetAssembler(asm)
d.Tick(t.Context())
got, _ := store.Get(t.Context(), sw.ID)
if got.Status != SwapStatusBroadcasting {
t.Fatalf("status = %q, want broadcasting (sign + finalize must succeed against release wallet)", got.Status)
}
if got.DestRawTx == "" {
t.Fatal("DestRawTx empty — assembler did not produce a raw tx")
}
// Verify the signer was invoked with the RELEASE wallet ID, not
// the deposit one.
if signer.calls.Load() != 1 {
t.Fatalf("signer should be called once, got %d", signer.calls.Load())
}
if signer.lastReq[0].walletID != releaseWalletID {
t.Errorf("signer was called with walletID=%q, want %q (the release wallet)",
signer.lastReq[0].walletID, releaseWalletID)
}
}
// =============================================================================
// extractWalletID
// =============================================================================
+28 -1
View File
@@ -82,9 +82,36 @@ type Swap struct {
ServiceFee float64 `json:"service_fee,omitempty"`
// Deposit address — legacy "wallet_name###address" format when
// minted via internal/mchain.KeygenForDeposit.
// minted via internal/mchain.KeygenForDeposit. This is the
// PER-SWAP source-chain receive address. The user sends source
// funds here; the refund driver also signs from this address
// when sweeping a stuck swap back to its original sender.
DepositAddress string `json:"deposit_address,omitempty"`
// ReleaseWalletID is the MPC wallet identifier for the long-lived
// per-DESTINATION-network release wallet. Populated at swap-create
// time by mchain.ReleaseWalletStore.GetOrCreate and reused by
// every swap to the same destination network. Distinct from
// DepositAddress, which is a fresh keygen per swap on the source
// side. The signing driver targets this wallet (not the deposit
// wallet) when producing the destination-chain release tx, because
// only this wallet's destination-chain address holds the
// operator-funded liquidity needed to pay the user.
//
// Empty for swaps created before the release-wallet split landed
// (2026-05) and for swaps created against a bridge process that
// has no --mpc-url configured. In those legacy paths the signing
// driver falls back to the deposit wallet — those swaps will
// fail-and-refund unless the operator pre-funded the per-swap
// release address out of band.
ReleaseWalletID string `json:"release_wallet_id,omitempty"`
// ReleaseAddress is the destination-network address controlled by
// ReleaseWalletID. The signing driver passes this to the tx
// assembler as SenderAddress so the release tx draws from
// operator-funded liquidity, not from the (always-empty) per-swap
// deposit wallet.
ReleaseAddress string `json:"release_address,omitempty"`
// Source-side observation: tx hash, confirmed amount.
SourceTxHash string `json:"source_tx_hash,omitempty"`
DepositedAmount float64 `json:"deposited_amount,omitempty"`
+96 -3
View File
@@ -59,9 +59,21 @@ type serverSwap struct {
SourceNetwork string `json:"source_network,omitempty"`
DestinationNetwork string `json:"destination_network,omitempty"`
DepositAddress string `json:"deposit_address,omitempty"`
Signature string `json:"signature,omitempty"`
SourceTxHash string `json:"source_tx_hash,omitempty"`
DestTxHash string `json:"dest_tx_hash,omitempty"`
// ReleaseAddress is the destination-network MPC address from which
// the bridge will pay the user. Surfaced for operator diagnostics
// (e.g. faucet/funder dashboards) and for the SDK to display the
// "paid from" leg of the release tx. Empty for swaps created
// before the release-wallet split landed.
ReleaseAddress string `json:"release_address,omitempty"`
// ReceiveAmount is the destination-asset amount the bridge
// committed to delivering at create time (snapshot of the
// QuoteEngine output). The signing driver scales the release tx
// value to match. Surfaced so the SDK + ops tooling can compare
// committed vs delivered amounts after the destination tx lands.
ReceiveAmount float64 `json:"receive_amount,omitempty"`
Signature string `json:"signature,omitempty"`
SourceTxHash string `json:"source_tx_hash,omitempty"`
DestTxHash string `json:"dest_tx_hash,omitempty"`
// DestRawTx is the wire-ready signed destination tx. Surfaced
// for operator diagnostics — useful for decoding the tx fields
// and verifying ECDSA recovery against the expected sender when
@@ -209,6 +221,50 @@ func (a *API) swapsCreateNative(c *zip.Ctx) error {
})
}
// Step 0 — snapshot the quote.
//
// The QuoteEngine output (ReceiveAmount + MinReceiveAmount +
// ServiceFee) is the source of truth for what the destination-side
// release tx will actually pay the user. Without this snapshot the
// signing driver would fall back to the raw input amount (sw.Amount)
// and the destination chain would receive 0.01-units-of-native
// regardless of source/destination price difference — i.e. 0.01 LUX
// for a 0.01 ETH input, not the ~14 LUX the quote endpoint promised.
//
// We fail at create time rather than at signing time: the user has
// already accepted a quote via GET /v1/bridge/quote, so refusing
// to commit a fresh server-side quote here means prices flapped
// during the round-trip — better surfaced now (the SDK can retry)
// than silently mis-paid later.
//
// When a.quote is nil (test rigs that didn't wire one), the snapshot
// is skipped and the signing driver falls back to sw.Amount —
// preserves the pre-fix path for tests that don't exercise pricing.
var quoteRes *QuoteResult
if a.quote != nil {
qr, err := a.quote.Quote(c.Context(), QuoteInput{
Amount: req.Amount,
SourceNetwork: req.SourceNetwork,
SourceAsset: req.SourceAsset,
DestinationNetwork: req.DestinationNetwork,
DestinationAsset: req.DestinationAsset,
Refuel: req.Refuel,
})
if err != nil {
if errors.Is(err, ErrPriceUnknown) {
return c.JSON(http.StatusServiceUnavailable, map[string]string{
"error": "price_unknown",
"detail": err.Error(),
})
}
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "quote_failed",
"detail": err.Error(),
})
}
quoteRes = qr
}
// Step 1 — optional MPC keygen for the deposit address.
var depositWallet *mchain.Wallet
if req.UseDepositAddress {
@@ -225,6 +281,32 @@ func (a *API) swapsCreateNative(c *zip.Ctx) error {
depositWallet = w
}
// Step 1b — resolve the destination-side release wallet.
//
// Release wallets are long-lived (one per destination network) and
// pay out from operator-funded liquidity. Unlike the deposit
// wallet — which is a fresh per-swap keygen — the release wallet
// is reused across every swap to the same destination network. The
// store's GetOrCreate mints on first use and caches thereafter, so
// at most one keygen per network for the lifetime of the bridge
// process (persisted across restarts when --release-wallets-file
// is set).
//
// Skipped when the release store isn't configured. The signing
// driver's resolveReleaseSigning() falls back to the deposit
// wallet in that case — preserves the legacy path so an operator
// who hasn't migrated their config yet sees the old behavior
// (which still works for any destination address the operator
// pre-funded out of band) rather than a hard failure.
var releaseWallet *mchain.Wallet
if req.UseDepositAddress && a.releaseStore != nil {
rw, err := a.releaseStore.GetOrCreate(c.Context(), req.DestinationNetwork)
if err != nil {
return mpcErrToHTTP(c, err, "createSwap_release")
}
releaseWallet = rw
}
// Step 2 — persist the swap. ID is assigned by the store.
sender := req.Sender
if sender == "" {
@@ -247,6 +329,15 @@ func (a *API) swapsCreateNative(c *zip.Ctx) error {
if depositWallet != nil {
swap.DepositAddress = depositWallet.LegacyDepositString()
}
if releaseWallet != nil {
swap.ReleaseWalletID = releaseWallet.Name
swap.ReleaseAddress = releaseWallet.Address
}
if quoteRes != nil {
swap.ReceiveAmount = quoteRes.ReceiveAmount
swap.MinReceiveAmount = quoteRes.MinReceiveAmount
swap.ServiceFee = quoteRes.ServiceFee
}
if err := a.store.Create(c.Context(), swap); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "store_failed",
@@ -291,6 +382,8 @@ func swapToServerShape(sw *Swap) serverSwap {
SourceNetwork: sw.SourceNetwork,
DestinationNetwork: sw.DestinationNetwork,
DepositAddress: sw.DepositAddress,
ReleaseAddress: sw.ReleaseAddress,
ReceiveAmount: sw.ReceiveAmount,
Signature: sw.Signature,
SourceTxHash: sw.SourceTxHash,
DestTxHash: sw.DestTxHash,
+237
View File
@@ -10,7 +10,9 @@ package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
@@ -123,6 +125,37 @@ func mockBchain(t *testing.T, on map[string]any) *httptest.Server {
return srv
}
// stubReleaseStore is a deterministic in-memory ReleaseWalletStore for
// tests. Lets us assert that the create handler stamps the
// release-wallet fields onto the swap without spinning up a second
// mpcMock for the release-side keygen.
type stubReleaseStore struct {
wallets map[string]*mchain.Wallet
calls map[string]int
}
func newStubReleaseStore() *stubReleaseStore {
return &stubReleaseStore{
wallets: map[string]*mchain.Wallet{},
calls: map[string]int{},
}
}
// set programs the wallet returned for `network`.
func (s *stubReleaseStore) set(network, name, address string) {
s.wallets[network] = &mchain.Wallet{Name: name, Address: address, AddressType: mchain.AddressTypeETH}
}
func (s *stubReleaseStore) GetOrCreate(_ context.Context, network string) (*mchain.Wallet, error) {
s.calls[network]++
w, ok := s.wallets[network]
if !ok {
return nil, errors.New("stubReleaseStore: no wallet programmed for " + network)
}
cp := *w
return &cp, nil
}
// mpcMock serves a fake MPC /keygen endpoint.
func mpcMock(t *testing.T, eth, btc, sol string) *httptest.Server {
t.Helper()
@@ -486,7 +519,9 @@ func TestSwapsCreate_UseDepositAddressTrue_NoMPC_Returns503(t *testing.T) {
reqBody, _ := json.Marshal(createSwapReq{
Amount: 0.1,
SourceNetwork: "ETHEREUM_SEPOLIA",
SourceAsset: "ETH",
DestinationNetwork: "LUX_TESTNET",
DestinationAsset: "LUX",
DestinationAddress: "0xabc",
UseDepositAddress: true,
})
@@ -507,7 +542,9 @@ func TestSwapsCreate_DOTRequest_Returns501(t *testing.T) {
reqBody, _ := json.Marshal(createSwapReq{
Amount: 1,
SourceNetwork: "POLKADOT_MAINNET",
SourceAsset: "DAI", // price-feed-known; the request fails on the DOT keygen path, not pricing
DestinationNetwork: "LUX_MAINNET",
DestinationAsset: "LUX",
DestinationAddress: "0xfeed",
UseDepositAddress: true,
})
@@ -532,7 +569,9 @@ func TestSwapsCreate_MPCKeygenFailure_Returns5xx(t *testing.T) {
reqBody, _ := json.Marshal(createSwapReq{
Amount: 0.1,
SourceNetwork: "ETHEREUM_SEPOLIA",
SourceAsset: "ETH",
DestinationNetwork: "LUX_TESTNET",
DestinationAsset: "LUX",
DestinationAddress: "0xabc",
UseDepositAddress: true,
})
@@ -729,3 +768,201 @@ func TestAPIAlias_SwapsGetReturnsPersistedSwap(t *testing.T) {
t.Fatalf("/api/swaps/:id returned id=%q want %q", resp.Data.ID, sw.ID)
}
}
// =============================================================================
// Release-wallet wiring (the per-destination payout-treasury split)
// =============================================================================
// TestSwapsCreate_PopulatesReleaseWallet verifies that when the API
// has a release store wired, swap creation stamps the long-lived
// destination-network release wallet onto the swap row AND surfaces
// the address on the serverSwap envelope so the SDK/UI can display
// "paid from" diagnostics.
func TestSwapsCreate_PopulatesReleaseWallet(t *testing.T) {
mpc := mpcMock(t, "0xDEP0sit0000000000000000000000000000000001", "", "")
mc := &mchain.Client{APIURL: mpc.URL, OrgID: "test-org", Timeout: 2 * time.Second}
rig := newRig(t, nil, mc, nil)
releases := newStubReleaseStore()
releases.set("LUX_TESTNET",
"bridge-release-lux_testnet-1",
"0xREL3ase0000000000000000000000000000000001",
)
rig.api.SetReleaseStore(releases)
reqBody, _ := json.Marshal(createSwapReq{
Amount: 0.01,
SourceNetwork: "ETHEREUM_SEPOLIA",
SourceAsset: "ETH",
DestinationNetwork: "LUX_TESTNET",
DestinationAsset: "LUX",
DestinationAddress: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
UseDepositAddress: true,
})
status, body := fireRequest(t, rig.app, http.MethodPost, "/v1/bridge/swaps", reqBody)
if status != http.StatusOK {
t.Fatalf("status=%d body=%s", status, body)
}
var resp struct {
Data serverSwap `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
t.Fatal(err)
}
if resp.Data.ReleaseAddress != "0xREL3ase0000000000000000000000000000000001" {
t.Errorf("serverSwap.ReleaseAddress=%q, want stub address", resp.Data.ReleaseAddress)
}
stored, _ := rig.store.Get(t.Context(), resp.Data.ID)
if stored.ReleaseWalletID != "bridge-release-lux_testnet-1" {
t.Errorf("stored ReleaseWalletID=%q, want stub name", stored.ReleaseWalletID)
}
if stored.ReleaseAddress != "0xREL3ase0000000000000000000000000000000001" {
t.Errorf("stored ReleaseAddress=%q, want stub address", stored.ReleaseAddress)
}
// Deposit and release are independent wallets — the deposit address
// shouldn't accidentally overwrite the release one (or vice versa).
if !strings.HasSuffix(stored.DepositAddress, "###0xDEP0sit0000000000000000000000000000000001") {
t.Errorf("DepositAddress=%q does not carry the mpcMock eth address", stored.DepositAddress)
}
if stored.DepositAddress == stored.ReleaseAddress {
t.Errorf("DepositAddress and ReleaseAddress collided — they MUST be distinct wallets")
}
}
// TestSwapsCreate_ReleaseWalletReused proves a second swap to the same
// destination network reuses the same release wallet. This is the
// whole point of the long-lived release store — a single funded
// address pays out every swap to that chain.
func TestSwapsCreate_ReleaseWalletReused(t *testing.T) {
mpc := mpcMock(t, "0xDEP0sit0000000000000000000000000000000001", "", "")
mc := &mchain.Client{APIURL: mpc.URL, OrgID: "test-org", Timeout: 2 * time.Second}
rig := newRig(t, nil, mc, nil)
releases := newStubReleaseStore()
releases.set("LUX_TESTNET", "release-lux-1", "0xREL3ase0000000000000000000000000000000001")
rig.api.SetReleaseStore(releases)
body, _ := json.Marshal(createSwapReq{
Amount: 0.01, SourceNetwork: "ETHEREUM_SEPOLIA", SourceAsset: "ETH",
DestinationNetwork: "LUX_TESTNET", DestinationAsset: "LUX",
DestinationAddress: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
UseDepositAddress: true,
})
for i := 0; i < 3; i++ {
st, _ := fireRequest(t, rig.app, http.MethodPost, "/v1/bridge/swaps", body)
if st != http.StatusOK {
t.Fatalf("swap %d: status=%d", i, st)
}
}
if got := releases.calls["LUX_TESTNET"]; got != 3 {
t.Errorf("GetOrCreate called %d times, want 3 (once per swap)", got)
}
}
// TestSwapsCreate_SnapshotsReceiveAmount proves the quote engine fires
// at create time and stamps the destination-asset receive amount onto
// the swap row + serverSwap envelope. Without this snapshot the
// signing driver would fall back to the raw input amount and the
// destination chain would receive 0.01-of-native instead of the
// quoted ~14 LUX.
func TestSwapsCreate_SnapshotsReceiveAmount(t *testing.T) {
mpc := mpcMock(t, "0xDEP0sit0000000000000000000000000000000001", "", "")
mc := &mchain.Client{APIURL: mpc.URL, OrgID: "test-org", Timeout: 2 * time.Second}
rig := newRig(t, nil, mc, nil)
reqBody, _ := json.Marshal(createSwapReq{
Amount: 0.01, // 0.01 ETH @ $3500 ÷ $2.50 LUX = 14 LUX (no Lux-exit fee on Sepolia→Lux)
SourceNetwork: "ETHEREUM_SEPOLIA",
SourceAsset: "ETH",
DestinationNetwork: "LUX_TESTNET",
DestinationAsset: "LUX",
DestinationAddress: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
UseDepositAddress: true,
})
status, body := fireRequest(t, rig.app, http.MethodPost, "/v1/bridge/swaps", reqBody)
if status != http.StatusOK {
t.Fatalf("status=%d body=%s", status, body)
}
var resp struct {
Data serverSwap `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
t.Fatal(err)
}
if resp.Data.ReceiveAmount != 14 {
t.Errorf("serverSwap.ReceiveAmount=%v, want 14 (0.01 ETH @ $3500 / $2.50 LUX)", resp.Data.ReceiveAmount)
}
stored, _ := rig.store.Get(t.Context(), resp.Data.ID)
if stored.ReceiveAmount != 14 {
t.Errorf("stored ReceiveAmount=%v, want 14", stored.ReceiveAmount)
}
if stored.MinReceiveAmount != 14*(1-DefaultSlippage) {
t.Errorf("stored MinReceiveAmount=%v, want %v", stored.MinReceiveAmount, 14*(1-DefaultSlippage))
}
// Sepolia→Lux has no exit-from-Lux fee (the source isn't Lux-family).
if stored.ServiceFee != 0 {
t.Errorf("stored ServiceFee=%v, want 0 for non-Lux-exit", stored.ServiceFee)
}
}
// TestSwapsCreate_PriceUnknown_Returns503 covers the failure path:
// when the price feed can't quote (asset not in the feed), create
// fails at 503 rather than silently storing a 0 ReceiveAmount that
// would later get fed to the signing driver.
func TestSwapsCreate_PriceUnknown_Returns503(t *testing.T) {
rig := newRig(t, nil, nil, nil)
rig.feed.Set("ETH", 0)
// Wipe a price by giving the feed a fresh map that omits ETH.
rig.api.quote = &QuoteEngine{Feed: NewStaticPriceFeed(map[string]float64{"LUX": 2.5})}
reqBody, _ := json.Marshal(createSwapReq{
Amount: 0.01,
SourceNetwork: "ETHEREUM_SEPOLIA",
SourceAsset: "ETH", // not in feed
DestinationNetwork: "LUX_TESTNET",
DestinationAsset: "LUX",
DestinationAddress: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
UseDepositAddress: false,
})
status, body := fireRequest(t, rig.app, http.MethodPost, "/v1/bridge/swaps", reqBody)
if status != http.StatusServiceUnavailable {
t.Fatalf("status=%d, want 503. body=%s", status, body)
}
if !strings.Contains(string(body), "price_unknown") {
t.Errorf("expected price_unknown error, got %s", body)
}
}
// TestSwapsCreate_NoReleaseStore_LeavesFieldsBlank covers the legacy
// path: bridges running without --release-wallets-file (or without
// --mpc-url) shouldn't crash — the create handler just leaves the new
// fields empty and the signing driver's resolveReleaseSigning() falls
// back to the deposit-address envelope.
func TestSwapsCreate_NoReleaseStore_LeavesFieldsBlank(t *testing.T) {
mpc := mpcMock(t, "0xDEP0sit0000000000000000000000000000000001", "", "")
mc := &mchain.Client{APIURL: mpc.URL, OrgID: "test-org", Timeout: 2 * time.Second}
rig := newRig(t, nil, mc, nil) // no SetReleaseStore call
reqBody, _ := json.Marshal(createSwapReq{
Amount: 0.01, SourceNetwork: "ETHEREUM_SEPOLIA", SourceAsset: "ETH",
DestinationNetwork: "LUX_TESTNET", DestinationAsset: "LUX",
DestinationAddress: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
UseDepositAddress: true,
})
status, body := fireRequest(t, rig.app, http.MethodPost, "/v1/bridge/swaps", reqBody)
if status != http.StatusOK {
t.Fatalf("status=%d body=%s", status, body)
}
var resp struct {
Data serverSwap `json:"data"`
}
_ = json.Unmarshal(body, &resp)
if resp.Data.ReleaseAddress != "" {
t.Errorf("ReleaseAddress=%q, want empty when no release store is wired", resp.Data.ReleaseAddress)
}
stored, _ := rig.store.Get(t.Context(), resp.Data.ID)
if stored.ReleaseWalletID != "" || stored.ReleaseAddress != "" {
t.Errorf("legacy create flow stamped release fields: id=%q addr=%q",
stored.ReleaseWalletID, stored.ReleaseAddress)
}
}
+12 -9
View File
@@ -129,19 +129,22 @@ type keygenResult struct {
Error string `json:"error"`
}
// Wallet is the public result of a keygen for one bridge deposit.
// Caller stores Name + Address; Name doubles as the MPC wallet
// identifier that the deposit-watcher and signing-session code use to
// recover the underlying key shares.
// Wallet is the public result of a keygen for one bridge deposit OR for
// a long-lived per-network release wallet. Caller stores Name + Address;
// Name doubles as the MPC wallet identifier that the deposit-watcher and
// signing-session code use to recover the underlying key shares.
//
// JSON tags are present because FileReleaseStore serializes Wallet to
// disk so per-network release wallets survive bridge restarts.
type Wallet struct {
// Name is the MPC wallet identifier (e.g. "bridge-ethereum_sepolia-1718000000").
Name string
// Address is the source-chain receive address derived from the
// keygen output, picked according to AddressTypeFor(network).
Address string
Name string `json:"name"`
// Address is the chain-appropriate receive/payout address derived
// from the keygen output, picked according to AddressTypeFor(network).
Address string `json:"address"`
// AddressType is the family of Address. Useful for downstream code
// that needs to render or validate the address.
AddressType AddressType
AddressType AddressType `json:"address_type"`
}
// LegacyDepositString returns the "wallet_name###address" string the
+181
View File
@@ -0,0 +1,181 @@
package mchain
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"sync"
)
// release_store.go: per-network release-wallet registry.
//
// A "release wallet" is the long-lived MPC wallet that signs and pays
// out settlements on a destination chain. One per destination network —
// *not* per swap. The operator pre-funds the release wallet's
// destination-chain address with native gas + bridged liquidity; on
// every swap completion the MPC quorum signs a tx FROM that address TO
// the user's destination address.
//
// Contrast with per-swap deposit wallets (KeygenForDeposit), which are
// minted fresh every swap so a compromise of one keygen exposes only
// one swap's worth of source-chain funds. Release wallets need to be
// stable because:
//
// 1. They hold real treasury. Rotating addresses every swap leaves
// stranded balance behind and breaks operator accounting.
// 2. Funding them is an out-of-band operator action. Static addresses
// keep ops + monitoring tractable.
// 3. Throughput: signing reuses the cluster-cached key shares for the
// wallet on every release tx.
//
// Persistence: when StorePath is set, the store serializes the wallet
// map to JSON on every successful mint, and reloads on construction.
// This matches the persistence model of the swap store (--data-dir): a
// single PV-backed file survives pod restarts. When StorePath is empty
// the store runs in-memory — fine for tests and first-deploys, but
// every restart re-mints release wallets and any liquidity sitting at
// the old address is stranded.
// ReleaseWalletStore mints (and caches) the per-network release wallet.
//
// GetOrCreate is idempotent: the first call mints a fresh MPC wallet
// via the underlying Client.KeygenForDeposit primitive (mpcd doesn't
// have a separate "release" endpoint — the cluster doesn't distinguish
// wallet purpose, only the bridge does). Subsequent calls return the
// cached entry. The `network` argument is the destination network's
// internal name (e.g. "LUX_TESTNET").
type ReleaseWalletStore interface {
GetOrCreate(ctx context.Context, network string) (*Wallet, error)
}
// FileReleaseStore is a ReleaseWalletStore backed by a Client for fresh
// keygen and (optionally) a JSON file for persistence. Safe for
// concurrent use.
type FileReleaseStore struct {
client *Client
path string // empty ⇒ in-memory only
mu sync.Mutex
cache map[string]*Wallet // network → wallet
}
// NewFileReleaseStore constructs a release-wallet store that mints via
// client and persists to path. When path is empty, the store is
// in-memory only — every restart re-mints, which strands any
// pre-funded liquidity at the old release address. Prefer a PV-backed
// path in prod.
//
// On construction the store eagerly loads existing wallets from path
// so the bridge can resume servicing destinations without waiting for
// a fresh keygen. A missing file is not an error — it just means no
// release wallets have been minted yet.
func NewFileReleaseStore(client *Client, path string) (*FileReleaseStore, error) {
if client == nil {
return nil, errors.New("mchain: release store requires a non-nil Client")
}
s := &FileReleaseStore{
client: client,
path: path,
cache: map[string]*Wallet{},
}
if path != "" {
if err := s.loadLocked(); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("mchain: load release wallets from %s: %w", path, err)
}
}
return s, nil
}
// GetOrCreate returns the release wallet for network. If absent, mints
// a fresh one via the underlying MPC keygen primitive and persists it.
//
// The lock is held across the keygen + persist round-trip so two
// concurrent callers for the same network don't mint two wallets. This
// serializes first-time mints for distinct networks too, which is
// acceptable: minting happens at most once per network for the
// lifetime of the store, and operators don't bring up dozens of new
// chains at once.
func (s *FileReleaseStore) GetOrCreate(ctx context.Context, network string) (*Wallet, error) {
if network == "" {
return nil, errors.New("mchain: release wallet network is required")
}
s.mu.Lock()
defer s.mu.Unlock()
if w, ok := s.cache[network]; ok && w != nil {
// Return a copy so callers can't mutate the cached entry.
cp := *w
return &cp, nil
}
w, err := s.client.KeygenForDeposit(ctx, network)
if err != nil {
return nil, fmt.Errorf("mchain: keygen release wallet for %s: %w", network, err)
}
s.cache[network] = w
if s.path != "" {
if err := s.persistLocked(); err != nil {
// Roll back the cache entry so the next call retries the
// mint rather than returning a wallet whose key material
// only the mpcd cluster knows about. Leaving stale cache
// state pointing at a "wallet" we couldn't durably record
// would silently strand future funding.
delete(s.cache, network)
return nil, fmt.Errorf("mchain: persist release wallets to %s: %w", s.path, err)
}
}
cp := *w
return &cp, nil
}
// fileShape is the JSON layout written to s.path. Versioned so a
// future store-format change can add fields without breaking existing
// files.
type fileShape struct {
Version int `json:"version"`
Wallets map[string]*Wallet `json:"wallets"`
}
const releaseStoreVersion = 1
// loadLocked reads s.path into s.cache. Caller must hold s.mu.
func (s *FileReleaseStore) loadLocked() error {
data, err := os.ReadFile(s.path)
if err != nil {
return err
}
if len(data) == 0 {
return nil
}
var doc fileShape
if err := json.Unmarshal(data, &doc); err != nil {
return fmt.Errorf("decode: %w", err)
}
for network, w := range doc.Wallets {
if w == nil {
continue
}
cp := *w
s.cache[network] = &cp
}
return nil
}
// persistLocked atomically writes s.cache to s.path. Caller must hold
// s.mu. Atomic via write-then-rename so a crash mid-write can't leave
// a truncated file that fails to parse on next boot.
func (s *FileReleaseStore) persistLocked() error {
doc := fileShape{Version: releaseStoreVersion, Wallets: s.cache}
data, err := json.MarshalIndent(&doc, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// Compile-time interface check.
var _ ReleaseWalletStore = (*FileReleaseStore)(nil)
+186
View File
@@ -0,0 +1,186 @@
package mchain
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
)
// keygenStub returns a fake mpcd /keygen endpoint that hands back a
// pre-canned response and counts the number of times it was hit.
// Wallet IDs are derived from the count so a test can prove that
// GetOrCreate isn't minting twice.
func keygenStub(t *testing.T) (*httptest.Server, *int64) {
t.Helper()
var calls int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/keygen" {
http.NotFound(w, r)
return
}
n := atomic.AddInt64(&calls, 1)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"wallet_id": "release-test-" + itoa(int(n)),
"eth_address": "0xE000000000000000000000000000000000000000",
"result_type": "success",
})
}))
t.Cleanup(srv.Close)
return srv, &calls
}
func itoa(n int) string {
const digits = "0123456789"
if n == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = digits[n%10]
n /= 10
}
return string(buf[i:])
}
func newClientForStub(srv *httptest.Server) *Client {
return &Client{
APIURL: srv.URL,
OrgID: "test-org",
Timeout: 2 * time.Second,
}
}
func TestFileReleaseStore_GetOrCreate_IsIdempotent(t *testing.T) {
srv, calls := keygenStub(t)
client := newClientForStub(srv)
store, err := NewFileReleaseStore(client, "")
if err != nil {
t.Fatalf("new store: %v", err)
}
ctx := context.Background()
w1, err := store.GetOrCreate(ctx, "LUX_TESTNET")
if err != nil {
t.Fatalf("first GetOrCreate: %v", err)
}
w2, err := store.GetOrCreate(ctx, "LUX_TESTNET")
if err != nil {
t.Fatalf("second GetOrCreate: %v", err)
}
if w1.Name != w2.Name || w1.Address != w2.Address {
t.Errorf("expected same wallet on second call, got %+v vs %+v", w1, w2)
}
if got := atomic.LoadInt64(calls); got != 1 {
t.Errorf("expected exactly 1 keygen call, got %d", got)
}
}
func TestFileReleaseStore_DistinctNetworks_DistinctWallets(t *testing.T) {
srv, _ := keygenStub(t)
store, _ := NewFileReleaseStore(newClientForStub(srv), "")
ctx := context.Background()
lux, _ := store.GetOrCreate(ctx, "LUX_TESTNET")
base, _ := store.GetOrCreate(ctx, "BASE_SEPOLIA")
if lux.Name == base.Name {
t.Errorf("expected distinct wallet IDs across networks, both = %q", lux.Name)
}
}
func TestFileReleaseStore_PersistsAcrossInstances(t *testing.T) {
srv, calls := keygenStub(t)
client := newClientForStub(srv)
dir := t.TempDir()
path := filepath.Join(dir, "release-wallets.json")
first, err := NewFileReleaseStore(client, path)
if err != nil {
t.Fatalf("new first store: %v", err)
}
w1, err := first.GetOrCreate(context.Background(), "LUX_TESTNET")
if err != nil {
t.Fatalf("first mint: %v", err)
}
// New store at the same path simulates a process restart.
second, err := NewFileReleaseStore(client, path)
if err != nil {
t.Fatalf("new second store: %v", err)
}
w2, err := second.GetOrCreate(context.Background(), "LUX_TESTNET")
if err != nil {
t.Fatalf("second mint: %v", err)
}
if w1.Name != w2.Name || w1.Address != w2.Address {
t.Errorf("wallet did not survive restart: %+v vs %+v", w1, w2)
}
if got := atomic.LoadInt64(calls); got != 1 {
t.Errorf("expected the persisted wallet to be reused (1 keygen call), got %d", got)
}
}
func TestFileReleaseStore_RejectsEmptyNetwork(t *testing.T) {
srv, _ := keygenStub(t)
store, _ := NewFileReleaseStore(newClientForStub(srv), "")
_, err := store.GetOrCreate(context.Background(), "")
if err == nil {
t.Fatal("expected an error for empty network, got nil")
}
}
func TestFileReleaseStore_LoadIgnoresMissingFile(t *testing.T) {
srv, _ := keygenStub(t)
// Point at a path that doesn't exist yet — constructor should
// succeed (missing file = no wallets minted yet).
dir := t.TempDir()
path := filepath.Join(dir, "does-not-exist.json")
_, err := NewFileReleaseStore(newClientForStub(srv), path)
if err != nil {
t.Fatalf("expected nil err for missing file, got %v", err)
}
}
func TestFileReleaseStore_PersistFileShape(t *testing.T) {
srv, _ := keygenStub(t)
dir := t.TempDir()
path := filepath.Join(dir, "release-wallets.json")
store, _ := NewFileReleaseStore(newClientForStub(srv), path)
if _, err := store.GetOrCreate(context.Background(), "LUX_TESTNET"); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read persisted file: %v", err)
}
var doc struct {
Version int `json:"version"`
Wallets map[string]*Wallet `json:"wallets"`
}
if err := json.Unmarshal(data, &doc); err != nil {
t.Fatalf("decode persisted file: %v\n%s", err, data)
}
if doc.Version != releaseStoreVersion {
t.Errorf("file version = %d, want %d", doc.Version, releaseStoreVersion)
}
if w := doc.Wallets["LUX_TESTNET"]; w == nil || w.Address == "" {
t.Errorf("LUX_TESTNET wallet missing/empty in persisted file: %+v", doc.Wallets)
}
}
func TestFileReleaseStore_NilClientRejected(t *testing.T) {
_, err := NewFileReleaseStore(nil, "")
if err == nil {
t.Fatal("expected an error for nil client, got nil")
}
}