mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
TON and XRP deposit addresses, derived the way the spender derives them
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.
This commit is contained in:
@@ -830,6 +830,12 @@ func runNodeConsensus(ctx context.Context, c *cli.Command) error {
|
||||
if solAddr := eddsaPubKeyToSolAddress(result.EDDSAPubKey); solAddr != "" {
|
||||
resp["sol_address"] = solAddr
|
||||
}
|
||||
if tonAddr := eddsaPubKeyToTonAddress(result.EDDSAPubKey); tonAddr != "" {
|
||||
resp["ton_address"] = tonAddr
|
||||
}
|
||||
if xrpAddr := ecdsaPubKeyToXrpAddress(result.ECDSAPubKey); xrpAddr != "" {
|
||||
resp["xrp_address"] = xrpAddr
|
||||
}
|
||||
} else {
|
||||
resp["error"] = result.ErrorReason
|
||||
resp["error_code"] = result.ErrorCode
|
||||
@@ -1886,6 +1892,56 @@ func eddsaPubKeyToSolAddress(pubKey []byte) string {
|
||||
return addr
|
||||
}
|
||||
|
||||
// eddsaPubKeyToTonAddress derives the v4R2 TON wallet address from the same
|
||||
// Ed25519 key Solana uses, or returns "" when the bytes are not one.
|
||||
//
|
||||
// TON and Solana share the key but not the address: a TON address is the hash
|
||||
// of a wallet contract's StateInit, not an encoding of the key. Serving the
|
||||
// Solana base58 string under a TON label is a bug this repo has already shipped
|
||||
// once, so the two are derived by two functions from one gate and never
|
||||
// substituted for one another.
|
||||
func eddsaPubKeyToTonAddress(pubKey []byte) string {
|
||||
if len(pubKey) == 0 {
|
||||
return ""
|
||||
}
|
||||
addr, err := address.TON(pubKey)
|
||||
if err != nil {
|
||||
logger.Warn("Refusing to derive a TON address from a non-Ed25519 key",
|
||||
"error", err.Error(),
|
||||
"consequence", "ton_address omitted from the response",
|
||||
)
|
||||
return ""
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
// ecdsaPubKeyToXrpAddress derives the XRP Ledger classic address from the
|
||||
// secp256k1 key, or returns "" when the bytes are not a point on that curve.
|
||||
//
|
||||
// Unlike pubKeyToBtcAddress, this refuses a bare 32-byte x-coordinate instead
|
||||
// of assuming even-y. Assuming is how you publish the sibling point's address
|
||||
// for half of all keys.
|
||||
//
|
||||
// The address is emitted because we must be able to prove we control it, not
|
||||
// because a fresh XRPL address should be minted per payer: XRPL's ~10 XRP base
|
||||
// reserve is non-refundable, so one address per payer strands the reserve every
|
||||
// time. Deposits belong at a single pooled address distinguished by
|
||||
// DestinationTag, which is the payment layer's concern, not keygen's.
|
||||
func ecdsaPubKeyToXrpAddress(pubKey []byte) string {
|
||||
if len(pubKey) == 0 {
|
||||
return ""
|
||||
}
|
||||
addr, err := address.XRP(pubKey)
|
||||
if err != nil {
|
||||
logger.Warn("Refusing to derive an XRP address from a non-secp256k1 key",
|
||||
"error", err.Error(),
|
||||
"consequence", "xrp_address omitted from the response",
|
||||
)
|
||||
return ""
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
// isThresholdLoopback reports whether `--threshold-listen` resolves to
|
||||
// a loopback host. Bare `:port` (= 0.0.0.0:port) is NOT loopback —
|
||||
// operators must spell out the bind host. See the dispatcher startup
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||
"github.com/luxfi/threshold/pkg/math/curve"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/mpc/pkg/types"
|
||||
)
|
||||
|
||||
// These tests sit at the daemon's own boundary, like sol_address_test.go:
|
||||
// eddsaPubKeyToTonAddress and ecdsaPubKeyToXrpAddress are the functions whose
|
||||
// return values become the ton_address and xrp_address fields of a /keygen
|
||||
// response — the strings a customer is told to deposit into.
|
||||
//
|
||||
// The vectors are the published ones pinned in pkg/address: a live TON mainnet
|
||||
// v4R2 account, and XRPLF/xrpl.js's own secp256k1 fixture. They are repeated
|
||||
// here rather than imported so this layer is checked end to end against
|
||||
// published truth, not against the layer below it.
|
||||
|
||||
const (
|
||||
// TON mainnet account 0:e7045e09…38cf, code_hash feb5ff68… (wallet v4R2),
|
||||
// get_public_key → 0x5754865e…c522. Same account @ton/ton asserts in
|
||||
// src/wallets/v4/WalletContractV4.spec.ts.
|
||||
tonVectorPubKey = "5754865e86d0ade1199301bbb0319a25ed6b129c4b0a57f28f62449b3df9c522"
|
||||
// Non-bounceable rendering, which is what a deposit address must be.
|
||||
tonVectorAddress = "UQDnBF4JTFKHTYjulEJyNd4dstLGH1m51UrLdu01_tw4zz3r"
|
||||
|
||||
// XRPLF/xrpl.js packages/ripple-keypairs/test/fixtures/api.json → .secp256k1
|
||||
xrpVectorPubKey = "030D58EB48B4420B1F7B9DF55087E0E29FEF0E8468F9A6825B01CA2C361042D435"
|
||||
xrpVectorAddress = "rU6K7V3Po4snVhBBaU29sesqs2qTQJWDw1"
|
||||
)
|
||||
|
||||
func TestTonAddressMatchesPublishedMainnetVector(t *testing.T) {
|
||||
pub, err := hex.DecodeString(tonVectorPubKey)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tonVectorAddress, eddsaPubKeyToTonAddress(pub))
|
||||
}
|
||||
|
||||
func TestXrpAddressMatchesPublishedVector(t *testing.T) {
|
||||
pub, err := hex.DecodeString(xrpVectorPubKey)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, xrpVectorAddress, ecdsaPubKeyToXrpAddress(pub))
|
||||
}
|
||||
|
||||
// TestTonAddressRefusesSecp256k1Keys is the same regression guard
|
||||
// sol_address_test.go applies, for the same reason: TON spends with an Ed25519
|
||||
// signature, so an x-only secp256k1 key rendered as a TON address would be
|
||||
// fundable and permanently unspendable.
|
||||
func TestTonAddressRefusesSecp256k1Keys(t *testing.T) {
|
||||
tested := 0
|
||||
for attempt := 0; attempt < 512 && tested < 32; attempt++ {
|
||||
candidate := make([]byte, 32)
|
||||
_, err := rand.Read(candidate)
|
||||
require.NoError(t, err)
|
||||
|
||||
if _, err := (curve.Secp256k1{}).LiftX(candidate); err != nil {
|
||||
continue
|
||||
}
|
||||
// Skip the ~1-in-16 that are also valid Ed25519 keys; those are
|
||||
// indistinguishable by inspection, which is why provenance and not
|
||||
// validation is the primary defence upstream.
|
||||
if err := (curve.Ed25519{}).NewPoint().UnmarshalBinary(candidate); err == nil {
|
||||
continue
|
||||
}
|
||||
tested++
|
||||
require.Empty(t, eddsaPubKeyToTonAddress(candidate),
|
||||
"a secp256k1 x-only key must never produce a TON address")
|
||||
}
|
||||
require.Positive(t, tested, "no secp256k1 x-only candidates were generated")
|
||||
}
|
||||
|
||||
// TestXrpAddressRefusesWrongCurveAndAmbiguousKeys covers the inputs that must
|
||||
// produce no field at all. The 32-byte case matters most: it is a real
|
||||
// secp256k1 x-coordinate with the y parity missing, and completing it by
|
||||
// assuming even-y — which pubKeyToBtcAddress does — names the sibling point's
|
||||
// address for half of all keys.
|
||||
func TestXrpAddressRefusesWrongCurveAndAmbiguousKeys(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()
|
||||
|
||||
for name, key := range map[string][]byte{
|
||||
"empty": {},
|
||||
"ed25519_key": edPub,
|
||||
"x_only_missing_parity": compressed[1:],
|
||||
"truncated": compressed[:32],
|
||||
"trailing_byte": append(append([]byte{}, compressed...), 0x00),
|
||||
"ed25519_with_0xED_prefix": append([]byte{0xED}, edPub...),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
require.Empty(t, ecdsaPubKeyToXrpAddress(key),
|
||||
"a key this function cannot verify must yield no xrp_address")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddressCurveMatchesSigningCurve binds the two halves that have to agree.
|
||||
// pkg/types.networkKeyType decides which curve a network's signatures come
|
||||
// from; the keygen response decides which key an address is derived from. If
|
||||
// those ever disagree the daemon publishes an address it cannot sign for, so
|
||||
// the agreement is asserted rather than assumed.
|
||||
func TestAddressCurveMatchesSigningCurve(t *testing.T) {
|
||||
edPub, _, err := ed25519.GenerateKey(rand.Reader)
|
||||
require.NoError(t, err)
|
||||
priv, err := secp256k1.GeneratePrivateKey()
|
||||
require.NoError(t, err)
|
||||
secpPub := priv.PubKey().SerializeCompressed()
|
||||
|
||||
// "ton" and "xrp" are the names a /sign caller actually sends, so resolve
|
||||
// them the way that path does rather than assuming the canonical code.
|
||||
tonNet, err := types.ParseNetwork("ton")
|
||||
require.NoError(t, err, "a network we publish an address for must be signable")
|
||||
tonKeyType, err := types.KeyTypeForNetwork(tonNet)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, types.KeyTypeEd25519, tonKeyType,
|
||||
"TON signs with Ed25519, so ton_address must come from the Ed25519 key")
|
||||
require.NotEmpty(t, eddsaPubKeyToTonAddress(edPub))
|
||||
require.Empty(t, eddsaPubKeyToTonAddress(secpPub))
|
||||
|
||||
xrpNet, err := types.ParseNetwork("xrp")
|
||||
require.NoError(t, err, "a network we publish an address for must be signable")
|
||||
xrpKeyType, err := types.KeyTypeForNetwork(xrpNet)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, types.KeyTypeSecp256k1, xrpKeyType,
|
||||
"XRPL signs with secp256k1, so xrp_address must come from the secp256k1 key")
|
||||
require.NotEmpty(t, ecdsaPubKeyToXrpAddress(secpPub))
|
||||
require.Empty(t, ecdsaPubKeyToXrpAddress(edPub))
|
||||
}
|
||||
@@ -50,6 +50,7 @@ require (
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/urfave/cli/v3 v3.8.0
|
||||
github.com/xssnick/tonutils-go v1.17.2
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/term v0.45.0
|
||||
google.golang.org/api v0.275.0
|
||||
@@ -210,6 +211,7 @@ require (
|
||||
github.com/nexus-rpc/sdk-go v0.6.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.27 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
|
||||
@@ -1965,6 +1965,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/xssnick/tonutils-go v1.17.2 h1:WMBjyqJsgnZuc3UU/shxia47DQLjK+3oJtK0Xx2qgIg=
|
||||
github.com/xssnick/tonutils-go v1.17.2/go.mod h1:Ind3q0JM2N/63zfWR7rSGmUpUZaiGxregm9nZc/xGx4=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
|
||||
+7
-10
@@ -11,16 +11,13 @@
|
||||
// string when it cannot. Callers must omit the address when they get an error:
|
||||
// no address is a recoverable state, a wrong address is not.
|
||||
//
|
||||
// TON is deliberately absent. A TON wallet holds the same Ed25519 key this
|
||||
// package validates, so the cryptography is not the gap — the address encoding
|
||||
// is. A TON address is workchain ‖ SHA-256 of the StateInit cell of a specific
|
||||
// wallet contract, rendered base64url with a CRC16, and it depends on which
|
||||
// wallet version is deployed (v4r2 and W5 give different addresses for the same
|
||||
// key). Producing it requires the TVM cell representation-hash construction and
|
||||
// the exact contract code cell. Guessing any of that yields a fundable,
|
||||
// unspendable address, which is the failure this package exists to prevent, so
|
||||
// TON addresses are not emitted until that encoding is implemented against
|
||||
// published test vectors.
|
||||
// The chains are one file each: Solana here, TON in ton.go, XRP in xrp.go.
|
||||
// Each file states which curve it gates on and, where the address is not a
|
||||
// direct encoding of the key, exactly which parameters were pinned to produce
|
||||
// it — because those parameters are as address-determining as the key. TON in
|
||||
// particular is not an encoding of a public key at all but the hash of a
|
||||
// specific wallet contract's StateInit, so its file names the contract version
|
||||
// it committed to and why; see ton.go.
|
||||
package address
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package address
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"fmt"
|
||||
|
||||
tonwallet "github.com/xssnick/tonutils-go/ton/wallet"
|
||||
)
|
||||
|
||||
// The TON address of an Ed25519 key is not an encoding of that key. It is the
|
||||
// address of a wallet *contract* that happens to hold the key:
|
||||
//
|
||||
// account_id = SHA-256(StateInit{code, data}) where data embeds the pubkey
|
||||
// address = workchain ‖ account_id
|
||||
//
|
||||
// Because the code cell is part of the hash, the same key lands on a different
|
||||
// address under every wallet version. v3R2, v4R2 and W5 are three different
|
||||
// addresses for one key. "The TON address of this key" is therefore not a
|
||||
// well-formed question; "the v4R2 address of this key" is.
|
||||
//
|
||||
// The three parameters below are that answer, and they are constants rather
|
||||
// than arguments on purpose: a per-call wallet version would let one caller
|
||||
// hand out a deposit address that a different caller cannot spend from.
|
||||
const (
|
||||
// TONWalletVersion is wallet v4R2.
|
||||
//
|
||||
// Chosen because it is what the rest of the estate already signs with:
|
||||
// luxfi/bridge's TON assembler builds its state_init with
|
||||
// tonwallet.GetStateInit(pub, tonwallet.V4R2, DefaultSubwallet) and derives
|
||||
// the source address with the identical call this file makes. If keygen
|
||||
// published a v3R2 or W5 address instead, the deposit would land at an
|
||||
// address whose contract the only code we have that can spend TON does not
|
||||
// know how to deploy — fundable, unspendable, which is the exact failure
|
||||
// this package exists to prevent. v4R2 is also the flavour the overwhelming
|
||||
// majority of TON wallets and exchanges deploy, so it is the well-trodden
|
||||
// path for the sender as well.
|
||||
//
|
||||
// The contract is NOT deployed when the address is handed out. That is
|
||||
// normal and safe on TON: an incoming transfer credits an uninitialised
|
||||
// account, and the wallet contract is deployed later by attaching this same
|
||||
// StateInit to the first outgoing message. It only works because the
|
||||
// StateInit we would deploy is byte-identical to the one hashed here, which
|
||||
// is why the version is pinned rather than chosen per call.
|
||||
TONWalletVersion = tonwallet.V4R2
|
||||
|
||||
// TONSubwalletID is the v4R2 sub-wallet id, which is hashed into the data
|
||||
// cell and so is as address-determining as the public key itself. 698983191
|
||||
// is the ecosystem default (tonutils-go's DefaultSubwallet, @ton/ton's
|
||||
// 698983191 + workchain) and is what luxfi/bridge signs with.
|
||||
TONSubwalletID uint32 = tonwallet.DefaultSubwallet
|
||||
|
||||
// TONWorkchain is 0, the basechain, where ordinary wallets live.
|
||||
// Workchain -1 is the masterchain and is for validators, not deposits.
|
||||
TONWorkchain int8 = 0
|
||||
)
|
||||
|
||||
// TON derives the v4R2 TON wallet address for an Ed25519 public key.
|
||||
//
|
||||
// The key is checked against its curve first, by the same RequireEd25519 gate
|
||||
// Solana uses, for the same reason: a 32-byte string that is not an
|
||||
// edwards25519 prime-order point still hashes into a perfectly well-formed
|
||||
// address, and that address would accept deposits no signature can ever move.
|
||||
//
|
||||
// The returned string is the user-friendly base64url form in its
|
||||
// NON-bounceable rendering ("UQ…"). That choice is deliberate. A bounceable
|
||||
// ("EQ…") message sent to an account whose contract is not yet deployed is
|
||||
// returned to the sender, and a deposit address is by definition undeployed
|
||||
// until we first spend from it — so publishing the bounceable form would make
|
||||
// a customer's first deposit bounce. The non-bounceable form credits the
|
||||
// uninitialised account and waits, which is what a deposit address must do.
|
||||
// Both forms carry the identical account_id; only the sender-facing hint
|
||||
// differs. luxfi/bridge makes the same choice for the same reason.
|
||||
func TON(pub []byte) (string, error) {
|
||||
if err := RequireEd25519(pub); err != nil {
|
||||
return "", err
|
||||
}
|
||||
addr, err := tonwallet.AddressFromPubKey(ed25519.PublicKey(pub), TONWalletVersion, TONSubwalletID, TONWorkchain)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("derive TON v4R2 address: %w", err)
|
||||
}
|
||||
return addr.Bounce(false).String(), nil
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package address
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
tonwallet "github.com/xssnick/tonutils-go/ton/wallet"
|
||||
"github.com/xssnick/tonutils-go/tvm/cell"
|
||||
)
|
||||
|
||||
// Published TON vectors, taken from TON mainnet itself.
|
||||
//
|
||||
// A library's fixture proves only that the library still agrees with the
|
||||
// library. What has to be true here is that the address we hand a customer is
|
||||
// the address of a v4R2 wallet contract holding our key, and the authority on
|
||||
// that is the chain. Each vector below is a live mainnet account, recorded by
|
||||
// asking a public node three questions:
|
||||
//
|
||||
// GET /api/v3/accountStates?address=<addr> → code_hash, data_boc
|
||||
// POST /api/v2/runGetMethod get_public_key → the key the contract holds
|
||||
//
|
||||
// keeping only accounts whose code_hash is the v4R2 wallet code hash
|
||||
// feb5ff6820e2ff0d9483e7e0d62c817d846789fb4ae580c878866d959dabd5c0. So for
|
||||
// every pair below the chain asserts: this address runs wallet v4R2, and that
|
||||
// contract's public key is this one. Our derivation has to map the key back to
|
||||
// the address, and any drift in the code cell, the sub-wallet id, the data
|
||||
// layout or the cell hashing moves the result off all four at once.
|
||||
//
|
||||
// Reproduce any row with:
|
||||
//
|
||||
// curl -s https://toncenter.com/api/v3/accountStates?address=<raw>
|
||||
// curl -s https://toncenter.com/api/v2/runGetMethod -H 'Content-Type: application/json' \
|
||||
// -d '{"address":"<raw>","method":"get_public_key","stack":[]}'
|
||||
//
|
||||
// The first row is additionally the address that @ton/ton (the independent
|
||||
// TypeScript implementation) asserts in src/wallets/v4/WalletContractV4.spec.ts
|
||||
// — "should has balance and correct address" — so that row is corroborated by
|
||||
// a second implementation as well as by the chain.
|
||||
var tonMainnetV4R2Vectors = []struct {
|
||||
name string
|
||||
pubKey string // hex, from get_public_key
|
||||
rawAddress string // "<workchain>:<account_id hex>", from accountStates
|
||||
bounceable string // EQ… form, "" when not separately published
|
||||
}{
|
||||
{
|
||||
name: "ton_org_WalletContractV4_spec",
|
||||
pubKey: "5754865e86d0ade1199301bbb0319a25ed6b129c4b0a57f28f62449b3df9c522",
|
||||
rawAddress: "0:e7045e094c52874d88ee94427235de1db2d2c61f59b9d54acb76ed35fedc38cf",
|
||||
bounceable: "EQDnBF4JTFKHTYjulEJyNd4dstLGH1m51UrLdu01_tw4z2Au",
|
||||
},
|
||||
{
|
||||
name: "mainnet_v4r2_a",
|
||||
pubKey: "25bf0159c7aefdde0a21522fbd3c4b0a29d45a56322596301abfff77907f710d",
|
||||
rawAddress: "0:46635cbdd634c45992509a7be7f1341e8744a01b4bfa5336ee7d62ebd342d6cc",
|
||||
},
|
||||
{
|
||||
name: "mainnet_v4r2_b",
|
||||
pubKey: "9943a1ccd841ce8c37cfd5ee5f1bc699a7f1aa7e77c4bee99fd3923d333f40e6",
|
||||
rawAddress: "0:3b105f243cd0e8fe8caadc4984411a7d713bbc1b71830783f03e24aea844176d",
|
||||
},
|
||||
{
|
||||
name: "mainnet_v4r2_c",
|
||||
pubKey: "26fb32300f35979e12627ac42846dadc92a4d00db551d76d7c2710f5ec0569bc",
|
||||
rawAddress: "0:f750b1869136f8c2c003675bf0fce90335185d2faab3bb89e913995e68729aaf",
|
||||
},
|
||||
}
|
||||
|
||||
// tonV4R2CodeHash is the representation hash of the wallet v4R2 code cell, as
|
||||
// reported by mainnet for every account above (accountStates.code_hash, base64
|
||||
// "/rX/aCDi/w2Ug+fg1iyBfYRniftK5YDIeIZtlZ2r1cA=").
|
||||
const tonV4R2CodeHash = "feb5ff6820e2ff0d9483e7e0d62c817d846789fb4ae580c878866d959dabd5c0"
|
||||
|
||||
// TestTONMatchesMainnetV4R2Accounts is the vector test. It goes through the
|
||||
// exported TON function, so the curve gate is exercised on real keys too: a
|
||||
// gate that rejected genuine wallet keys would fail here rather than in
|
||||
// production.
|
||||
func TestTONMatchesMainnetV4R2Accounts(t *testing.T) {
|
||||
for _, v := range tonMainnetV4R2Vectors {
|
||||
t.Run(v.name, func(t *testing.T) {
|
||||
pub, err := hex.DecodeString(v.pubKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
addr, err := TON(pub)
|
||||
require.NoError(t, err, "a real mainnet wallet key must yield an address")
|
||||
|
||||
// TON returns the non-bounceable rendering; compare on the
|
||||
// account_id, which is what actually identifies the account and is
|
||||
// identical across renderings.
|
||||
parsed, err := parseTONFriendly(addr)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, v.rawAddress, parsed,
|
||||
"derived account differs from the v4R2 contract mainnet reports for this key")
|
||||
|
||||
if v.bounceable != "" {
|
||||
alt, err := parseTONFriendly(v.bounceable)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, v.rawAddress, alt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTONCodeCellIsTheMainnetV4R2Code checks the input we cannot see in the
|
||||
// address: the contract code hashed into it. If tonutils-go ever re-tags a
|
||||
// different cell as V4R2, every address this package produces silently moves,
|
||||
// and every already-issued deposit address becomes undeployable. This pins the
|
||||
// code cell to the hash mainnet reports for the accounts above.
|
||||
func TestTONCodeCellIsTheMainnetV4R2Code(t *testing.T) {
|
||||
stateInit, err := tonwallet.GetStateInit(make(ed25519.PublicKey, 32), TONWalletVersion, TONSubwalletID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, stateInit.Code)
|
||||
require.Equal(t, tonV4R2CodeHash, hex.EncodeToString(stateInit.Code.Hash()),
|
||||
"the wallet code being hashed is not mainnet's v4R2 code")
|
||||
}
|
||||
|
||||
// TestTONStateInitDataLayoutMatchesMainnet checks the other hashed input: the
|
||||
// data cell. Mainnet's data_boc for the first vector decodes to
|
||||
// seqno ‖ subwallet_id ‖ pubkey, and the sub-wallet id in particular is a
|
||||
// silent address-mover — nothing about a wrong one looks wrong.
|
||||
func TestTONStateInitDataLayoutMatchesMainnet(t *testing.T) {
|
||||
// accountStates.data_boc for 0:e7045e09…38cf, truncated to the fields the
|
||||
// address depends on. seqno differs from 0 because the wallet has sent
|
||||
// transactions; the address was fixed at seqno 0, which is what
|
||||
// GetStateInit builds.
|
||||
const mainnetDataBOC = "te6ccgEBAgEAUQABUQAAAFQpqaMXV1SGXobQreEZkwG7sDGaJe1rEpxLClfyj2JEmz35xSLAAQBFoQAI+lAOsPuAY5Dfuu2no15Sv+OhWcwEHuHJ2z2e09ajB1A="
|
||||
|
||||
raw, err := base64.StdEncoding.DecodeString(mainnetDataBOC)
|
||||
require.NoError(t, err)
|
||||
c, err := cell.FromBOC(raw)
|
||||
require.NoError(t, err)
|
||||
|
||||
s, err := c.BeginParse()
|
||||
require.NoError(t, err)
|
||||
_, err = s.LoadUInt(32) // seqno, now non-zero on chain
|
||||
require.NoError(t, err)
|
||||
subwallet, err := s.LoadUInt(32)
|
||||
require.NoError(t, err)
|
||||
pub, err := s.LoadSlice(256)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, uint64(TONSubwalletID), subwallet,
|
||||
"mainnet's v4R2 wallet uses a different sub-wallet id than we hash")
|
||||
require.Equal(t, tonMainnetV4R2Vectors[0].pubKey, hex.EncodeToString(pub),
|
||||
"data cell layout is not seqno‖subwallet‖pubkey")
|
||||
|
||||
// And the parameters we pin are the ones the constants claim.
|
||||
require.Equal(t, uint32(698983191), TONSubwalletID)
|
||||
require.Equal(t, int8(0), TONWorkchain)
|
||||
require.Equal(t, tonwallet.V4R2, TONWalletVersion)
|
||||
}
|
||||
|
||||
// TestTONEmitsNonBounceableMainnetForm pins the rendering. UQ vs EQ decides
|
||||
// whether a deposit to a not-yet-deployed wallet lands or is returned to the
|
||||
// sender, and the flag byte is the only difference between the two strings.
|
||||
func TestTONEmitsNonBounceableMainnetForm(t *testing.T) {
|
||||
for i := 0; i < 32; i++ {
|
||||
pub, _, err := ed25519.GenerateKey(rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
addr, err := TON(pub)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, strings.HasPrefix(addr, "UQ"),
|
||||
"deposit addresses must be non-bounceable mainnet (UQ…), got %q", addr)
|
||||
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(addr)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, decoded, 36, "1 flag + 1 workchain + 32 account id + 2 crc")
|
||||
require.Equal(t, byte(0x51), decoded[0], "0x51 = non-bounceable, mainnet")
|
||||
require.Equal(t, byte(0x00), decoded[1], "basechain")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTONRefusesTheSameKeysSolanaRefuses keeps the two chains on one gate. Both
|
||||
// spend with an Ed25519 signature, so a key too weak to be a Solana address is
|
||||
// exactly as unspendable as a TON wallet.
|
||||
func TestTONRefusesTheSameKeysSolanaRefuses(t *testing.T) {
|
||||
pub, _, err := ed25519.GenerateKey(rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
bad := map[string][]byte{
|
||||
"nil": nil,
|
||||
"empty": {},
|
||||
"truncated": pub[:31],
|
||||
"too_long": append([]byte(pub), 0x00),
|
||||
"secp256k1_compressed_33_bytes": append([]byte{0x02}, pub...),
|
||||
"identity": mustHex(t, "0100000000000000000000000000000000000000000000000000000000000000"),
|
||||
"order_2": mustHex(t, "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"),
|
||||
"order_4_a": mustHex(t, "0000000000000000000000000000000000000000000000000000000000000080"),
|
||||
"order_4_b": mustHex(t, "0000000000000000000000000000000000000000000000000000000000000000"),
|
||||
"order_8_mix1": mustHex(t, "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05"),
|
||||
"order_8_mix2": mustHex(t, "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a"),
|
||||
}
|
||||
|
||||
for name, key := range bad {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
addr, err := TON(key)
|
||||
require.ErrorIs(t, err, ErrNotEd25519)
|
||||
require.Empty(t, addr, "an error must never be accompanied by an address")
|
||||
|
||||
// Same verdict as Solana, since it is the same key and the same
|
||||
// signature scheme behind both.
|
||||
_, solErr := Solana(key)
|
||||
require.Error(t, solErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTONIsDistinctFromSolana guards the bug this work replaced: a base58
|
||||
// Solana string was once served as a TON address. The two encodings of one key
|
||||
// must never be confusable.
|
||||
func TestTONIsDistinctFromSolana(t *testing.T) {
|
||||
for i := 0; i < 32; i++ {
|
||||
pub, _, err := ed25519.GenerateKey(rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
ton, err := TON(pub)
|
||||
require.NoError(t, err)
|
||||
sol, err := Solana(pub)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotEqual(t, sol, ton)
|
||||
require.False(t, strings.HasPrefix(ton, sol[:8]))
|
||||
|
||||
// A TON address is a contract hash, so it must not contain the key.
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(ton)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, []byte(pub), decoded[2:34],
|
||||
"the account id must be the StateInit hash, never the raw key")
|
||||
}
|
||||
}
|
||||
|
||||
// parseTONFriendly turns a user-friendly TON address back into its canonical
|
||||
// "<workchain>:<account_id>" form and verifies the CRC16 while doing it.
|
||||
func parseTONFriendly(s string) (string, error) {
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(decoded) != 36 {
|
||||
return "", errBadTONLength
|
||||
}
|
||||
got := binary.BigEndian.Uint16(decoded[34:])
|
||||
if got != crc16XMODEM(decoded[:34]) {
|
||||
return "", errBadTONChecksum
|
||||
}
|
||||
return strings.ToLower(
|
||||
itoa(int8(decoded[1])) + ":" + hex.EncodeToString(decoded[2:34]),
|
||||
), nil
|
||||
}
|
||||
|
||||
var (
|
||||
errBadTONLength = errTON("ton: address must decode to 36 bytes")
|
||||
errBadTONChecksum = errTON("ton: crc16 mismatch")
|
||||
)
|
||||
|
||||
type errTON string
|
||||
|
||||
func (e errTON) Error() string { return string(e) }
|
||||
|
||||
func itoa(v int8) string {
|
||||
if v < 0 {
|
||||
return "-" + itoa(-v)
|
||||
}
|
||||
if v < 10 {
|
||||
return string(rune('0' + v))
|
||||
}
|
||||
return itoa(v/10) + string(rune('0'+v%10))
|
||||
}
|
||||
|
||||
// crc16XMODEM is the checksum TON puts on user-friendly addresses. Written out
|
||||
// here rather than imported so the test verifies the address independently of
|
||||
// the library that produced it.
|
||||
func crc16XMODEM(data []byte) uint16 {
|
||||
var crc uint16
|
||||
for _, b := range data {
|
||||
crc ^= uint16(b) << 8
|
||||
for i := 0; i < 8; i++ {
|
||||
if crc&0x8000 != 0 {
|
||||
crc = crc<<1 ^ 0x1021
|
||||
} else {
|
||||
crc <<= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return crc
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package address
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||
"github.com/mr-tron/base58"
|
||||
"golang.org/x/crypto/ripemd160" //nolint:staticcheck // XRPL and Bitcoin both specify RIPEMD160; there is no substitute.
|
||||
)
|
||||
|
||||
// ErrNotSecp256k1 reports that bytes offered as a secp256k1 public key are not
|
||||
// one. As with ErrNotEd25519, the only correct response is to emit no address.
|
||||
var ErrNotSecp256k1 = errors.New("not a secp256k1 public key")
|
||||
|
||||
// xrplAlphabet is the XRPL base58 dictionary. It is NOT the Bitcoin dictionary:
|
||||
// the two alphabets contain the same 58 characters in a different order, so
|
||||
// encoding an XRPL payload with the Bitcoin alphabet yields a string that is
|
||||
// still 34 characters, still starts with 'r' about as often, and still passes a
|
||||
// regex — but names a different account. Nothing about the shape of the output
|
||||
// reveals the mistake, which is why the encoder is pinned to published test
|
||||
// vectors rather than eyeballed.
|
||||
var xrplAlphabet = base58.NewAlphabet("rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz")
|
||||
|
||||
// xrplAccountIDPrefix is the type prefix XRPL puts in front of an AccountID
|
||||
// before base58 encoding. XRPL base58-encodes several different kinds of key
|
||||
// with the same alphabet and distinguishes them by this byte; 0x00 is what
|
||||
// makes an address render as "r…".
|
||||
const xrplAccountIDPrefix = 0x00
|
||||
|
||||
// XRP derives the XRP Ledger classic address ("r…") for a secp256k1 public key.
|
||||
//
|
||||
// Deposit model, which this function does NOT implement and must not be read as
|
||||
// implementing: XRPL charges a base reserve (10 XRP at time of writing) per
|
||||
// funded account, and that reserve is not refundable. Handing every payer their
|
||||
// own r-address therefore strands 10 XRP per payer, forever. The production
|
||||
// model is a single pooled r-address plus a per-deposit DestinationTag, and the
|
||||
// tag allocation belongs to whoever is taking the payments, not here. This
|
||||
// function exists so the pooled address is one we provably control and can
|
||||
// sign for — not so a fresh address can be minted per payer.
|
||||
//
|
||||
// Only secp256k1 is accepted. XRPL also supports Ed25519 account keys (wire
|
||||
// prefix 0xED), and this wallet does hold an Ed25519 key, but the XRPL signing
|
||||
// path in luxfi/bridge emits ECDSA signatures only. An Ed25519 XRPL address
|
||||
// would be an address we can fund and cannot spend from.
|
||||
func XRP(pub []byte) (string, error) {
|
||||
compressed, err := RequireSecp256k1(pub)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return xrplClassicAddress(compressed), nil
|
||||
}
|
||||
|
||||
// RequireSecp256k1 checks that pub is a usable secp256k1 public key and returns
|
||||
// it in the 33-byte compressed encoding XRPL hashes.
|
||||
//
|
||||
// It returns the canonical bytes rather than only an error because the caller
|
||||
// must hash the compressed form specifically: the same point has a 65-byte
|
||||
// uncompressed encoding that hashes to a completely different, wrong address.
|
||||
// Having one function both validate and canonicalise leaves no way to hash a
|
||||
// non-canonical encoding by accident.
|
||||
//
|
||||
// A bare 32-byte x-coordinate is refused rather than completed. Recovering the
|
||||
// point from x alone requires choosing a sign for y, and both choices are valid
|
||||
// points, so the choice is a coin flip that silently produces the sibling
|
||||
// point's address half the time. That is not a hypothetical: pubKeyToBtcAddress
|
||||
// in cmd/mpcd guesses 0x02 for a 32-byte input for exactly this reason and is
|
||||
// wrong for every odd-y key. Refusing is the only safe answer, and the MPC
|
||||
// keygen serialises the full compressed point anyway (see
|
||||
// pkg/mpc/keygen_session.go), so a 32-byte ECDSA key means something upstream
|
||||
// has already lost the parity.
|
||||
func RequireSecp256k1(pub []byte) ([]byte, error) {
|
||||
switch {
|
||||
case len(pub) == 33 && (pub[0] == 0x02 || pub[0] == 0x03):
|
||||
case len(pub) == 65 && pub[0] == 0x04:
|
||||
case len(pub) == 32:
|
||||
return nil, fmt.Errorf("%w: 32 bytes is a bare x-coordinate with no y parity, and guessing the parity picks the wrong point for half of all keys", ErrNotSecp256k1)
|
||||
default:
|
||||
lead := "none"
|
||||
if len(pub) > 0 {
|
||||
lead = fmt.Sprintf("0x%02x", pub[0])
|
||||
}
|
||||
return nil, fmt.Errorf("%w: want 33 bytes led by 0x02/0x03 or 65 bytes led by 0x04, got %d bytes led by %s", ErrNotSecp256k1, len(pub), lead)
|
||||
}
|
||||
|
||||
// Parsing is what proves the bytes are a point on secp256k1 at all. Without
|
||||
// it the encoding checks above would pass any 33 bytes beginning 0x02,
|
||||
// including an Ed25519 key with a byte stuck on the front.
|
||||
key, err := secp256k1.ParsePubKey(pub)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrNotSecp256k1, err)
|
||||
}
|
||||
return key.SerializeCompressed(), nil
|
||||
}
|
||||
|
||||
// xrplClassicAddress performs the XRPL address encoding on a 33-byte account
|
||||
// public key, per XRPL "Address Encoding":
|
||||
//
|
||||
// account_id = RIPEMD160(SHA-256(pubkey))
|
||||
// payload = 0x00 ‖ account_id
|
||||
// checksum = SHA-256(SHA-256(payload))[:4]
|
||||
// address = base58_xrpl(payload ‖ checksum)
|
||||
//
|
||||
// It is unexported and takes no view on the curve, because the encoding does
|
||||
// not: XRPL feeds a 33-byte secp256k1 key or an 0xED-prefixed Ed25519 key
|
||||
// through the identical steps. Keeping the encoder curve-blind is what lets it
|
||||
// be tested against the Ed25519 vectors XRPL publishes while the exported XRP
|
||||
// entry point still refuses everything but secp256k1.
|
||||
func xrplClassicAddress(accountPubKey []byte) string {
|
||||
inner := sha256.Sum256(accountPubKey)
|
||||
outer := ripemd160.New()
|
||||
outer.Write(inner[:])
|
||||
accountID := outer.Sum(nil)
|
||||
|
||||
payload := make([]byte, 0, 1+len(accountID)+4)
|
||||
payload = append(payload, xrplAccountIDPrefix)
|
||||
payload = append(payload, accountID...)
|
||||
|
||||
first := sha256.Sum256(payload)
|
||||
second := sha256.Sum256(first[:])
|
||||
|
||||
return base58.EncodeAlphabet(append(payload, second[:4]...), xrplAlphabet)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
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:])
|
||||
}
|
||||
+40
-13
@@ -1,15 +1,41 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/luxfi/mpc/pkg/address"
|
||||
"github.com/luxfi/mpc/pkg/db"
|
||||
)
|
||||
|
||||
// deriveFromPubKey turns a stored hex public key into an address using one of
|
||||
// pkg/address's curve-gated derivations, reporting false when there is no key,
|
||||
// the hex is unusable, or the key is not on the curve that chain needs.
|
||||
//
|
||||
// Deriving on read rather than storing a column per chain is deliberate: the
|
||||
// public keys are already persisted, the derivation is pure, and every wallet
|
||||
// minted before a chain was supported therefore gets its address with no
|
||||
// backfill and no schema change. There is still exactly one derivation per
|
||||
// chain — this only decides when to call it.
|
||||
func deriveFromPubKey(pubKeyHex *string, derive func([]byte) (string, error)) (string, bool) {
|
||||
if pubKeyHex == nil || *pubKeyHex == "" {
|
||||
return "", false
|
||||
}
|
||||
raw, err := hex.DecodeString(*pubKeyHex)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
addr, err := derive(raw)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return addr, true
|
||||
}
|
||||
|
||||
func (s *Server) handleListWallets(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
userID := getUserID(r.Context())
|
||||
@@ -136,28 +162,29 @@ func (s *Server) handleGetWalletAddresses(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
// Every value here is derived from a public key by pkg/address, never
|
||||
// copied from another chain's address. Sharing a key does not mean sharing
|
||||
// an encoding: "xrp" served the 20-byte EVM address for a while, and "ton"
|
||||
// served the Solana base58 string, and neither is an address on the chain
|
||||
// it was labelled with. A chain whose derivation fails is simply absent.
|
||||
addresses := map[string]interface{}{}
|
||||
if wallet.EVMAddress != nil {
|
||||
// "ethereum"/"lux"/"xrp" are user-facing display labels keyed by
|
||||
// chain name; the value is the 20-byte EVM-runtime account
|
||||
// address (the form consumed by every EVM-compatible chain plus
|
||||
// xrp's secp256k1-derived form). Map labels are display names,
|
||||
// not internal naming.
|
||||
// "ethereum" and "lux" are display labels for one value: the 20-byte
|
||||
// EVM-runtime account address every EVM-compatible chain consumes.
|
||||
addresses["ethereum"] = *wallet.EVMAddress
|
||||
addresses["lux"] = *wallet.EVMAddress // EVM-compatible
|
||||
addresses["xrp"] = *wallet.EVMAddress // secp256k1-derived (simplified)
|
||||
addresses["lux"] = *wallet.EVMAddress
|
||||
}
|
||||
if wallet.BtcAddress != nil {
|
||||
addresses["bitcoin"] = *wallet.BtcAddress
|
||||
}
|
||||
if wallet.SolAddress != nil {
|
||||
addresses["solana"] = *wallet.SolAddress
|
||||
// No "ton" entry. TON shares this wallet's Ed25519 key but not its
|
||||
// address format: a TON address is workchain ‖ SHA-256 of a wallet
|
||||
// contract's StateInit cell, base64url with a CRC16, and it differs
|
||||
// between contract versions. The Solana base58 string is not a TON
|
||||
// address in any encoding, so serving it here published an address that
|
||||
// can receive nothing and prove nothing. Absent until address.TON exists.
|
||||
}
|
||||
if addr, ok := deriveFromPubKey(wallet.ECDSAPubkey, address.XRP); ok {
|
||||
addresses["xrp"] = addr
|
||||
}
|
||||
if addr, ok := deriveFromPubKey(wallet.EDDSAPubkey, address.TON); ok {
|
||||
addresses["ton"] = addr
|
||||
}
|
||||
writeJSON(w, http.StatusOK, addresses)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user