mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
TON's wallet version is not a preference, it is FORCED. The account_id is a hash of the wallet contract's StateInit, so the same Ed25519 key yields a different address under v3R2, v4R2 and W5 — and luxfi/bridge's TON assembler is the only code in the estate that can SPEND TON. It builds v4R2 with subwallet 698983191. Emitting any other version would produce addresses that fund normally and that our own signer can never deploy a contract for: money in, unspendable. So this makes the identical call into the identical library rather than hand-rolling cell serialization. Non-bounceable (UQ…) because a bounceable message to a not-yet-deployed contract is returned to sender, and a deposit address is undeployed by definition until its first spend. Bridge makes the same choice. ⚠ SECOND LIVE WRONG-ADDRESS BUG, same shape as the TON/Solana one removed last week: handlers_wallets served addresses["xrp"] = *wallet.EVMAddress — a 20-byte hex EVM address labelled XRP. Both xrp and ton now derive from the stored public keys through pkg/address. Derived on READ rather than adding columns, so every wallet minted before today gets a correct address with no migration. Vectors are external on purpose. XRP matches three published XRPL fixtures (xrpl.js secp256k1 + ed25519, and the dev-portal address-encoding example). TON uses MAINNET as the oracle — library fixtures would be self-referential — by harvesting accounts whose code_hash is the v4R2 code hash and asking each contract for its own public key; one row is corroborated independently by @ton/ton's published spec address. The code cell is pinned to mainnet's hash and the data layout to seqno‖subwallet‖pubkey. Curve confusion fails closed both ways: TON refuses a secp256k1 key, XRP refuses an Ed25519 one, and TON refuses every torsion-order point Solana refuses. Known trap left alone deliberately: pubKeyToEVMAddress and pubKeyToBtcAddress still assume even-y for a 32-byte input, which is wrong for half of keys. Not reachable today because keygen serializes the 33-byte compressed point, but it is one refactor from being live. address.XRP refuses that input instead. Fixing the EVM/BTC paths is a higher-risk edit that deserves its own review.
220 lines
8.3 KiB
Go
220 lines
8.3 KiB
Go
package address
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"testing"
|
|
|
|
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
|
"github.com/mr-tron/base58"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// Published XRPL vectors.
|
|
//
|
|
// Source 1 — XRPLF/xrpl.js, packages/ripple-keypairs/test/fixtures/api.json.
|
|
// These are the reference implementation's own fixtures, one per signing
|
|
// algorithm, each pairing a public key with the address it must produce.
|
|
//
|
|
// Source 2 — XRPLF/xrpl-dev-portal, docs/concepts/accounts/addresses.md,
|
|
// section "Address Encoding". The docs walk the encoding step by step and
|
|
// print the resulting address at the end; that worked example is reproduced
|
|
// here verbatim.
|
|
//
|
|
// The two sources are independent (a library's tests and the protocol's
|
|
// documentation), and between them they cover both key types XRPL accepts, so
|
|
// agreement is evidence about the encoding rather than about us.
|
|
const (
|
|
// ripple-keypairs api.json → .secp256k1.keypair.publicKey / .secp256k1.address
|
|
xrplVectorSecpPubKey = "030D58EB48B4420B1F7B9DF55087E0E29FEF0E8468F9A6825B01CA2C361042D435"
|
|
xrplVectorSecpAddress = "rU6K7V3Po4snVhBBaU29sesqs2qTQJWDw1"
|
|
|
|
// ripple-keypairs api.json → .ed25519.keypair.publicKey / .ed25519.address
|
|
xrplVectorEd25519PubKey = "ED01FA53FA5A7E77798F882ECE20B1ABC00BB358A9E55A202D0D0676BD0CE37A63"
|
|
xrplVectorEd25519Address = "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD"
|
|
|
|
// addresses.md "Address Encoding" worked example.
|
|
xrplDocsPubKey = "ED9434799226374926EDA3B54B1B461B4ABF7237962EAE18528FEA67595397FA32"
|
|
xrplDocsAddress = "rDTXLQ7ZKZVKz33zJbHjgVShjsBnqMBhmN"
|
|
)
|
|
|
|
// TestXRPMatchesPublishedSecp256k1Vector is the one that matters for us: the
|
|
// full exported path, from a secp256k1 key of the kind mpcd actually holds to
|
|
// the address XRPL's reference implementation says that key owns.
|
|
func TestXRPMatchesPublishedSecp256k1Vector(t *testing.T) {
|
|
pub, err := hex.DecodeString(xrplVectorSecpPubKey)
|
|
require.NoError(t, err)
|
|
|
|
addr, err := XRP(pub)
|
|
require.NoError(t, err)
|
|
require.Equal(t, xrplVectorSecpAddress, addr,
|
|
"XRP address must equal the one XRPLF/xrpl.js publishes for this key")
|
|
}
|
|
|
|
// TestXRPLEncoderMatchesPublishedEd25519Vectors exercises the encoder against
|
|
// the two published Ed25519 vectors.
|
|
//
|
|
// XRP itself refuses these keys — an Ed25519 XRPL address is one this wallet
|
|
// cannot sign for — so the test reaches the unexported encoder directly. That
|
|
// is the point: the encoding is curve-blind and these vectors pin it, which
|
|
// doubles the published evidence behind the base58 alphabet, the 0x00 prefix
|
|
// and the double-SHA256 checksum without weakening the curve gate.
|
|
func TestXRPLEncoderMatchesPublishedEd25519Vectors(t *testing.T) {
|
|
for name, tc := range map[string]struct{ pubHex, want string }{
|
|
"ripple_keypairs_fixture": {xrplVectorEd25519PubKey, xrplVectorEd25519Address},
|
|
"docs_worked_example": {xrplDocsPubKey, xrplDocsAddress},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
pub, err := hex.DecodeString(tc.pubHex)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tc.want, xrplClassicAddress(pub))
|
|
|
|
// And the exported entry point must still refuse it, because the
|
|
// signing path for XRPL is ECDSA-only.
|
|
addr, err := XRP(pub)
|
|
require.ErrorIs(t, err, ErrNotSecp256k1)
|
|
require.Empty(t, addr)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestXRPRoundTripsToTheAccountID decodes the address back to its parts and
|
|
// checks each one, so a change that happened to keep the string well-formed
|
|
// while corrupting the payload cannot pass.
|
|
func TestXRPRoundTripsToTheAccountID(t *testing.T) {
|
|
for i := 0; i < 64; i++ {
|
|
priv, err := secp256k1.GeneratePrivateKey()
|
|
require.NoError(t, err)
|
|
compressed := priv.PubKey().SerializeCompressed()
|
|
|
|
addr, err := XRP(compressed)
|
|
require.NoError(t, err)
|
|
|
|
decoded, err := base58.DecodeAlphabet(addr, xrplAlphabet)
|
|
require.NoError(t, err)
|
|
require.Len(t, decoded, 25, "1 prefix + 20 account id + 4 checksum")
|
|
require.Equal(t, byte(xrplAccountIDPrefix), decoded[0])
|
|
require.Equal(t, "r", addr[:1], "the 0x00 prefix is what makes it render as r…")
|
|
|
|
require.Equal(t, xrplClassicAddress(compressed), addr)
|
|
|
|
// The uncompressed encoding of the same point must land on the same
|
|
// address; canonicalisation happening inside RequireSecp256k1 is what
|
|
// guarantees that, and it is the property that stops a caller's choice
|
|
// of encoding from moving the address.
|
|
fromUncompressed, err := XRP(priv.PubKey().SerializeUncompressed())
|
|
require.NoError(t, err)
|
|
require.Equal(t, addr, fromUncompressed)
|
|
}
|
|
}
|
|
|
|
// TestXRPRefusesCorruptedChecksum proves the checksum is load-bearing: flipping
|
|
// any single bit of a valid address must break it. Verified against XRPL's own
|
|
// rule rather than ours by checking the decoded checksum no longer matches.
|
|
func TestXRPRefusesCorruptedChecksum(t *testing.T) {
|
|
pub, err := hex.DecodeString(xrplVectorSecpPubKey)
|
|
require.NoError(t, err)
|
|
addr, err := XRP(pub)
|
|
require.NoError(t, err)
|
|
|
|
decoded, err := base58.DecodeAlphabet(addr, xrplAlphabet)
|
|
require.NoError(t, err)
|
|
|
|
alphabet := "rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz"
|
|
for i := 0; i < len(addr); i++ {
|
|
corrupted := []byte(addr)
|
|
// Swap this character for a different one from the same alphabet, so
|
|
// the string stays decodable and only the value changes.
|
|
if corrupted[i] == alphabet[0] {
|
|
corrupted[i] = alphabet[1]
|
|
} else {
|
|
corrupted[i] = alphabet[0]
|
|
}
|
|
require.NotEqual(t, addr, string(corrupted))
|
|
|
|
got, err := base58.DecodeAlphabet(string(corrupted), xrplAlphabet)
|
|
if err != nil || len(got) != 25 {
|
|
continue // malformed length is rejection enough
|
|
}
|
|
valid := checksumValid(got)
|
|
require.False(t, valid,
|
|
"corrupting position %d produced an address with a valid checksum", i)
|
|
}
|
|
require.True(t, checksumValid(decoded), "the untouched address must verify")
|
|
}
|
|
|
|
// TestXRPRefusesWrongCurveAndWrongLength covers the inputs that must never
|
|
// yield a string: wrong curve, wrong length, and the 32-byte x-only form whose
|
|
// missing parity cannot be guessed.
|
|
func TestXRPRefusesWrongCurveAndWrongLength(t *testing.T) {
|
|
edPub, _, err := ed25519.GenerateKey(rand.Reader)
|
|
require.NoError(t, err)
|
|
|
|
priv, err := secp256k1.GeneratePrivateKey()
|
|
require.NoError(t, err)
|
|
compressed := priv.PubKey().SerializeCompressed()
|
|
|
|
// 33 bytes led by 0x02 but not a point: passes every shape check and must
|
|
// still be refused, which only on-curve parsing can do.
|
|
notOnCurve := make([]byte, 33)
|
|
notOnCurve[0] = 0x02
|
|
for i := 1; i < 33; i++ {
|
|
notOnCurve[i] = 0xFF
|
|
}
|
|
|
|
for name, key := range map[string][]byte{
|
|
"nil": nil,
|
|
"empty": {},
|
|
"ed25519_32_bytes": edPub,
|
|
"ed25519_with_0xED_prefix": append([]byte{0xED}, edPub...),
|
|
"x_only_32_bytes": compressed[1:],
|
|
"truncated_compressed": compressed[:32],
|
|
"compressed_plus_one_byte": append(append([]byte{}, compressed...), 0x00),
|
|
"bad_leading_byte": append([]byte{0x05}, compressed[1:]...),
|
|
"uncompressed_wrong_leader": append([]byte{0x02}, make([]byte, 64)...),
|
|
"on_shape_off_curve": notOnCurve,
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
addr, err := XRP(key)
|
|
require.ErrorIs(t, err, ErrNotSecp256k1)
|
|
require.Empty(t, addr, "an error must never be accompanied by an address")
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRequireSecp256k1CanonicalisesEncoding pins the contract the address
|
|
// derivation leans on: whatever encoding comes in, the compressed form comes
|
|
// out, so the hash input cannot vary with the caller.
|
|
func TestRequireSecp256k1CanonicalisesEncoding(t *testing.T) {
|
|
for i := 0; i < 64; i++ {
|
|
priv, err := secp256k1.GeneratePrivateKey()
|
|
require.NoError(t, err)
|
|
want := priv.PubKey().SerializeCompressed()
|
|
|
|
fromCompressed, err := RequireSecp256k1(want)
|
|
require.NoError(t, err)
|
|
require.Equal(t, want, fromCompressed)
|
|
|
|
fromUncompressed, err := RequireSecp256k1(priv.PubKey().SerializeUncompressed())
|
|
require.NoError(t, err)
|
|
require.Equal(t, want, fromUncompressed)
|
|
require.Len(t, fromUncompressed, 33)
|
|
require.Contains(t, []byte{0x02, 0x03}, fromUncompressed[0])
|
|
}
|
|
}
|
|
|
|
// checksumValid recomputes XRPL's checksum over an already-prefixed payload,
|
|
// independently of the encoder, so a bug in the encoder cannot make the
|
|
// corruption test pass by agreeing with itself.
|
|
func checksumValid(decoded []byte) bool {
|
|
if len(decoded) != 25 {
|
|
return false
|
|
}
|
|
first := sha256.Sum256(decoded[:21])
|
|
second := sha256.Sum256(first[:])
|
|
return bytes.Equal(second[:4], decoded[21:])
|
|
}
|