mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user