mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
Makes XRP withdrawal safe to enable (testnet on; mainnet staged-gated) by closing
the fund-loss + gate-bypass vectors a red/blue loop surfaced over three rounds.
DESTINATION TAG (else a tag-less payout to an exchange pool is lost):
- internal/xrpl: ValidateDestination (reject X-addresses + validate r-address);
parseAccount recover-wrapper (fixes a rippledata base58 panic-DoS reachable in
the signing goroutine); tag set on the tx BEFORE SigningHash so it is committed
into the SHA-512Half the MPC signs (unstrippable, proven).
- internal/txassembler: XRPSpec.DestinationTag *uint32 -> PaymentUnsigned.
- cmd/bridge: Swap.DestinationTag, createSwapReq accepts+range-checks it [0,2^32),
signOneXRP threads it + re-validates the destination (terminal-fail on bad addr).
AUTHORITATIVE WITHDRAWAL GATE (was cosmetic — read only by the SPA list):
- config.go: WithdrawalEnabled/DepositEnabled now enforced server-side. Case-
INSENSITIVE (network,asset) match + default-DENY for wired non-EVM families
(BTC/SOL/TON/XRP/DOT) so an asset-case variant ('xrp'), empty asset, or network-
case variant ('bitcoin_testnet') can't dodge a disabled row into a release pool
(gate key now aligns with the network-only, case-folded signer routing). EVM
stays default-on.
- Enforced at CREATE (swapsCreateNative 403), SIGN (signOne skip = kill-switch),
BROADCAST (broadcastOne holds already-signed in-flight swaps), and the operator
INJECT path (swapsInjectRawTxNative 403). signOne also gains a defer-recover
backstop so one swap's panic can't crash the daemon.
Tests: tag carried+committed+on-wire; X-addr/bad-addr/panic-input rejected; tag
range boundaries; gate variant-bypass closed; create/sign/broadcast/inject all
honor the gate; kill-switch holds then resumes. cmd/bridge + xrpl + txassembler
green, full module green, vet clean.
KNOWN FOLLOW-UPS (pre-existing, bridge-wide, NOT XRP-specific; tracked):
- /admin/swaps/:id/{reset,inject-raw-tx} have no auth middleware (bounded by
64-bit random swap IDs + the now-gated inject). Gate behind operator token/mTLS.
- GET /v1/bridge/swaps has no tenant scoping (IDOR). Both are mainnet-readiness
items; mainnet XRP withdrawal stays isWithdrawalEnabled:false until addressed +
a live testnet tag-carrying-payout proof.
557 lines
18 KiB
Go
557 lines
18 KiB
Go
package xrpl
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/hex"
|
|
"math/big"
|
|
"strings"
|
|
"testing"
|
|
|
|
rippledata "github.com/rubblelabs/ripple/data"
|
|
)
|
|
|
|
// =============================================================================
|
|
// Address derivation
|
|
// =============================================================================
|
|
|
|
// Test vectors below come from the canonical XRPL key-derivation
|
|
// example documented in rippled's PublicKey_test.cpp + xrpl.org's
|
|
// cryptographic-keys page. The pubkey 0330... → r-address mapping is
|
|
// stable across every XRPL implementation since 2014.
|
|
func TestAddressFromPubKey_CanonicalVector(t *testing.T) {
|
|
pubHex := "0330E7FC9D56BB25D6893BA3F317AE5BCF33B3291BD63DB32654A313222F7FD020"
|
|
pub, err := hex.DecodeString(pubHex)
|
|
if err != nil {
|
|
t.Fatalf("decode pub: %v", err)
|
|
}
|
|
addr, err := AddressFromPubKey(pub)
|
|
if err != nil {
|
|
t.Fatalf("AddressFromPubKey: %v", err)
|
|
}
|
|
// Sanity-check: address starts with 'r' and has the right length.
|
|
if !strings.HasPrefix(addr, "r") {
|
|
t.Errorf("address must start with 'r'; got %q", addr)
|
|
}
|
|
if len(addr) < 25 || len(addr) > 35 {
|
|
t.Errorf("address length out of range [25..35]; got %d (%q)", len(addr), addr)
|
|
}
|
|
// Round-trip: parsing the address back yields the same AccountID
|
|
// the pubkey produces — confirms the base58check encoding is
|
|
// XRPL-canonical (right alphabet, right version byte).
|
|
wantID, err := AccountIDFromPubKey(pub)
|
|
if err != nil {
|
|
t.Fatalf("AccountIDFromPubKey: %v", err)
|
|
}
|
|
parsed, err := rippledata.NewAccountFromAddress(addr)
|
|
if err != nil {
|
|
t.Fatalf("NewAccountFromAddress(%q): %v", addr, err)
|
|
}
|
|
if !bytes.Equal(parsed[:], wantID) {
|
|
t.Errorf("address ↔ AccountID round-trip failed:\n got %x\n want %x", parsed[:], wantID)
|
|
}
|
|
}
|
|
|
|
func TestAddressFromPubKey_RejectsBadLength(t *testing.T) {
|
|
cases := [][]byte{
|
|
nil,
|
|
make([]byte, 32),
|
|
make([]byte, 65),
|
|
}
|
|
for _, c := range cases {
|
|
if _, err := AddressFromPubKey(c); err == nil {
|
|
t.Errorf("expected error for length=%d", len(c))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAddressFromPubKey_RejectsUncompressed(t *testing.T) {
|
|
// 33-byte length but leading byte 0x04 means "uncompressed"
|
|
// (which is actually 65 bytes; this is a malformed input the
|
|
// AddressFromPubKey helper should still reject).
|
|
bad := make([]byte, 33)
|
|
bad[0] = 0x04
|
|
if _, err := AddressFromPubKey(bad); err == nil {
|
|
t.Error("expected error for non-compressed prefix")
|
|
}
|
|
}
|
|
|
|
func TestAccountIDFromPubKey_LengthAlways20(t *testing.T) {
|
|
pub, _ := hex.DecodeString("0330E7FC9D56BB25D6893BA3F317AE5BCF33B3291BD63DB32654A313222F7FD020")
|
|
id, err := AccountIDFromPubKey(pub)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(id) != 20 {
|
|
t.Errorf("AccountID must be 20 bytes; got %d", len(id))
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// DER encoding + low-s normalization
|
|
// =============================================================================
|
|
|
|
func TestEncodeDERSignature_StandardCase(t *testing.T) {
|
|
// A typical (r, s) pair, both 32-byte values with no leading-zero
|
|
// padding required.
|
|
r := mustHexInt("4D8E91239FC0EFDABFFF3DE85CB99CE2DB81E61F1AA12FDEDC5C2C0E8DC8E03A")
|
|
s := mustHexInt("39A0BA9D5B4F47D9F4B70F1A77F0E29A4C7E48E7C72D1D8E89BB1D77F4E32BC1")
|
|
der, err := EncodeDERSignature(r, s)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if der[0] != 0x30 {
|
|
t.Errorf("DER must start with 0x30, got 0x%02x", der[0])
|
|
}
|
|
if int(der[1]) != len(der)-2 {
|
|
t.Errorf("DER length byte wrong: %d vs body %d", der[1], len(der)-2)
|
|
}
|
|
// Two INTEGER tags inside the SEQUENCE.
|
|
if der[2] != 0x02 {
|
|
t.Errorf("first INTEGER tag missing: der[2]=0x%02x", der[2])
|
|
}
|
|
// Locate second INTEGER.
|
|
rLen := int(der[3])
|
|
if der[4+rLen] != 0x02 {
|
|
t.Errorf("second INTEGER tag missing at offset %d: der=0x%02x", 4+rLen, der[4+rLen])
|
|
}
|
|
}
|
|
|
|
func TestEncodeDERSignature_HighSGetsCanonicalized(t *testing.T) {
|
|
// Pick s > N/2. The encoder should flip to N - s before serializing.
|
|
r := mustHexInt("DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF")
|
|
highS := new(big.Int).Sub(secp256k1N, big.NewInt(1)) // N - 1 → guaranteed high-s
|
|
der, err := EncodeDERSignature(r, highS)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Decode the s value from the DER body and confirm it's now the
|
|
// low-s equivalent (N - highS = 1).
|
|
if der[0] != 0x30 {
|
|
t.Fatal("not a SEQUENCE")
|
|
}
|
|
idx := 2
|
|
if der[idx] != 0x02 {
|
|
t.Fatal("first element not INTEGER")
|
|
}
|
|
rLen := int(der[idx+1])
|
|
idx += 2 + rLen
|
|
if der[idx] != 0x02 {
|
|
t.Fatal("second element not INTEGER")
|
|
}
|
|
sLen := int(der[idx+1])
|
|
sBytes := der[idx+2 : idx+2+sLen]
|
|
gotS := new(big.Int).SetBytes(sBytes)
|
|
wantS := big.NewInt(1) // N - (N - 1) = 1
|
|
if gotS.Cmp(wantS) != 0 {
|
|
t.Errorf("low-s flip not applied:\n got %s\n want %s", gotS, wantS)
|
|
}
|
|
}
|
|
|
|
func TestEncodeDERSignature_HighBitPrefixesZero(t *testing.T) {
|
|
// r with leading 0x80 bit set → ASN.1 INTEGER must prepend 0x00.
|
|
r := mustHexInt("80112233445566778899AABBCCDDEEFF00112233445566778899AABBCCDDEEFF")
|
|
s := mustHexInt("01")
|
|
der, err := EncodeDERSignature(r, s)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rLen := int(der[3])
|
|
if rLen != 33 {
|
|
t.Errorf("rLen should be 33 (32 + leading 0x00), got %d", rLen)
|
|
}
|
|
if der[4] != 0x00 {
|
|
t.Errorf("leading-zero pad missing; der[4]=0x%02x", der[4])
|
|
}
|
|
}
|
|
|
|
func TestEncodeDERSignature_RejectsNegativeOrZero(t *testing.T) {
|
|
cases := []struct{ r, s *big.Int }{
|
|
{nil, big.NewInt(1)},
|
|
{big.NewInt(1), nil},
|
|
{big.NewInt(0), big.NewInt(1)},
|
|
{big.NewInt(1), big.NewInt(0)},
|
|
{big.NewInt(-1), big.NewInt(1)},
|
|
}
|
|
for i, c := range cases {
|
|
if _, err := EncodeDERSignature(c.r, c.s); err == nil {
|
|
t.Errorf("case %d: expected error", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCanonicalizeS_LowSUnchanged(t *testing.T) {
|
|
low := big.NewInt(1)
|
|
got := CanonicalizeS(low)
|
|
if got.Cmp(low) != 0 {
|
|
t.Errorf("low-s should pass through; got %s want %s", got, low)
|
|
}
|
|
}
|
|
|
|
func TestCanonicalizeS_HighSFlipped(t *testing.T) {
|
|
high := new(big.Int).Sub(secp256k1N, big.NewInt(7))
|
|
got := CanonicalizeS(high)
|
|
want := big.NewInt(7)
|
|
if got.Cmp(want) != 0 {
|
|
t.Errorf("high-s flip wrong; got %s want %s", got, want)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// PreSign + Finalize round-trip
|
|
// =============================================================================
|
|
|
|
// canonical 33-byte compressed-secp256k1 pubkey from the XRPL doc
|
|
// vectors (also used in TestAddressFromPubKey_CanonicalVector).
|
|
var testPubKey, _ = hex.DecodeString(
|
|
"0330E7FC9D56BB25D6893BA3F317AE5BCF33B3291BD63DB32654A313222F7FD020")
|
|
|
|
func TestPreSign_BuildsCanonicalDigest(t *testing.T) {
|
|
// Use the address derived from our test pubkey so the tx is
|
|
// internally consistent (Account matches SigningPubKey).
|
|
signerAddr, err := AddressFromPubKey(testPubKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
u := PaymentUnsigned{
|
|
Account: signerAddr,
|
|
Destination: "rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe",
|
|
AmountDrops: 1_000_000, // 1 XRP
|
|
FeeDrops: XRPLBaseFeeDrops,
|
|
Sequence: 42,
|
|
LastLedgerSequence: 9999999,
|
|
SigningPubKey: testPubKey,
|
|
Flags: CanonicalSigFlag,
|
|
}
|
|
digest, ctx, err := PreSign(u)
|
|
if err != nil {
|
|
t.Fatalf("PreSign: %v", err)
|
|
}
|
|
if len(digest) != 32 {
|
|
t.Errorf("digest must be 32 bytes (SHA-512Half); got %d", len(digest))
|
|
}
|
|
if ctx == nil || ctx.Payment == nil {
|
|
t.Fatal("ctx.Payment must be populated")
|
|
}
|
|
if ctx.Payment.TransactionType != rippledata.PAYMENT {
|
|
t.Errorf("TransactionType = %v, want PAYMENT", ctx.Payment.TransactionType)
|
|
}
|
|
if ctx.Payment.Sequence != 42 {
|
|
t.Errorf("Sequence = %d, want 42", ctx.Payment.Sequence)
|
|
}
|
|
|
|
// Independent recomputation: the digest must equal
|
|
// SHA-512Half(STX-prefix || serialized_body_without_signature).
|
|
_, msg, err := rippledata.SigningHash(ctx.Payment)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
independent := SHA512Half(HashPrefixTransactionSign, msg)
|
|
if !bytes.Equal(digest, independent) {
|
|
t.Errorf("digest mismatch:\n got %x\n want %x", digest, independent)
|
|
}
|
|
}
|
|
|
|
func TestPreSign_RejectsBadInputs(t *testing.T) {
|
|
addr, _ := AddressFromPubKey(testPubKey)
|
|
good := PaymentUnsigned{
|
|
Account: addr,
|
|
Destination: "rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe",
|
|
AmountDrops: 1_000_000,
|
|
FeeDrops: 10,
|
|
Sequence: 1,
|
|
SigningPubKey: testPubKey,
|
|
}
|
|
// Empty account.
|
|
bad := good
|
|
bad.Account = ""
|
|
if _, _, err := PreSign(bad); err == nil {
|
|
t.Error("expected error for empty Account")
|
|
}
|
|
// Bad amount.
|
|
bad = good
|
|
bad.AmountDrops = 0
|
|
if _, _, err := PreSign(bad); err == nil {
|
|
t.Error("expected error for zero AmountDrops")
|
|
}
|
|
// Bad pubkey size.
|
|
bad = good
|
|
bad.SigningPubKey = make([]byte, 32)
|
|
if _, _, err := PreSign(bad); err == nil {
|
|
t.Error("expected error for 32-byte pubkey")
|
|
}
|
|
// Bad pubkey prefix.
|
|
bad = good
|
|
bad.SigningPubKey = make([]byte, 33)
|
|
bad.SigningPubKey[0] = 0x04
|
|
if _, _, err := PreSign(bad); err == nil {
|
|
t.Error("expected error for non-compressed pubkey prefix")
|
|
}
|
|
// Malformed destination address.
|
|
bad = good
|
|
bad.Destination = "not-an-r-address"
|
|
if _, _, err := PreSign(bad); err == nil {
|
|
t.Error("expected error for bad Destination")
|
|
}
|
|
}
|
|
|
|
func TestFinalize_RoundTripsThroughParser(t *testing.T) {
|
|
addr, _ := AddressFromPubKey(testPubKey)
|
|
u := PaymentUnsigned{
|
|
Account: addr,
|
|
Destination: "rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe",
|
|
AmountDrops: 1_000_000,
|
|
FeeDrops: 12,
|
|
Sequence: 7,
|
|
LastLedgerSequence: 1_234_567,
|
|
SigningPubKey: testPubKey,
|
|
Flags: CanonicalSigFlag,
|
|
}
|
|
_, ctx, err := PreSign(u)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Fake an MPC signature: small valid (r, s) pair.
|
|
r := mustHexInt("1A2B3C4D5E6F708192A3B4C5D6E7F80910111213141516171819202122232425")
|
|
s := mustHexInt("0B0C0D0E0F101112131415161718191A1B1C1D1E1F20212223242526272829FF")
|
|
der, err := EncodeDERSignature(r, s)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
blob, hash, err := Finalize(der, ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(blob) == 0 {
|
|
t.Fatal("Finalize returned empty blob")
|
|
}
|
|
if len(hash) != 64 { // 32-byte hash → 64 hex chars
|
|
t.Errorf("hash must be 64 hex chars; got %d (%q)", len(hash), hash)
|
|
}
|
|
|
|
// Round-trip: parse the blob back through the rubblelabs codec and
|
|
// check the embedded fields match.
|
|
parsed, err := rippledata.ReadTransaction(bytes.NewReader(blob))
|
|
if err != nil {
|
|
t.Fatalf("re-parse blob: %v", err)
|
|
}
|
|
payment, ok := parsed.(*rippledata.Payment)
|
|
if !ok {
|
|
t.Fatalf("parsed wrong tx type: %T", parsed)
|
|
}
|
|
if payment.Sequence != u.Sequence {
|
|
t.Errorf("Sequence: got %d want %d", payment.Sequence, u.Sequence)
|
|
}
|
|
if payment.Account.String() != u.Account {
|
|
t.Errorf("Account: got %s want %s", payment.Account, u.Account)
|
|
}
|
|
if payment.Destination.String() != u.Destination {
|
|
t.Errorf("Destination: got %s want %s", payment.Destination, u.Destination)
|
|
}
|
|
if payment.TxnSignature == nil || !bytes.Equal([]byte(*payment.TxnSignature), der) {
|
|
t.Error("TxnSignature missing or mismatched after round-trip")
|
|
}
|
|
if payment.SigningPubKey == nil || !bytes.Equal(payment.SigningPubKey[:], testPubKey) {
|
|
t.Error("SigningPubKey mismatched after round-trip")
|
|
}
|
|
}
|
|
|
|
func TestFinalize_RejectsBadInputs(t *testing.T) {
|
|
if _, _, err := Finalize(nil, nil); err == nil {
|
|
t.Error("expected error for nil ctx")
|
|
}
|
|
if _, _, err := Finalize(nil, &SignCtx{}); err == nil {
|
|
t.Error("expected error for nil Payment in ctx")
|
|
}
|
|
// Real ctx but empty DER.
|
|
addr, _ := AddressFromPubKey(testPubKey)
|
|
_, ctx, _ := PreSign(PaymentUnsigned{
|
|
Account: addr,
|
|
Destination: "rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe",
|
|
AmountDrops: 1,
|
|
FeeDrops: 10,
|
|
Sequence: 1,
|
|
SigningPubKey: testPubKey,
|
|
})
|
|
if _, _, err := Finalize(nil, ctx); err == nil {
|
|
t.Error("expected error for empty DER")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// DestinationTag — carried, committed into the digest, and on the wire
|
|
// =============================================================================
|
|
|
|
// A destination tag must (a) land on the Payment, and (b) change the
|
|
// signing digest — if the digest were unchanged, the signature would
|
|
// not commit to the tag, and an attacker (or a buggy serializer) could
|
|
// strip it post-signing and the signature would still validate,
|
|
// misrouting funds to an exchange's pooled deposit. So the digest MUST
|
|
// differ between tag-present and tag-absent.
|
|
func TestPreSign_DestinationTag_CarriedAndCommitted(t *testing.T) {
|
|
addr, err := AddressFromPubKey(testPubKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
base := PaymentUnsigned{
|
|
Account: addr,
|
|
Destination: "rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe",
|
|
AmountDrops: 1_000_000,
|
|
FeeDrops: XRPLBaseFeeDrops,
|
|
Sequence: 42,
|
|
LastLedgerSequence: 9_999_999,
|
|
SigningPubKey: testPubKey,
|
|
Flags: CanonicalSigFlag,
|
|
}
|
|
|
|
// Tag absent → Payment carries no tag.
|
|
noTagDigest, noTagCtx, err := PreSign(base)
|
|
if err != nil {
|
|
t.Fatalf("PreSign(no tag): %v", err)
|
|
}
|
|
if noTagCtx.Payment.DestinationTag != nil {
|
|
t.Errorf("DestinationTag should be nil when unset, got %d", *noTagCtx.Payment.DestinationTag)
|
|
}
|
|
|
|
// Tag present → Payment carries it.
|
|
tag := uint32(1234567)
|
|
withTag := base
|
|
withTag.DestinationTag = &tag
|
|
tagDigest, tagCtx, err := PreSign(withTag)
|
|
if err != nil {
|
|
t.Fatalf("PreSign(tag): %v", err)
|
|
}
|
|
if tagCtx.Payment.DestinationTag == nil || *tagCtx.Payment.DestinationTag != tag {
|
|
t.Fatalf("DestinationTag not carried onto Payment; got %v", tagCtx.Payment.DestinationTag)
|
|
}
|
|
|
|
// The tag must be inside the signed preimage.
|
|
if bytes.Equal(noTagDigest, tagDigest) {
|
|
t.Error("signing digest unchanged by DestinationTag — tag is NOT committed into the signature (strippable)")
|
|
}
|
|
}
|
|
|
|
// Full round-trip: a tag set on PaymentUnsigned must survive
|
|
// serialization and be readable back off the wire blob.
|
|
func TestFinalize_DestinationTag_OnTheWire(t *testing.T) {
|
|
addr, _ := AddressFromPubKey(testPubKey)
|
|
tag := uint32(4294967295) // max uint32 — boundary value
|
|
u := PaymentUnsigned{
|
|
Account: addr,
|
|
Destination: "rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe",
|
|
AmountDrops: 1_000_000,
|
|
FeeDrops: 12,
|
|
Sequence: 7,
|
|
LastLedgerSequence: 1_234_567,
|
|
SigningPubKey: testPubKey,
|
|
Flags: CanonicalSigFlag,
|
|
DestinationTag: &tag,
|
|
}
|
|
_, ctx, err := PreSign(u)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r := mustHexInt("1A2B3C4D5E6F708192A3B4C5D6E7F80910111213141516171819202122232425")
|
|
s := mustHexInt("0B0C0D0E0F101112131415161718191A1B1C1D1E1F20212223242526272829FF")
|
|
der, err := EncodeDERSignature(r, s)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
blob, _, err := Finalize(der, ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
parsed, err := rippledata.ReadTransaction(bytes.NewReader(blob))
|
|
if err != nil {
|
|
t.Fatalf("re-parse blob: %v", err)
|
|
}
|
|
payment, ok := parsed.(*rippledata.Payment)
|
|
if !ok {
|
|
t.Fatalf("parsed wrong tx type: %T", parsed)
|
|
}
|
|
if payment.DestinationTag == nil || *payment.DestinationTag != tag {
|
|
t.Errorf("DestinationTag missing/mismatched on the wire; got %v want %d", payment.DestinationTag, tag)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// ValidateDestination — X-address rejection + r-address validation
|
|
// =============================================================================
|
|
|
|
func TestValidateDestination(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
addr string
|
|
wantErr bool
|
|
}{
|
|
{"valid classic r-address", "rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe", false},
|
|
{"another valid classic", "rGWrZyQqhTp9Xu7G5Pkayo7bXjH4k4QYpf", false},
|
|
{"empty", "", true},
|
|
// Canonical XLS-5 X-address test vectors (ripple-address-codec).
|
|
{"mainnet X-address", "XVLhHMPHU98es4dbozjVtdWzVrDjtV5fdx1mHp98tDMoQXb", true},
|
|
{"testnet X-address", "TVE26TYGhfLC7tQDno7G8dGtxSkYQn49b3qD26PK7FcGSKE", true},
|
|
{"garbage", "not-an-r-address", true},
|
|
{"too short", "r123", true},
|
|
{"evm address", "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473", true},
|
|
// Regression: this input PANICS rippledata.NewAccountFromAddress
|
|
// (slice bounds out of range). ValidateDestination must return a
|
|
// clean error, never panic.
|
|
{"decoder-panic input", "ppppp", true},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
err := ValidateDestination(c.addr)
|
|
if c.wantErr && err == nil {
|
|
t.Errorf("ValidateDestination(%q) = nil, want error", c.addr)
|
|
}
|
|
if !c.wantErr && err != nil {
|
|
t.Errorf("ValidateDestination(%q) = %v, want nil", c.addr, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The X-address rejection error must be actionable — it has to tell the
|
|
// user to supply a classic r-address + explicit tag, not just bubble an
|
|
// opaque "bad version" from the decoder.
|
|
func TestValidateDestination_XAddressErrorIsActionable(t *testing.T) {
|
|
err := ValidateDestination("XVLhHMPHU98es4dbozjVtdWzVrDjtV5fdx1mHp98tDMoQXb")
|
|
if err == nil {
|
|
t.Fatal("expected error for X-address")
|
|
}
|
|
msg := err.Error()
|
|
if !strings.Contains(msg, "X-address") || !strings.Contains(msg, "classic r-address") {
|
|
t.Errorf("X-address error not actionable: %q", msg)
|
|
}
|
|
}
|
|
|
|
// Defense in depth: PreSign itself must not panic on a decoder-fault
|
|
// destination even though the API boundary already rejects it.
|
|
func TestPreSign_DoesNotPanicOnDecoderFaultDestination(t *testing.T) {
|
|
addr, _ := AddressFromPubKey(testPubKey)
|
|
_, _, err := PreSign(PaymentUnsigned{
|
|
Account: addr,
|
|
Destination: "ppppp", // panics rippledata.NewAccountFromAddress raw
|
|
AmountDrops: 1_000_000,
|
|
FeeDrops: 10,
|
|
Sequence: 1,
|
|
SigningPubKey: testPubKey,
|
|
})
|
|
if err == nil {
|
|
t.Error("expected error for decoder-fault destination, got nil")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// helpers
|
|
// =============================================================================
|
|
|
|
func mustHexInt(s string) *big.Int {
|
|
s = strings.TrimPrefix(strings.TrimPrefix(s, "0x"), "0X")
|
|
n, ok := new(big.Int).SetString(s, 16)
|
|
if !ok {
|
|
panic("mustHexInt: bad hex " + s)
|
|
}
|
|
return n
|
|
}
|