mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
feat(xrp): native inbound deposit detection + wire XRP into mainnet/testnet config
XRP was the one of {BTC,SOL,TON,XRP,ETH} with a genuine in-repo gap: outbound
(release pool + assembler + rippled submit) was complete, but inbound deposit
detection was a stub (ErrUnsupportedNetwork) and XRP was absent from all config.
- depositcheck.checkXRP: rippled account_info against the last *validated* ledger;
balance read as decimal drops (1 XRP = 1e6 drops), compared via the shared
compareBalance(decimals=6). An unfunded account (actNotFound) maps to the
(false,nil) 'no deposit yet' contract, not an error. strict:true so account is
read as an address. Same canonical rippled endpoints as the outbound broadcaster.
- networks.{mainnet,testnet}.yaml: XRP_{MAINNET,TESTNET} network + XRP token,
deposit+withdrawal enabled. XRP settles on secp256k1 (same curve as the live
ETH/BTC pipeline) so it has no mpcd FROST/Ed25519 gap like SOL/TON; r-address
derives from the ecdsa_pub_key mpcd already returns. Empty release wallets fail
payouts safe until an operator funds them (>=10 XRP reserve + fee).
- 7 new XRP deposit tests (funded/boundary/insufficient/actNotFound x2/rpc-error/
malformed); retire the obsolete TestCheck_XRP_NotImplemented. depositcheck 36/36.
This commit is contained in:
@@ -130,6 +130,14 @@ networks:
|
||||
txExplorerTpl: https://tonscan.org/tx/{tx}
|
||||
addrExplorerTpl: https://tonscan.org/address/{addr}
|
||||
|
||||
- internalName: XRP_MAINNET
|
||||
displayName: XRP Ledger
|
||||
nativeCurrency: XRP
|
||||
type: xrp
|
||||
avgCompletion: 1m
|
||||
txExplorerTpl: https://livenet.xrpl.org/transactions/{tx}
|
||||
addrExplorerTpl: https://livenet.xrpl.org/accounts/{addr}
|
||||
|
||||
tokens:
|
||||
# LUX
|
||||
- asset: LUX
|
||||
@@ -261,6 +269,18 @@ tokens:
|
||||
isDepositEnabled: false
|
||||
isWithdrawalEnabled: false
|
||||
|
||||
# XRP settles on secp256k1 (same curve as the live ETH/BTC pipeline) — no
|
||||
# mpcd FROST/Ed25519 gap. Inbound detection via rippled account_info
|
||||
# (internal/depositcheck); outbound via the XRP release pool + assembler +
|
||||
# rippled submit. Operator note: pre-fund each XRP release wallet with
|
||||
# >= 10 XRP (AccountRoot base reserve) + per-payment fee before payouts.
|
||||
- asset: XRP
|
||||
name: XRP
|
||||
decimals: 6
|
||||
network: XRP_MAINNET
|
||||
isDepositEnabled: true
|
||||
isWithdrawalEnabled: true
|
||||
|
||||
limits:
|
||||
minUSD: 10
|
||||
maxUSD: 100000
|
||||
|
||||
@@ -148,6 +148,15 @@ networks:
|
||||
txExplorerTpl: https://testnet.tonscan.org/tx/{tx}
|
||||
addrExplorerTpl: https://testnet.tonscan.org/address/{addr}
|
||||
|
||||
- internalName: XRP_TESTNET
|
||||
displayName: XRP Ledger Testnet
|
||||
nativeCurrency: XRP
|
||||
type: xrp
|
||||
isTestnet: true
|
||||
avgCompletion: 1m
|
||||
txExplorerTpl: https://testnet.xrpl.org/transactions/{tx}
|
||||
addrExplorerTpl: https://testnet.xrpl.org/accounts/{addr}
|
||||
|
||||
tokens:
|
||||
- asset: ETH
|
||||
name: Ether
|
||||
@@ -269,6 +278,20 @@ tokens:
|
||||
isDepositEnabled: false
|
||||
isWithdrawalEnabled: false
|
||||
|
||||
# XRP settles on secp256k1 — the SAME curve as the live ETH/BTC pipeline, so
|
||||
# it has no mpcd FROST/Ed25519 gap (unlike SOL/TON). Inbound detection
|
||||
# (rippled account_info) is implemented in internal/depositcheck; the
|
||||
# r-address is derived locally from the ecdsa_pub_key mpcd already returns.
|
||||
# Outbound is wired (XRP release pool + assembler + rippled submit).
|
||||
# Operator note: pre-fund each XRP release wallet with >= 10 XRP (the
|
||||
# AccountRoot base reserve) + per-payment fee before enabling payouts.
|
||||
- asset: XRP
|
||||
name: XRP
|
||||
decimals: 6
|
||||
network: XRP_TESTNET
|
||||
isDepositEnabled: true
|
||||
isWithdrawalEnabled: true
|
||||
|
||||
limits:
|
||||
minUSD: 1
|
||||
maxUSD: 100000
|
||||
|
||||
@@ -83,6 +83,12 @@ var rpcURLs = map[string]string{
|
||||
"TON_TESTNET": "https://testnet.toncenter.com/api/v2",
|
||||
// Polkadot
|
||||
"POLKADOT_MAINNET": "https://rpc.polkadot.io",
|
||||
// XRP Ledger (rippled JSON-RPC). Same public clusters as the outbound
|
||||
// broadcaster (internal/broadcast/client.go) — s1.ripple.com is the
|
||||
// load-balanced mainnet cluster, s.altnet the Ripple-hosted testnet;
|
||||
// :51234 is rippled's JSON-RPC port. One canonical endpoint per env.
|
||||
"XRP_MAINNET": "https://s1.ripple.com:51234/",
|
||||
"XRP_TESTNET": "https://s.altnet.rippletest.net:51234/",
|
||||
}
|
||||
|
||||
// RPCURLFor returns the configured upstream URL for a network. Unknown
|
||||
@@ -204,10 +210,7 @@ func (c *Client) Check(ctx context.Context, p CheckParams) (bool, error) {
|
||||
case mchain.AddressTypeTON:
|
||||
return c.checkTON(ctx, url, p.Address, p.RequiredAmount)
|
||||
case mchain.AddressTypeXRP:
|
||||
// XRP deposit detection not implemented in TS either (the
|
||||
// mpc-wallet.ts default falls through to a warn-and-return-false).
|
||||
// Mirror that behaviour with an explicit error.
|
||||
return false, fmt.Errorf("%w: xrp deposit check not implemented", ErrUnsupportedNetwork)
|
||||
return c.checkXRP(ctx, url, p.Address, p.RequiredAmount)
|
||||
case mchain.AddressTypeDOT:
|
||||
return false, ErrSubstrateNotImplemented
|
||||
default:
|
||||
@@ -452,6 +455,69 @@ func (c *Client) checkTON(ctx context.Context, apiBase, address string, required
|
||||
return balTon >= requiredAmount, nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// XRP — rippled JSON-RPC `account_info` (balance in drops)
|
||||
// =============================================================================
|
||||
|
||||
// xrpDropsDecimals is the drops-per-XRP scale exponent: 1 XRP = 10^6 drops.
|
||||
// compareBalance(drops, xrpDropsDecimals, want) == (drops/1e6 >= want).
|
||||
const xrpDropsDecimals = 6
|
||||
|
||||
// checkXRP reads the XRP balance of a deposit address from rippled's
|
||||
// `account_info` against the last *validated* ledger (final, never
|
||||
// in-flight). rippled returns the balance as a decimal *drops* string
|
||||
// under result.account_data.Balance.
|
||||
//
|
||||
// rippled is JSON-RPC-ish: it ignores the {jsonrpc,id} envelope on the
|
||||
// request and replies {result:{...}} with no envelope — so the shared
|
||||
// jsonRPC POST helper works, and we decode rippled's result shape here
|
||||
// (same division of labour the broadcaster uses for `submit`).
|
||||
//
|
||||
// An address that has never received the base reserve is not an account
|
||||
// yet: rippled replies error="actNotFound". That is "no deposit yet"
|
||||
// (a zero balance), NOT a failure — it maps to the (false, nil) "not
|
||||
// enough funds" contract, exactly like a funded-but-too-small balance.
|
||||
func (c *Client) checkXRP(ctx context.Context, rpcURL, address string, requiredAmount float64) (bool, error) {
|
||||
type xrpAccountInfoResp struct {
|
||||
Result struct {
|
||||
AccountData struct {
|
||||
Balance string `json:"Balance"`
|
||||
} `json:"account_data"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
} `json:"result"`
|
||||
}
|
||||
// strict:true forces `account` to be read as an address (never a
|
||||
// username); ledger_index:"validated" reads only finalized state.
|
||||
params := []any{map[string]any{
|
||||
"account": address,
|
||||
"ledger_index": "validated",
|
||||
"strict": true,
|
||||
}}
|
||||
var resp xrpAccountInfoResp
|
||||
if err := c.jsonRPC(ctx, rpcURL, "account_info", params, &resp); err != nil {
|
||||
return false, err
|
||||
}
|
||||
// Unfunded account ⇒ zero balance ⇒ "no deposit yet" (not an error).
|
||||
if resp.Result.Error == "actNotFound" {
|
||||
return compareBalance(new(big.Int), xrpDropsDecimals, requiredAmount), nil
|
||||
}
|
||||
if resp.Result.Error != "" {
|
||||
return false, fmt.Errorf("depositcheck: xrp account_info: %s (%s)",
|
||||
resp.Result.Error, resp.Result.ErrorMessage)
|
||||
}
|
||||
if resp.Result.AccountData.Balance == "" {
|
||||
return false, fmt.Errorf("depositcheck: xrp account_info: empty balance")
|
||||
}
|
||||
drops, ok := new(big.Int).SetString(resp.Result.AccountData.Balance, 10)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("depositcheck: xrp balance not a decimal string: %q",
|
||||
resp.Result.AccountData.Balance)
|
||||
}
|
||||
return compareBalance(drops, xrpDropsDecimals, requiredAmount), nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HTTP helpers
|
||||
// =============================================================================
|
||||
|
||||
@@ -654,21 +654,9 @@ func TestCheck_Substrate_NotImplemented(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck_XRP_NotImplemented(t *testing.T) {
|
||||
// XRP routes through ErrUnsupportedNetwork rather than its own
|
||||
// sentinel — matches the TS legacy "default: warn + return false"
|
||||
// branch but with an explicit error type.
|
||||
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
|
||||
"XRP_TESTNET": "http://unused",
|
||||
}}
|
||||
_, err := c.Check(context.Background(), CheckParams{
|
||||
NetworkInternalName: "XRP_TESTNET",
|
||||
RequiredAmount: 1,
|
||||
})
|
||||
if !errors.Is(err, ErrUnsupportedNetwork) {
|
||||
t.Fatalf("expected ErrUnsupportedNetwork for XRP, got %v", err)
|
||||
}
|
||||
}
|
||||
// XRP deposit detection (rippled account_info) is now implemented; its
|
||||
// tests live in xrp_test.go. The former "not implemented" assertion was
|
||||
// retired when checkXRP landed.
|
||||
|
||||
// =============================================================================
|
||||
// Context + timeout
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// Tests for XRP inbound deposit detection via rippled's account_info.
|
||||
// A dedicated httptest.Server mocks rippled's result-only envelope and
|
||||
// asserts the request shape (method=account_info, validated ledger, a
|
||||
// supplied account). RPCURLOverrides points the Client at the mock.
|
||||
|
||||
package depositcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// xrpAccountInfoServer mocks rippled `account_info`. It asserts the
|
||||
// request method + params, then replies with either a funded
|
||||
// account_data.Balance (drops) or an actNotFound error envelope.
|
||||
func xrpAccountInfoServer(t *testing.T, balanceDrops string, notFound bool) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Method string `json:"method"`
|
||||
Params []struct {
|
||||
Account string `json:"account"`
|
||||
LedgerIndex string `json:"ledger_index"`
|
||||
Strict bool `json:"strict"`
|
||||
} `json:"params"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Method != "account_info" {
|
||||
t.Errorf("rippled method = %q, want account_info", req.Method)
|
||||
}
|
||||
if len(req.Params) != 1 || req.Params[0].Account == "" {
|
||||
t.Errorf("account_info params malformed: %+v", req.Params)
|
||||
}
|
||||
if req.Params[0].LedgerIndex != "validated" {
|
||||
t.Errorf("ledger_index = %q, want validated", req.Params[0].LedgerIndex)
|
||||
}
|
||||
if !req.Params[0].Strict {
|
||||
t.Errorf("strict must be true (address, not username)")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if notFound {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"result": map[string]any{
|
||||
"account": req.Params[0].Account,
|
||||
"error": "actNotFound",
|
||||
"error_code": 19,
|
||||
"error_message": "Account not found.",
|
||||
"status": "error",
|
||||
"validated": true,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"result": map[string]any{
|
||||
"account_data": map[string]any{
|
||||
"Account": req.Params[0].Account,
|
||||
"Balance": balanceDrops,
|
||||
},
|
||||
"status": "success",
|
||||
"validated": true,
|
||||
},
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func xrpClient(srv *httptest.Server) *Client {
|
||||
return &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{"XRP_TESTNET": srv.URL}}
|
||||
}
|
||||
|
||||
func xrpCheck(t *testing.T, c *Client, required float64) (bool, error) {
|
||||
t.Helper()
|
||||
return c.Check(context.Background(), CheckParams{
|
||||
NetworkInternalName: "XRP_TESTNET",
|
||||
Address: "rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe",
|
||||
Asset: "XRP",
|
||||
RequiredAmount: required,
|
||||
})
|
||||
}
|
||||
|
||||
// 20 XRP (20_000_000 drops) satisfies a 10-XRP requirement.
|
||||
func TestCheck_XRP_Confirmed(t *testing.T) {
|
||||
c := xrpClient(xrpAccountInfoServer(t, "20000000", false))
|
||||
ok, err := xrpCheck(t, c, 10)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("20 XRP vs 10 required: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Exactly 10 XRP satisfies a 10-XRP requirement (>= boundary).
|
||||
func TestCheck_XRP_ExactBoundary(t *testing.T) {
|
||||
c := xrpClient(xrpAccountInfoServer(t, "10000000", false))
|
||||
ok, err := xrpCheck(t, c, 10)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("exactly 10 XRP must satisfy 10 required: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 5 XRP is short of a 10-XRP requirement: (false, nil) — not an error.
|
||||
func TestCheck_XRP_Insufficient(t *testing.T) {
|
||||
c := xrpClient(xrpAccountInfoServer(t, "5000000", false))
|
||||
ok, err := xrpCheck(t, c, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("insufficient balance must not error: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("5 XRP must not satisfy 10 required")
|
||||
}
|
||||
}
|
||||
|
||||
// An unfunded account (actNotFound) is "no deposit yet" — (false, nil),
|
||||
// NOT an error — for a positive requirement.
|
||||
func TestCheck_XRP_NotFound_IsNoDepositNotError(t *testing.T) {
|
||||
c := xrpClient(xrpAccountInfoServer(t, "", true))
|
||||
ok, err := xrpCheck(t, c, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("actNotFound must not be an error, got %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("actNotFound (zero balance) must not satisfy 10 required")
|
||||
}
|
||||
}
|
||||
|
||||
// A zero requirement is satisfied by any balance, including an unfunded
|
||||
// (actNotFound) account.
|
||||
func TestCheck_XRP_NotFound_ZeroRequired(t *testing.T) {
|
||||
c := xrpClient(xrpAccountInfoServer(t, "", true))
|
||||
ok, err := xrpCheck(t, c, 0)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("zero-required must be satisfied by any balance: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A non-actNotFound rippled error surfaces as (false, error).
|
||||
func TestCheck_XRP_RPCError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"result": map[string]any{
|
||||
"error": "invalidParams",
|
||||
"error_message": "Missing field 'account'.",
|
||||
"status": "error",
|
||||
},
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
ok, err := xrpCheck(t, xrpClient(srv), 1)
|
||||
if err == nil {
|
||||
t.Fatalf("rippled invalidParams must surface as an error")
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("error result must be false")
|
||||
}
|
||||
}
|
||||
|
||||
// A non-decimal Balance is a decode failure, not a silent zero.
|
||||
func TestCheck_XRP_MalformedBalance(t *testing.T) {
|
||||
c := xrpClient(xrpAccountInfoServer(t, "not-a-number", false))
|
||||
if _, err := xrpCheck(t, c, 1); err == nil {
|
||||
t.Fatalf("non-decimal Balance must error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user