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:
2026-06-26 08:44:12 -07:00
parent b82775078d
commit 2dd8da86db
5 changed files with 290 additions and 19 deletions
+20
View File
@@ -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
+23
View File
@@ -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
+70 -4
View File
@@ -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
// =============================================================================
+3 -15
View File
@@ -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
+174
View File
@@ -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")
}
}