Files
zeekay 2dd8da86db 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.
2026-06-26 08:44:12 -07:00

175 lines
5.3 KiB
Go

// 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")
}
}