mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
internal/txassembler/xrp.go: - XRPProvider interface + per-network XRPNetwork config (LastLedgerWindow, fee override, canonical-sig flag). - Assembler.PreSignXRP: pulls Sequence + suggested fee from rippled, embeds the release wallet's compressed-secp256k1 pubkey, returns the 32-byte SHA-512Half (via internal/xrpl) the MPC quorum signs. - Assembler.FinalizeXRP: parses (r, s) from the MPC's 65-byte R||S||V hex (ignores recovery byte — XRPL doesn't need ecrecover), DER-encodes with low-s canonicalization, attaches as TxnSignature, returns the wire blob (hex) + canonical XRPL tx hash. - XRPRequiredDrops: amount + fee + 10-XRP reserve, used by the gas pre-check to refuse signing when the wallet can't cover. - xrpFloatToDrops with the same string-format trick floatToWei uses, avoiding IEEE noise at 6-decimal native XRP precision. internal/mchain/client.go: - Wallet now carries ECDSAPubKeyHex (compressed-secp256k1 hex). EVM paths ignore it; XRP signing needs it on the wire. - pickAddress(XRP) derives the canonical r-address from the ECDSAPubKey via internal/xrpl.AddressFromPubKey instead of falling back to the ETH slot. Fallback retained for legacy mocks without the pubkey field. cmd/bridge/xrp_provider.go: - HTTP wrapper over rippled JSON-RPC (account_info, fee). Returns (Sequence, validated_ledger_index) and the open_ledger_fee/median fee / base_fee chain. Treats actNotFound on balance lookup as "wallet not activated → balance 0" so the gas pre-check correctly reports an unactivated release wallet. cmd/bridge/release_pool.go: - ReleasePoolEntry gains Family + PubKeyHex fields (round-trips through JSON; pre-XRP entries leave them empty). - AcquireForFamily filters entries by family — the signing driver now picks an XRP wallet for XRP destinations and an EVM wallet for EVM. Falls back to family-agnostic Acquire when no per-family match. - SizeByFamily + Reload helpers so a parallel XRP-pool bootstrap can share the same store as the EVM pool without re-indexing. cmd/bridge/signing_driver.go: - signOne dispatches on mchain.AddressTypeFor(DestinationNetwork). XRP routes to signOneXRP, which mirrors the EVM ceremony but: builds a Payment via Assembler.PreSignXRP, runs an XRP gas pre-check (balance vs amount+fee+reserve) instead of eth_getBalance, hands the SHA-512Half digest to the MPC, then Assembler.FinalizeXRP emits the signed wire blob. - xrpGasPrecheck short-circuits to SwapStatusFailedInsufficientReleaseGas with a humanized "balance=X drops, required=Y drops" reason. Probe failures DON'T block the swap (best-effort, broadcast layer still retries on transient rippled flakes). cmd/bridge/main.go: - New flags: --xrp-release-pool-size (default 5), --xrp-release-pool-mint-network (default XRP_TESTNET), --xrp-release-balance-threshold-drops (default 10 XRP). - XRPProviderClient instantiated once, shared between the assembler (for PreSignXRP) and the gas pre-check (via assembler.XRPProviderFor). - XRP pool minted on top of the existing EVM pool (via a helper-pool bound to the same store + Reload), so a single ReleasePool object serves both families. - /health surfaces pool sizes broken out by family. Tests: - xrpl: address derivation round-trip, bad-pubkey rejection, DER encoding + low-s flip, PreSign digest matches SHA512Half(STX||body), full PreSign→Finalize round-trip through the rubblelabs parser. - broadcast: tesSUCCESS happy path, terQUEUED retryable, tecINSUFFICIENT_FUNDS + tecNO_DST fatal, temBAD_AMOUNT malformed, telINSUF_FEE_P retryable, rippled high-level errors, classifyEngineResult table. - txassembler: PreSignXRP populates Sequence / Fee / LastLedger, fee floor + pin, bad-input rejection, FinalizeXRP round-trip, XRPRequiredDrops math, xrpFloatToDrops table. - mchain: XRP r-address derivation from ECDSAPubKey, legacy fallback to ETH slot, bad-hex rejection. - release pool: XRP-family Bootstrap + entry shape + PubKey roundtrip, AcquireForFamily filtering, no-match returns ErrEmptyPool. - signing driver: XRP happy path advances to broadcasting with populated DestRawTx, insufficient balance short-circuits, missing PubKeyHex rolls back with readable LastError. - xrp_provider: account_info / fee / balance stubs, actNotFound → 0, rippled error surfacing.
546 lines
20 KiB
Go
546 lines
20 KiB
Go
package txassembler
|
||
|
||
import (
|
||
"context"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"math/big"
|
||
"strings"
|
||
"sync"
|
||
|
||
"golang.org/x/crypto/sha3"
|
||
|
||
"github.com/luxfi/bridge/internal/tokens"
|
||
)
|
||
|
||
// =============================================================================
|
||
// Per-network config
|
||
// =============================================================================
|
||
|
||
// PerNetwork is the destination-chain configuration the assembler
|
||
// needs to build a valid transaction.
|
||
//
|
||
// ChainID is required (it goes into the EIP-155 v field). The other
|
||
// fields control the tx mode:
|
||
//
|
||
// - DefaultGasLimit defaults the gas allowance when the operator
|
||
// doesn't want a dynamic estimateGas call. 21000 for plain
|
||
// transfers, 100000+ for contract calls.
|
||
// - DefaultGasPriceWei pins the gas price. When set, the assembler
|
||
// skips the eth_gasPrice RPC; when zero, the Provider must
|
||
// supply one via SuggestGasPrice.
|
||
// - BridgeContract + ReleaseSelector switch the tx into
|
||
// contract-call mode: `to = BridgeContract`, `data = selector ||
|
||
// abi(recipient, amount)`. When BridgeContract is empty the
|
||
// assembler runs in pure transfer mode (`to = recipient`,
|
||
// `data = empty`).
|
||
// - NativeDecimals scales the human-readable amount into wei.
|
||
// Defaults to 18 if unset (matches ETH/LUX/BNB/etc.).
|
||
type PerNetwork struct {
|
||
ChainID *big.Int
|
||
DefaultGasLimit uint64
|
||
DefaultGasPriceWei *big.Int
|
||
BridgeContract string // 0x… address; empty ⇒ pure transfer mode
|
||
ReleaseSelector [4]byte
|
||
NativeDecimals int
|
||
}
|
||
|
||
// Provider abstracts the destination-chain RPC operations the
|
||
// assembler needs. *broadcast.Client doesn't expose these today —
|
||
// callers can wrap their own JSON-RPC client, or use the
|
||
// StaticProvider for dev / tests.
|
||
type Provider interface {
|
||
// PendingNonce returns the next nonce to use for `address` on the
|
||
// destination chain. Should query eth_getTransactionCount with
|
||
// the "pending" block tag so back-to-back txs from the same
|
||
// signer don't collide.
|
||
PendingNonce(ctx context.Context, network, address string) (uint64, error)
|
||
// SuggestGasPrice returns the chain's current gas price (wei).
|
||
// Used only when PerNetwork.DefaultGasPriceWei is unset.
|
||
SuggestGasPrice(ctx context.Context, network string) (*big.Int, error)
|
||
}
|
||
|
||
// StaticProvider returns hardcoded values. Useful for tests and for
|
||
// running against chains with predictable fees (Lux testnet, devnet).
|
||
type StaticProvider struct {
|
||
mu sync.RWMutex
|
||
Nonces map[string]uint64 // key = network|address
|
||
GasPrice map[string]*big.Int // key = network → default gas price
|
||
}
|
||
|
||
// PendingNonce returns the configured nonce (default 0) and post-
|
||
// increments so back-to-back calls produce distinct values — useful
|
||
// for tests that simulate multiple txs from the same signer.
|
||
func (s *StaticProvider) PendingNonce(_ context.Context, network, address string) (uint64, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
if s.Nonces == nil {
|
||
s.Nonces = map[string]uint64{}
|
||
}
|
||
key := network + "|" + strings.ToLower(strings.TrimPrefix(address, "0x"))
|
||
v := s.Nonces[key]
|
||
s.Nonces[key] = v + 1
|
||
return v, nil
|
||
}
|
||
|
||
func (s *StaticProvider) SuggestGasPrice(_ context.Context, network string) (*big.Int, error) {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
if g, ok := s.GasPrice[network]; ok {
|
||
return new(big.Int).Set(g), nil
|
||
}
|
||
return nil, fmt.Errorf("txassembler: no gas price configured for %s", network)
|
||
}
|
||
|
||
// =============================================================================
|
||
// Assembler
|
||
// =============================================================================
|
||
|
||
// Assembler builds destination-chain transactions for swap settlements.
|
||
// Concurrency-safe.
|
||
type Assembler struct {
|
||
Provider Provider
|
||
Networks map[string]PerNetwork // key = network internal_name
|
||
// Tokens, when set, drives ERC-20 destination handling. If the
|
||
// (DestinationNetwork, DestinationAsset) pair resolves to an
|
||
// ERC-20 entry (non-empty Contract), PreSign builds a
|
||
// `transfer(recipient, amount)` calldata against that contract
|
||
// and scales the amount by the token's Decimals. When nil, every
|
||
// PreSign falls back to native transfers / cfg.BridgeContract
|
||
// release calls — backward compat for callers that haven't
|
||
// adopted the registry yet.
|
||
Tokens *tokens.Registry
|
||
|
||
// XRP is the XRP Ledger-side surface for the PreSignXRP /
|
||
// FinalizeXRP path (see xrp.go). nil ⇒ XRP destinations are
|
||
// unsupported and the signing driver short-circuits.
|
||
XRP XRPProvider
|
||
|
||
// XRPNetworks is per-chain XRP config (last-ledger window, fee
|
||
// override). Optional — zero-value entries get sane defaults.
|
||
XRPNetworks map[string]XRPNetwork
|
||
}
|
||
|
||
// New builds an empty assembler. Populate Networks per-network.
|
||
func New(provider Provider) *Assembler {
|
||
return &Assembler{Provider: provider, Networks: map[string]PerNetwork{}}
|
||
}
|
||
|
||
// SetNetwork registers a per-chain config. Convenience wrapper.
|
||
func (a *Assembler) SetNetwork(network string, cfg PerNetwork) { a.Networks[network] = cfg }
|
||
|
||
// erc20TransferSelector is the first 4 bytes of
|
||
// keccak256("transfer(address,uint256)") — the canonical ERC-20
|
||
// selector for sending tokens. Constant per the Solidity ABI.
|
||
var erc20TransferSelector = [4]byte{0xa9, 0x05, 0x9c, 0xbb}
|
||
|
||
// Unsigned is the intermediate representation between PreSign and
|
||
// Finalize. Callers MUST pass the same instance to Finalize that
|
||
// PreSign returned — internal fields persist the canonical wire
|
||
// values used to compute the sighash, so reassembly with the
|
||
// signature produces a bit-identical raw tx.
|
||
type Unsigned struct {
|
||
Network string
|
||
ChainID *big.Int
|
||
Nonce uint64
|
||
GasPrice *big.Int
|
||
GasLimit uint64
|
||
To []byte // 20-byte address bytes
|
||
Value *big.Int
|
||
Data []byte
|
||
|
||
// SigHash is the keccak256 of the RLP-encoded
|
||
// [nonce, gasPrice, gas, to, value, data, chainID, 0, 0] —
|
||
// computed inside PreSign and stored here so the caller doesn't
|
||
// have to recompute.
|
||
SigHash [32]byte
|
||
}
|
||
|
||
// SwapIntent is the minimal swap data the assembler needs. Defined
|
||
// here (rather than importing cmd/bridge's Swap type) so the package
|
||
// stays usable in isolation.
|
||
type SwapIntent struct {
|
||
// DestinationNetwork is the internal_name of the destination
|
||
// chain (e.g. LUX_TESTNET, ETHEREUM_SEPOLIA).
|
||
DestinationNetwork string
|
||
// DestinationAsset is the token ticker on the destination chain
|
||
// (ETH, LUX, USDC, etc.). When the assembler has a Tokens registry
|
||
// attached and the asset resolves to an ERC-20, PreSign switches
|
||
// to `transfer(recipient, amount)` calldata against the token's
|
||
// contract and uses the token's Decimals. Empty / unknown ⇒
|
||
// native-token transfer (or BridgeContract-release mode).
|
||
DestinationAsset string
|
||
// DestinationAddress is the user's recipient address on the
|
||
// destination chain (0x-prefixed hex for EVM).
|
||
DestinationAddress string
|
||
// Amount is the human-readable amount the user receives
|
||
// (e.g. 0.1 = 0.1 ETH). Scaled to base units using the resolved
|
||
// asset's decimals (or PerNetwork.NativeDecimals as a fallback).
|
||
Amount float64
|
||
// SenderAddress is the MPC-controlled signer address — comes
|
||
// from the eth_address slot of the keygen response. Used to
|
||
// query nonce; also goes into the v calculation post-signing.
|
||
SenderAddress string
|
||
}
|
||
|
||
// PreSign builds the unsigned transaction and returns the keccak256
|
||
// sighash the MPC quorum must sign.
|
||
func (a *Assembler) PreSign(ctx context.Context, in SwapIntent) (*Unsigned, error) {
|
||
cfg, ok := a.Networks[in.DestinationNetwork]
|
||
if !ok {
|
||
return nil, fmt.Errorf("txassembler: no config for network %s", in.DestinationNetwork)
|
||
}
|
||
if cfg.ChainID == nil || cfg.ChainID.Sign() <= 0 {
|
||
return nil, fmt.Errorf("txassembler: ChainID required for %s", in.DestinationNetwork)
|
||
}
|
||
|
||
// Resolve destination asset → contract + decimals. Three modes:
|
||
// 1. ERC-20 transfer: token.Contract != "" → tx targets the
|
||
// token contract with transfer() calldata
|
||
// and tx value = 0.
|
||
// 2. Bridge release: cfg.BridgeContract != "" (and asset is
|
||
// native or unresolved) → tx targets the
|
||
// per-network bridge contract with
|
||
// release() calldata and tx value = 0.
|
||
// 3. Pure transfer: fallback. tx targets the recipient
|
||
// directly with the native amount as
|
||
// tx value and empty calldata.
|
||
var tokenInfo *tokens.Info
|
||
if a.Tokens != nil && in.DestinationAsset != "" {
|
||
if info, ok := a.Tokens.Lookup(in.DestinationNetwork, in.DestinationAsset); ok {
|
||
tokenInfo = info
|
||
}
|
||
}
|
||
|
||
decimals := cfg.NativeDecimals
|
||
if decimals == 0 {
|
||
decimals = 18 // ETH/LUX/BNB/etc.
|
||
}
|
||
if tokenInfo != nil {
|
||
decimals = tokenInfo.Decimals
|
||
}
|
||
value, err := floatToWei(in.Amount, decimals)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Determine `to` + `data` based on mode.
|
||
var to []byte
|
||
var data []byte
|
||
switch {
|
||
case tokenInfo != nil && !tokenInfo.IsNative():
|
||
// Mode 1: ERC-20 transfer
|
||
to, err = parseAddress(tokenInfo.Contract)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("ERC-20 contract address %s/%s: %w",
|
||
in.DestinationNetwork, in.DestinationAsset, err)
|
||
}
|
||
recipient, err := parseAddress(in.DestinationAddress)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("DestinationAddress: %w", err)
|
||
}
|
||
data = buildABIWordCall(erc20TransferSelector[:], recipient, value)
|
||
value = big.NewInt(0) // ERC-20 tx carries no ETH value
|
||
|
||
case cfg.BridgeContract != "":
|
||
// Mode 2: bridge-contract release
|
||
to, err = parseAddress(cfg.BridgeContract)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("BridgeContract: %w", err)
|
||
}
|
||
recipient, err := parseAddress(in.DestinationAddress)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("DestinationAddress: %w", err)
|
||
}
|
||
data = buildABIWordCall(cfg.ReleaseSelector[:], recipient, value)
|
||
value = big.NewInt(0)
|
||
|
||
default:
|
||
// Mode 3: pure native transfer
|
||
to, err = parseAddress(in.DestinationAddress)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("DestinationAddress: %w", err)
|
||
}
|
||
}
|
||
|
||
// Nonce + gas price come from the provider (or static config).
|
||
nonce, err := a.Provider.PendingNonce(ctx, in.DestinationNetwork, in.SenderAddress)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("PendingNonce: %w", err)
|
||
}
|
||
var gasPrice *big.Int
|
||
if cfg.DefaultGasPriceWei != nil && cfg.DefaultGasPriceWei.Sign() > 0 {
|
||
gasPrice = new(big.Int).Set(cfg.DefaultGasPriceWei)
|
||
} else {
|
||
gasPrice, err = a.Provider.SuggestGasPrice(ctx, in.DestinationNetwork)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("SuggestGasPrice: %w", err)
|
||
}
|
||
}
|
||
gasLimit := cfg.DefaultGasLimit
|
||
if gasLimit == 0 {
|
||
if len(data) == 0 {
|
||
gasLimit = 21000 // standard plain transfer
|
||
} else {
|
||
gasLimit = 100000 // conservative default for a contract call
|
||
}
|
||
}
|
||
|
||
unsigned := &Unsigned{
|
||
Network: in.DestinationNetwork,
|
||
ChainID: new(big.Int).Set(cfg.ChainID),
|
||
Nonce: nonce,
|
||
GasPrice: gasPrice,
|
||
GasLimit: gasLimit,
|
||
To: to,
|
||
Value: value,
|
||
Data: data,
|
||
}
|
||
|
||
// Sighash: keccak256(RLP([nonce, gasPrice, gas, to, value, data, chainID, 0, 0]))
|
||
encoded := rlpList(
|
||
encodeUint(new(big.Int).SetUint64(unsigned.Nonce)),
|
||
encodeUint(unsigned.GasPrice),
|
||
encodeUint(new(big.Int).SetUint64(unsigned.GasLimit)),
|
||
encodeBytes(unsigned.To),
|
||
encodeUint(unsigned.Value),
|
||
encodeBytes(unsigned.Data),
|
||
encodeUint(unsigned.ChainID),
|
||
encodeUint(nil), // r placeholder = 0
|
||
encodeUint(nil), // s placeholder = 0
|
||
)
|
||
unsigned.SigHash = keccak256(encoded)
|
||
return unsigned, nil
|
||
}
|
||
|
||
// PreSignNativeTransfer is the low-level sibling of PreSign for raw
|
||
// native-token transfers expressed in wei rather than human-readable
|
||
// amounts. Used by the refund driver to sweep a deposit address back
|
||
// to its original sender, where the value is computed as
|
||
// "balance − gas" — a quantity only meaningful in base units, never
|
||
// floats.
|
||
//
|
||
// Behaviour vs PreSign:
|
||
// - Skips the Tokens / BridgeContract / ERC-20 routing entirely.
|
||
// Native transfer only: `to` is the recipient, `data` empty,
|
||
// `value` the supplied wei amount.
|
||
// - Looks up the network's PerNetwork config to source ChainID +
|
||
// gas defaults; falls back to 21000 gas / SuggestGasPrice when
|
||
// the per-network values are unset.
|
||
// - Returns an Unsigned ready for MPC signing → Finalize, same as
|
||
// PreSign — the refund flow reuses the same signer + assembler
|
||
// instances as the destination-release flow.
|
||
func (a *Assembler) PreSignNativeTransfer(
|
||
ctx context.Context,
|
||
network, fromAddress, toAddress string,
|
||
valueWei *big.Int,
|
||
) (*Unsigned, error) {
|
||
cfg, ok := a.Networks[network]
|
||
if !ok {
|
||
return nil, fmt.Errorf("txassembler: no config for network %s", network)
|
||
}
|
||
if cfg.ChainID == nil || cfg.ChainID.Sign() <= 0 {
|
||
return nil, fmt.Errorf("txassembler: ChainID required for %s", network)
|
||
}
|
||
if valueWei == nil || valueWei.Sign() < 0 {
|
||
return nil, fmt.Errorf("txassembler: valueWei must be non-negative; got %v", valueWei)
|
||
}
|
||
to, err := parseAddress(toAddress)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("toAddress: %w", err)
|
||
}
|
||
|
||
nonce, err := a.Provider.PendingNonce(ctx, network, fromAddress)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("PendingNonce: %w", err)
|
||
}
|
||
var gasPrice *big.Int
|
||
if cfg.DefaultGasPriceWei != nil && cfg.DefaultGasPriceWei.Sign() > 0 {
|
||
gasPrice = new(big.Int).Set(cfg.DefaultGasPriceWei)
|
||
} else {
|
||
gasPrice, err = a.Provider.SuggestGasPrice(ctx, network)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("SuggestGasPrice: %w", err)
|
||
}
|
||
}
|
||
gasLimit := cfg.DefaultGasLimit
|
||
if gasLimit == 0 {
|
||
gasLimit = 21000 // pure native transfer.
|
||
}
|
||
|
||
unsigned := &Unsigned{
|
||
Network: network,
|
||
ChainID: new(big.Int).Set(cfg.ChainID),
|
||
Nonce: nonce,
|
||
GasPrice: gasPrice,
|
||
GasLimit: gasLimit,
|
||
To: to,
|
||
Value: new(big.Int).Set(valueWei),
|
||
Data: nil,
|
||
}
|
||
encoded := rlpList(
|
||
encodeUint(new(big.Int).SetUint64(unsigned.Nonce)),
|
||
encodeUint(unsigned.GasPrice),
|
||
encodeUint(new(big.Int).SetUint64(unsigned.GasLimit)),
|
||
encodeBytes(unsigned.To),
|
||
encodeUint(unsigned.Value),
|
||
encodeBytes(unsigned.Data),
|
||
encodeUint(unsigned.ChainID),
|
||
encodeUint(nil),
|
||
encodeUint(nil),
|
||
)
|
||
unsigned.SigHash = keccak256(encoded)
|
||
return unsigned, nil
|
||
}
|
||
|
||
// Finalize takes the MPC signature (r, s, recoveryID) and produces
|
||
// the wire-ready RLP-encoded signed transaction as 0x-prefixed hex.
|
||
//
|
||
// recoveryID is 0 or 1 — the conventional ECDSA recovery byte. The
|
||
// MPC cluster's threshold-ECDSA implementation should return it
|
||
// alongside r and s. The assembler computes v = chainID*2 + 35 + recoveryID
|
||
// per EIP-155.
|
||
//
|
||
// r and s are big.Ints (canonical, no leading zeros). If your MPC
|
||
// returns a 65-byte concatenated (r || s || v) blob, use ParseRSV
|
||
// to split it.
|
||
func (a *Assembler) Finalize(unsigned *Unsigned, r, s *big.Int, recoveryID byte) (string, error) {
|
||
if unsigned == nil {
|
||
return "", errors.New("txassembler: nil unsigned")
|
||
}
|
||
if recoveryID > 1 {
|
||
return "", fmt.Errorf("txassembler: recoveryID must be 0 or 1, got %d", recoveryID)
|
||
}
|
||
// v = chainID * 2 + 35 + recoveryID (EIP-155)
|
||
v := new(big.Int).Mul(unsigned.ChainID, big.NewInt(2))
|
||
v.Add(v, big.NewInt(35+int64(recoveryID)))
|
||
|
||
encoded := rlpList(
|
||
encodeUint(new(big.Int).SetUint64(unsigned.Nonce)),
|
||
encodeUint(unsigned.GasPrice),
|
||
encodeUint(new(big.Int).SetUint64(unsigned.GasLimit)),
|
||
encodeBytes(unsigned.To),
|
||
encodeUint(unsigned.Value),
|
||
encodeBytes(unsigned.Data),
|
||
encodeUint(v),
|
||
encodeUint(r),
|
||
encodeUint(s),
|
||
)
|
||
return "0x" + hex.EncodeToString(encoded), nil
|
||
}
|
||
|
||
// secp256k1N is the order of the secp256k1 generator point. Used to
|
||
// canonicalize high-s signatures to their low-s equivalent (EIP-2 +
|
||
// every modern Ethereum-style chain rejects high-s signatures as
|
||
// "invalid sender" because they're malleable).
|
||
var secp256k1N, _ = new(big.Int).SetString(
|
||
"fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141", 16)
|
||
|
||
// secp256k1HalfN is n/2 — the threshold above which s is considered
|
||
// "high" and must be flipped.
|
||
var secp256k1HalfN = new(big.Int).Rsh(secp256k1N, 1)
|
||
|
||
// ParseRSV splits a 65-byte concatenated signature (r || s || v) into
|
||
// (r, s, recoveryID). Accepts hex-encoded input with or without 0x
|
||
// prefix. The `v` byte is interpreted as the recovery id directly —
|
||
// callers passing an EIP-155-adjusted v (already chainID-mangled)
|
||
// should subtract chainID*2 + 35 first.
|
||
//
|
||
// Low-s canonicalization: if s > n/2, ParseRSV replaces it with
|
||
// n - s and flips recoveryID (0 ↔ 1). The result is the same
|
||
// signature in canonical low-s form, which is what every
|
||
// post-EIP-2 chain (geth, coreth, etc.) requires. Without this,
|
||
// MPC implementations that don't pre-canonicalize (CGGMP21 here
|
||
// is one) produce signatures that recover to the correct address
|
||
// but get rejected at the RPC layer as "invalid sender" — exactly
|
||
// the symptom debugged on Lux Testnet.
|
||
func ParseRSV(sigHex string) (r, s *big.Int, recoveryID byte, err error) {
|
||
sigHex = strings.TrimPrefix(strings.TrimPrefix(sigHex, "0x"), "0X")
|
||
if len(sigHex) != 130 {
|
||
return nil, nil, 0, fmt.Errorf("txassembler: signature must be 65 bytes (130 hex chars), got %d", len(sigHex))
|
||
}
|
||
sig, err := hex.DecodeString(sigHex)
|
||
if err != nil {
|
||
return nil, nil, 0, fmt.Errorf("txassembler: decode signature hex: %w", err)
|
||
}
|
||
r = new(big.Int).SetBytes(sig[:32])
|
||
s = new(big.Int).SetBytes(sig[32:64])
|
||
recoveryID = sig[64]
|
||
// Some signers return `v` as 27/28 instead of 0/1; normalize.
|
||
if recoveryID == 27 {
|
||
recoveryID = 0
|
||
} else if recoveryID == 28 {
|
||
recoveryID = 1
|
||
}
|
||
if recoveryID > 1 {
|
||
return nil, nil, 0, fmt.Errorf("txassembler: unexpected recovery id %d (want 0/1 or 27/28)", recoveryID)
|
||
}
|
||
// Canonicalize: convert high-s → low-s and flip recID.
|
||
if s.Cmp(secp256k1HalfN) > 0 {
|
||
s = new(big.Int).Sub(secp256k1N, s)
|
||
recoveryID ^= 1
|
||
}
|
||
return r, s, recoveryID, nil
|
||
}
|
||
|
||
// =============================================================================
|
||
// keccak256 wrapper
|
||
// =============================================================================
|
||
|
||
// keccak256 returns the 32-byte Keccak-256 digest of data. Used for
|
||
// the tx sighash and (in future) ABI function selectors.
|
||
func keccak256(data []byte) [32]byte {
|
||
h := sha3.NewLegacyKeccak256()
|
||
_, _ = h.Write(data)
|
||
var out [32]byte
|
||
copy(out[:], h.Sum(nil))
|
||
return out
|
||
}
|
||
|
||
// buildABIWordCall encodes a function call as
|
||
// selector(4) || addressWord(32) || uintWord(32).
|
||
//
|
||
// Used by both ERC-20 transfer (selector=0xa9059cbb) and the bridge
|
||
// release path (selector=cfg.ReleaseSelector). Both have the same
|
||
// (address, uint256) argument shape — the ERC-20 spec's
|
||
// `transfer(address,uint256)` and the bridge's `release(address,uint256)`
|
||
// or `mint(address,uint256)` all share this signature.
|
||
func buildABIWordCall(selector []byte, recipient []byte, amount *big.Int) []byte {
|
||
if len(selector) != 4 {
|
||
// Shouldn't happen with our callers — guard against silent
|
||
// corruption if a future caller passes the wrong size.
|
||
panic("txassembler: buildABIWordCall requires 4-byte selector")
|
||
}
|
||
out := make([]byte, 0, 4+32+32)
|
||
out = append(out, selector...)
|
||
// recipient word: 12 zero bytes + 20-byte address (right-aligned).
|
||
var addrWord [32]byte
|
||
copy(addrWord[12:], recipient)
|
||
out = append(out, addrWord[:]...)
|
||
// amount word: right-aligned 32-byte big-endian uint256.
|
||
var valueWord [32]byte
|
||
valueBytes := amount.Bytes()
|
||
if len(valueBytes) > 32 {
|
||
// uint256 overflow — should never happen for normal amounts.
|
||
panic(fmt.Sprintf("txassembler: amount %s exceeds uint256", amount))
|
||
}
|
||
copy(valueWord[32-len(valueBytes):], valueBytes)
|
||
out = append(out, valueWord[:]...)
|
||
return out
|
||
}
|
||
|
||
// FunctionSelector returns the first 4 bytes of keccak256("signature")
|
||
// — the canonical Solidity function selector. Useful for populating
|
||
// PerNetwork.ReleaseSelector when the destination bridge contract
|
||
// exposes a known release ABI.
|
||
//
|
||
// Example: FunctionSelector("release(address,uint256)") → [0x37, 0x55, 0x4b, 0xa1]
|
||
// (or whatever the actual selector turns out to be — sanity-check on chain).
|
||
func FunctionSelector(signature string) [4]byte {
|
||
sum := keccak256([]byte(signature))
|
||
var sel [4]byte
|
||
copy(sel[:], sum[:4])
|
||
return sel
|
||
}
|