fix(bridge): enforce per-swap USD min/max server-side in quote + create

limits.minUSD/maxUSD (and per-asset Per caps) were display-only — the
/limits handler echoed config and nothing enforced it, so a ~$50M swap
quoted HTTP 200 in prod. The Phase 2 ed25519 custody cap assumes this
ceiling is real (docs/custody-signoff-ed25519.md reconciles the hot-wallet
cap against limits.maxUSD), so without it a single swap could exceed the
cap and blow the single-signer loss ceiling.

Add limitViolation(): notional = amount x source price (reusing the quote
engine's PriceFeed), enforced against a per-asset Per[srcAsset] override
then the global Min/MaxUSD band. Wired into quoteNative and
swapsCreateNative (the latter before any keygen or persist) -> HTTP 400
amount_below_min / amount_above_max. Fail-open on an unpriceable source
asset (no price -> no notional, and the quote step rejects ErrPriceUnknown
anyway, so the swap can't settle regardless).

Tests in limits_gate_test.go: below-min, above-max, within-band, per-asset
override beats global, within-per-asset, create-over-max persists zero
rows, and fail-open on unknown price. Full cmd/bridge suite green.
This commit is contained in:
venuswhispers
2026-06-23 02:54:14 +02:00
parent e810274367
commit e450b15d21
2 changed files with 175 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
// Tests for the server-side per-swap USD limit gate (limitViolation).
// limits.minUSD/maxUSD (and per-asset Per caps) were previously display-only
// (/limits echoed config; nothing enforced it). These assert the notional
// gate now rejects out-of-bounds swaps in quoteNative + swapsCreateNative,
// including the per-asset override and fail-open on an unpriceable asset.
// See architecture_limits_not_enforced_server_side.
package main
import (
"encoding/json"
"net/http"
"testing"
)
// limitsRig wires a rig whose config has all corridors enabled (so only the
// limit gate is exercised) plus a global $10–$100k band and a tighter
// per-asset USDC cap ($10–$50). Prices come from defaultPrices: ETH=$3500,
// LUX=$2.50, USDC=$1.
func limitsRig(t *testing.T) *testRig {
t.Helper()
rig := newRig(t, nil, nil, nil)
rig.api.cfg = Config{
Networks: []Network{
{InternalName: "ETHEREUM_MAINNET", DisplayName: "Ethereum", Type: "evm", Status: "active", NativeCurrency: "ETH"},
{InternalName: "LUX_MAINNET", DisplayName: "Lux", Type: "evm", Status: "active", NativeCurrency: "LUX"},
},
Tokens: []Token{
{Asset: "ETH", Name: "Ether", Network: "ETHEREUM_MAINNET"},
{Asset: "USDC", Name: "USD Coin", Network: "ETHEREUM_MAINNET"},
{Asset: "LUX", Name: "Lux", Network: "LUX_MAINNET"},
},
Limits: Limits{
MinUSD: 10,
MaxUSD: 100000,
Per: map[string]Caps{"USDC": {MinUSD: 10, MaxUSD: 50}},
},
}
return rig
}
func TestQuoteLimits(t *testing.T) {
rig := limitsRig(t)
const ethLux = "source_network=ETHEREUM_MAINNET&source_token=ETH&destination_network=LUX_MAINNET&destination_token=LUX&amount="
const usdcLux = "source_network=ETHEREUM_MAINNET&source_token=USDC&destination_network=LUX_MAINNET&destination_token=LUX&amount="
cases := []struct {
name string
query string
wantStatus int
wantCode string
}{
{"below global min (ETH 0.001 ~ $3.50)", ethLux + "0.001", http.StatusBadRequest, "amount_below_min"},
{"above global max (ETH 50 ~ $175k)", ethLux + "50", http.StatusBadRequest, "amount_above_max"},
{"within global band (ETH 1 ~ $3500)", ethLux + "1", http.StatusOK, ""},
{"per-asset cap beats global (USDC 100 > $50)", usdcLux + "100", http.StatusBadRequest, "amount_above_max"},
{"within per-asset cap (USDC 30)", usdcLux + "30", http.StatusOK, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
status, body := fireRequest(t, rig.app, http.MethodGet, "/v1/bridge/quote?"+tc.query, nil)
if status != tc.wantStatus {
t.Fatalf("status = %d, want %d; body=%s", status, tc.wantStatus, body)
}
if tc.wantCode != "" {
if got := errCode(t, body); got != tc.wantCode {
t.Fatalf("error = %q, want %q; body=%s", got, tc.wantCode, body)
}
}
})
}
}
func TestCreateLimits(t *testing.T) {
rig := limitsRig(t)
create := func(amount float64) (int, []byte) {
body, _ := json.Marshal(map[string]any{
"source_network": "ETHEREUM_MAINNET",
"source_asset": "ETH",
"destination_network": "LUX_MAINNET",
"destination_asset": "LUX",
"destination_address": "0x1111111111111111111111111111111111111111",
"sender": "0x2222222222222222222222222222222222222222",
"amount": amount,
"use_deposit_address": false,
})
return fireRequest(t, rig.app, http.MethodPost, "/v1/bridge/swaps", body)
}
// Over the global max: rejected before any persist.
status, resp := create(50) // 50 ETH ~ $175k
if status != http.StatusBadRequest {
t.Fatalf("over-max create: status = %d, want 400; body=%s", status, resp)
}
if got := errCode(t, resp); got != "amount_above_max" {
t.Fatalf("over-max create: error = %q, want amount_above_max; body=%s", got, resp)
}
if got, _ := rig.store.List(t.Context(), SwapFilter{}); len(got) != 0 {
t.Fatalf("over-max create persisted %d swap(s), want 0", len(got))
}
// Within band: gate passes through to a normal create.
status, resp = create(1) // 1 ETH ~ $3500
if status != http.StatusOK {
t.Fatalf("in-band create: status = %d, want 200; body=%s", status, resp)
}
}
func TestLimitViolation_FailOpenOnUnknownPrice(t *testing.T) {
rig := limitsRig(t)
// No price for "UNPRICED" → notional can't be computed → no violation
// (the swap can't settle without a price anyway).
if code, _ := rig.api.limitViolation(t.Context(), "UNPRICED", 1_000_000); code != "" {
t.Fatalf("expected no violation for unpriced asset, got %q", code)
}
}
+59
View File
@@ -227,6 +227,52 @@ func (a *API) corridorDisabled(srcNet, srcAsset, dstNet, dstAsset string) (code,
return "", ""
}
// limitViolation enforces the per-swap USD min/max from a.cfg.Limits. The
// swap's notional is the SOURCE value (amount × source price) — which, net
// of fee + slippage, also bounds the destination payout, so it caps how
// much a single swap can draw from a release/hot wallet. This is the guard
// the Phase 2 ed25519 custody cap assumes exists (docs/custody-signoff-
// ed25519.md reconciles the hot-wallet cap against limits.maxUSD); without
// it a single swap could exceed the cap and blow the single-signer loss
// ceiling. A per-asset Limits.Per[sourceAsset] override takes precedence
// over the global Min/MaxUSD; a zero bound means "unset" (not enforced).
//
// Fail-open on an unpriceable source asset: with no price we can't compute
// a notional, but the swap can't settle without a price anyway (the quote
// step rejects ErrPriceUnknown), so skipping the check here never widens
// what actually moves. See architecture_limits_not_enforced_server_side.
func (a *API) limitViolation(ctx context.Context, srcAsset string, amount float64) (code, detail string) {
if a.quote == nil || a.quote.Feed == nil {
return "", ""
}
price, err := a.quote.Feed.Price(ctx, srcAsset)
if err != nil || price <= 0 {
return "", ""
}
usd := amount * price
minUSD, maxUSD := a.cfg.Limits.MinUSD, a.cfg.Limits.MaxUSD
for asset, caps := range a.cfg.Limits.Per {
if !strings.EqualFold(asset, srcAsset) {
continue
}
// Only the bounds the operator actually set (non-zero) override.
if caps.MinUSD > 0 {
minUSD = caps.MinUSD
}
if caps.MaxUSD > 0 {
maxUSD = caps.MaxUSD
}
break
}
if minUSD > 0 && usd < minUSD {
return "amount_below_min", fmt.Sprintf("swap notional $%.2f is below the $%.2f minimum", usd, minUSD)
}
if maxUSD > 0 && usd > maxUSD {
return "amount_above_max", fmt.Sprintf("swap notional $%.2f exceeds the $%.2f per-swap maximum", usd, maxUSD)
}
return "", ""
}
func (a *API) quoteNative(c *zip.Ctx) error {
src := c.Query("source_network")
srcTok := c.Query("source_token")
@@ -256,6 +302,11 @@ func (a *API) quoteNative(c *zip.Ctx) error {
if code, detail := a.corridorDisabled(src, srcTok, dst, dstTok); code != "" {
return c.JSON(http.StatusForbidden, map[string]string{"error": code, "detail": detail})
}
// Per-swap min/max USD gate (display config is also enforced here, not
// just shown in /limits).
if code, detail := a.limitViolation(c.Context(), srcTok, amountF); code != "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": code, "detail": detail})
}
res, err := a.quote.Quote(c.Context(), QuoteInput{
Amount: amountF,
@@ -356,6 +407,14 @@ func (a *API) swapsCreateNative(c *zip.Ctx) error {
return c.JSON(http.StatusForbidden, map[string]string{"error": code, "detail": detail})
}
// Per-swap min/max USD gate. Enforced before any keygen/persist so an
// over-cap swap can't mint a deposit address or draw a release wallet
// beyond the custody cap.
if code, detail := a.limitViolation(c.Context(), req.SourceAsset, req.Amount); code != "" {
fmt.Printf("[swap-create-reject] %s source=%s/%s amount=%v\n", code, req.SourceNetwork, req.SourceAsset, req.Amount)
return c.JSON(http.StatusBadRequest, map[string]string{"error": code, "detail": detail})
}
// Validate cosigner intents up-front. ValidateIntents enforces the
// SDK wire shape (kind discriminator, required fields per family,
// secret-field deny-list). A malformed entry returns HTTP 400 with