mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
Multi-tenant commerce and billing: orders, subscriptions, invoices, plans, usage metering and a credit ledger. One Go binary. MIT OR Apache-2.0. Claude-Session: https://claude.ai/code/session_01SAtaDFm8VDiWrhYHNNtxMw
623 lines
22 KiB
Go
623 lines
22 KiB
Go
package evm
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/luxfi/crypto"
|
|
"github.com/luxfi/geth/common"
|
|
"github.com/luxfi/geth/core/types"
|
|
|
|
"github.com/hanzoai/commerce/billing/custody"
|
|
"github.com/hanzoai/commerce/billing/husdindex"
|
|
)
|
|
|
|
// --- The external vectors -------------------------------------------------
|
|
//
|
|
// Everything in this block was produced by code that shares no line with the
|
|
// code under test: Python's eth-account (the Ethereum Foundation's own
|
|
// implementation), signing with the key below. Nothing here was generated by
|
|
// luxfi/geth, by this package, or by our MPC signer, which is the whole point —
|
|
// a signature that only our own signer accepts proves that our signer and our
|
|
// verifier agree with each other, not that either is right.
|
|
//
|
|
// The generator is itself anchored: vectorLegacyRaw is byte for byte the signed
|
|
// transaction PUBLISHED IN EIP-155, so an eth-account that produced it is
|
|
// producing spec-conformant output, and the type-2 vector beside it inherits
|
|
// that credibility.
|
|
const (
|
|
// The EIP-155 example key, and the address it controls.
|
|
vectorKey = "4646464646464646464646464646464646464646464646464646464646464646"
|
|
vectorAddr = "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F"
|
|
|
|
// EIP-155 §Example: nonce 9, gasPrice 20 gwei, gas 21000,
|
|
// to 0x3535…35, value 1 ETH, chainId 1.
|
|
vectorLegacyRaw = "f86c098504a817c800825208943535353535353535353535353535353535353535880de0b6b3a7640000" +
|
|
"8025a028ef61340bd939bc2195fe537567866003e1a15d3c71ff63e1590620aa636276" +
|
|
"a067cbe9d8997f761aecb703304b3800ccf555c9f3dc64214b297fb1966a3b6d83"
|
|
|
|
// An EIP-1559 ERC-20 transfer: 1000 USDC from the vector address to
|
|
// vitalik.eth, chainId 1, nonce 7, tip 2 gwei, cap 100 gwei, gas 60000.
|
|
vector1559To = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" // USDC
|
|
vector1559Dest = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
|
|
vector1559Data = "a9059cbb" +
|
|
"000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045" +
|
|
"000000000000000000000000000000000000000000000000000000003b9aca00"
|
|
vector1559Digest = "822ca5bf5e642dee57376b97e9f0d07b18692986677618b627eb3dcd5bebd9c0"
|
|
vector1559R = "b891dcb94e04af2073cc7753d19157b955e20dc98df315a5b68980f2de211150"
|
|
vector1559S = "069c419befda1ef9e2b24a026c795e0bb708b0b4f97376718401c60d324a61db"
|
|
vector1559V = 1
|
|
vector1559Raw = "02f8b00107847735940085174876e80082ea6094a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4880b844" +
|
|
"a9059cbb000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045" +
|
|
"000000000000000000000000000000000000000000000000000000003b9aca00c0" +
|
|
"01a0b891dcb94e04af2073cc7753d19157b955e20dc98df315a5b68980f2de211150" +
|
|
"a0069c419befda1ef9e2b24a026c795e0bb708b0b4f97376718401c60d324a61db"
|
|
vector1559Hash = "e7d700cc7b10b1e0c06b06f997b2c47505ac1aa75fb112e0687b2203921a7df4"
|
|
)
|
|
|
|
func unhex(t *testing.T, s string) []byte {
|
|
t.Helper()
|
|
b, err := hex.DecodeString(strings.TrimPrefix(s, "0x"))
|
|
if err != nil {
|
|
t.Fatalf("bad hex in test data %q: %v", s, err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// vector1559Tx rebuilds the external transaction through OUR construction path.
|
|
func vector1559Tx(t *testing.T) (*types.Transaction, common.Address) {
|
|
t.Helper()
|
|
to, err := parseAddr(vector1559To)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
from, err := parseAddr(vectorAddr)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dest, err := parseAddr(vector1559Dest)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The calldata is built by transferCall, not pasted, so this asserts our
|
|
// ABI encoding too.
|
|
data := transferCall(dest, big.NewInt(1_000_000_000))
|
|
if got := hex.EncodeToString(data); got != vector1559Data {
|
|
t.Fatalf("transferCall encoded\n got %s\nwant %s", got, vector1559Data)
|
|
}
|
|
return types.NewTx(&types.DynamicFeeTx{
|
|
ChainID: big.NewInt(1),
|
|
Nonce: 7,
|
|
GasTipCap: big.NewInt(2_000_000_000),
|
|
GasFeeCap: big.NewInt(100_000_000_000),
|
|
Gas: 60000,
|
|
To: &to,
|
|
Value: new(big.Int),
|
|
Data: data,
|
|
}), from
|
|
}
|
|
|
|
// TestSelectorIsKeccak proves the four bytes that choose which function of a
|
|
// token contract runs. It hashes the signature rather than trusting the
|
|
// constant, because a wrong selector does not fail loudly — on many contracts
|
|
// it lands in the fallback and the call succeeds having moved nothing.
|
|
func TestSelectorIsKeccak(t *testing.T) {
|
|
want := hex.EncodeToString(crypto.Keccak256([]byte("transfer(address,uint256)"))[:4])
|
|
if want != transferSelector {
|
|
t.Fatalf("transferSelector is %s but keccak256(\"transfer(address,uint256)\")[:4] is %s", transferSelector, want)
|
|
}
|
|
}
|
|
|
|
// TestDigestMatchesExternal proves the bytes we ask the MPC fleet to sign are
|
|
// the bytes an independent implementation signs for the same transaction.
|
|
//
|
|
// This is the single most important assertion in the package. Everything
|
|
// downstream — the signature, the assembled transaction, the money — is
|
|
// determined by this digest, and a digest that is subtly wrong yields a
|
|
// perfectly valid signature over the wrong payload.
|
|
func TestDigestMatchesExternal(t *testing.T) {
|
|
tx, from := vector1559Tx(t)
|
|
c := &Chain{network: custody.Ethereum, chainID: big.NewInt(1)}
|
|
d, err := c.draft(tx, from)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(d.Digests) != 1 {
|
|
t.Fatalf("an account-model chain must produce exactly one digest, got %d", len(d.Digests))
|
|
}
|
|
if got := hex.EncodeToString(d.Digests[0]); got != vector1559Digest {
|
|
t.Fatalf("signing digest\n got %s\nwant %s (eth-account)", got, vector1559Digest)
|
|
}
|
|
}
|
|
|
|
// TestSealMatchesExternal feeds a signature THIS CODE DID NOT PRODUCE into
|
|
// Seal and requires the assembled transaction to be byte-identical to the one
|
|
// eth-account produced.
|
|
//
|
|
// The signature comes in as bare r‖s — the shape mpcd returns, minus the
|
|
// recovery byte — so this also proves that recovering v ourselves lands on the
|
|
// same value the external implementation chose.
|
|
func TestSealMatchesExternal(t *testing.T) {
|
|
tx, from := vector1559Tx(t)
|
|
c := &Chain{network: custody.Ethereum, chainID: big.NewInt(1)}
|
|
d, err := c.draft(tx, from)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
sig := append(unhex(t, vector1559R), unhex(t, vector1559S)...)
|
|
if len(sig) != 64 {
|
|
t.Fatalf("external r‖s should be 64 bytes, got %d", len(sig))
|
|
}
|
|
|
|
raw, err := d.Seal([][]byte{sig})
|
|
if err != nil {
|
|
t.Fatalf("Seal refused a signature that is genuinely from the right key: %v", err)
|
|
}
|
|
if got := hex.EncodeToString(raw); got != vector1559Raw {
|
|
t.Fatalf("assembled transaction\n got %s\nwant %s (eth-account)", got, vector1559Raw)
|
|
}
|
|
|
|
// And the transaction hash the chain will know it by.
|
|
var decoded types.Transaction
|
|
if err := decoded.UnmarshalBinary(raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := strings.TrimPrefix(decoded.Hash().Hex(), "0x"); got != vector1559Hash {
|
|
t.Fatalf("tx hash\n got %s\nwant %s (eth-account)", got, vector1559Hash)
|
|
}
|
|
}
|
|
|
|
// TestRecoveryIdIsDerivedNotTrusted feeds the signer's v byte in as BOTH
|
|
// possible values and requires the same correct answer each time.
|
|
//
|
|
// mpcd returns a v and checks it against the wallet's own public key. Trusting
|
|
// it would make our check circular: it would confirm the signer agrees with
|
|
// itself, when the question is whether the signer agrees with the address
|
|
// commerce recorded and told a customer to pay into.
|
|
func TestRecoveryIdIsDerivedNotTrusted(t *testing.T) {
|
|
tx, from := vector1559Tx(t)
|
|
c := &Chain{network: custody.Ethereum, chainID: big.NewInt(1)}
|
|
d, err := c.draft(tx, from)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
base := append(unhex(t, vector1559R), unhex(t, vector1559S)...)
|
|
|
|
for _, v := range []byte{0, 1} {
|
|
sig := append(append([]byte{}, base...), v)
|
|
raw, err := d.Seal([][]byte{sig})
|
|
if err != nil {
|
|
t.Fatalf("v=%d: Seal refused: %v", v, err)
|
|
}
|
|
if got := hex.EncodeToString(raw); got != vector1559Raw {
|
|
t.Fatalf("v=%d: assembled transaction differs from the external vector\n got %s\nwant %s", v, got, vector1559Raw)
|
|
}
|
|
}
|
|
if vector1559V != 1 {
|
|
t.Fatalf("test data drifted: external v is %d", vector1559V)
|
|
}
|
|
}
|
|
|
|
// TestSealRefusesForeignSignature is the check that stands between a confused
|
|
// signer and somebody else's money.
|
|
//
|
|
// A wrong wallet id, a reshared key, a signer answering on the wrong curve and
|
|
// a digest that drifted between drafting and signing all look identical from
|
|
// here: a well-formed 64-byte signature that belongs to a different key. None
|
|
// of them is detectable by inspecting the signature. All of them must stop.
|
|
func TestSealRefusesForeignSignature(t *testing.T) {
|
|
tx, from := vector1559Tx(t)
|
|
c := &Chain{network: custody.Ethereum, chainID: big.NewInt(1)}
|
|
d, err := c.draft(tx, from)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// A real signature over the right digest, by the wrong key.
|
|
other, err := crypto.HexToECDSA("1111111111111111111111111111111111111111111111111111111111111111")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
foreign, err := crypto.Sign(d.Digests[0], other)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := d.Seal([][]byte{foreign}); err == nil {
|
|
t.Fatal("Seal accepted a signature from a key that does not own the custody address")
|
|
} else if !strings.Contains(err.Error(), "does not belong to custody address") {
|
|
t.Fatalf("wrong refusal: %v", err)
|
|
}
|
|
|
|
// Garbage of the right length must not be assembled either.
|
|
if _, err := d.Seal([][]byte{make([]byte, 64)}); err == nil {
|
|
t.Fatal("Seal accepted an all-zero signature")
|
|
}
|
|
// And the wrong NUMBER of signatures is refused rather than indexed into.
|
|
if _, err := d.Seal(nil); err == nil {
|
|
t.Fatal("Seal accepted zero signatures")
|
|
}
|
|
if _, err := d.Seal([][]byte{foreign, foreign}); err == nil {
|
|
t.Fatal("Seal accepted two signatures for a one-input transaction")
|
|
}
|
|
}
|
|
|
|
// TestEIP155VectorRecovers checks our address derivation against the signed
|
|
// transaction printed in EIP-155 itself — a vector older and more widely
|
|
// replicated than any library.
|
|
func TestEIP155VectorRecovers(t *testing.T) {
|
|
var tx types.Transaction
|
|
if err := tx.UnmarshalBinary(unhex(t, vectorLegacyRaw)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := types.Sender(types.LatestSignerForChainID(big.NewInt(1)), &tx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != common.HexToAddress(vectorAddr) {
|
|
t.Fatalf("EIP-155 vector recovers to %s, want %s", got.Hex(), vectorAddr)
|
|
}
|
|
}
|
|
|
|
// TestParseAddrRefusesTruncation covers the reason parseAddr exists at all.
|
|
//
|
|
// common.HexToAddress silently zero-pads and truncates, so a destination one
|
|
// character short becomes a different, perfectly valid address, and an empty
|
|
// string becomes the zero address — a burn.
|
|
func TestParseAddrRefusesTruncation(t *testing.T) {
|
|
for _, bad := range []string{
|
|
"",
|
|
"0x",
|
|
"0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4", // one nibble short
|
|
"0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4FF", // one nibble long
|
|
"0x0000000000000000000000000000000000000000", // burn
|
|
"0xzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
|
|
"9d8A62f656a8d1615C1294fd71e9CFb3E4855A4",
|
|
} {
|
|
if _, err := parseAddr(bad); err == nil {
|
|
t.Errorf("parseAddr accepted %q", bad)
|
|
}
|
|
}
|
|
// A good address survives, with or without the prefix, in either case.
|
|
for _, good := range []string{vectorAddr, strings.TrimPrefix(vectorAddr, "0x"), strings.ToLower(vectorAddr)} {
|
|
if _, err := parseAddr(good); err != nil {
|
|
t.Errorf("parseAddr rejected %q: %v", good, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Draft against a node -------------------------------------------------
|
|
|
|
// node is a fake EVM JSON-RPC endpoint. It answers only what a sweep asks, and
|
|
// records the transaction it was told to broadcast.
|
|
type node struct {
|
|
chainID int64
|
|
nonce uint64
|
|
tip, base *big.Int
|
|
gas uint64
|
|
native *big.Int // wei held by the custody address
|
|
token *big.Int // token balance of the custody address
|
|
sent string
|
|
estimated map[string]string // last eth_estimateGas argument
|
|
}
|
|
|
|
func (n *node) serve(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
hexb := func(b *big.Int) string { return "0x" + b.Text(16) }
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
ID int64 `json:"id"`
|
|
Method string `json:"method"`
|
|
Params []any `json:"params"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
t.Errorf("fake node got undecodable request: %v", err)
|
|
return
|
|
}
|
|
var result any
|
|
switch req.Method {
|
|
case "eth_chainId":
|
|
result = "0x" + big.NewInt(n.chainID).Text(16)
|
|
case "eth_getTransactionCount":
|
|
if len(req.Params) < 2 || req.Params[1] != "pending" {
|
|
t.Errorf("nonce must be read at 'pending', got %v", req.Params)
|
|
}
|
|
result = "0x" + new(big.Int).SetUint64(n.nonce).Text(16)
|
|
case "eth_maxPriorityFeePerGas":
|
|
result = hexb(n.tip)
|
|
case "eth_getBlockByNumber":
|
|
if len(req.Params) < 1 || req.Params[0] != "pending" {
|
|
t.Errorf("base fee must be read from the 'pending' block, got %v", req.Params)
|
|
}
|
|
result = map[string]string{"baseFeePerGas": hexb(n.base)}
|
|
case "eth_estimateGas":
|
|
if m, ok := req.Params[0].(map[string]any); ok {
|
|
n.estimated = map[string]string{}
|
|
for k, v := range m {
|
|
n.estimated[k], _ = v.(string)
|
|
}
|
|
}
|
|
result = "0x" + new(big.Int).SetUint64(n.gas).Text(16)
|
|
case "eth_getBalance":
|
|
result = hexb(n.native)
|
|
case "eth_call":
|
|
result = "0x" + fmt.Sprintf("%064s", n.token.Text(16))
|
|
case "eth_sendRawTransaction":
|
|
n.sent, _ = req.Params[0].(string)
|
|
result = "0x" + vector1559Hash
|
|
default:
|
|
t.Errorf("fake node got an unexpected method %q — a sweep should not need it", req.Method)
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": result})
|
|
}))
|
|
}
|
|
|
|
func gwei(n int64) *big.Int { return new(big.Int).Mul(big.NewInt(n), big.NewInt(1_000_000_000)) }
|
|
func eth(n int64) *big.Int { return new(big.Int).Mul(gwei(n), big.NewInt(1_000_000_000)) }
|
|
|
|
func TestDraftTokenRefusesWithoutGas(t *testing.T) {
|
|
n := &node{
|
|
chainID: 1, nonce: 3, tip: gwei(2), base: gwei(30), gas: 65000,
|
|
native: big.NewInt(0), // the ordinary state: USDC arrived, no ETH did
|
|
token: big.NewInt(1_000_000_000), // 1000 USDC
|
|
}
|
|
srv := n.serve(t)
|
|
defer srv.Close()
|
|
|
|
c, err := New(context.Background(), custody.Ethereum, srv.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = c.Draft(context.Background(), custody.Transfer{
|
|
OrgID: "acme", WalletID: "w1", From: vectorAddr, To: vector1559Dest, Token: vector1559To,
|
|
})
|
|
var noFee *custody.ErrNoFee
|
|
if err == nil {
|
|
t.Fatal("drafted a token transfer from an address with no gas")
|
|
}
|
|
if !asErrNoFee(err, &noFee) {
|
|
t.Fatalf("want a typed ErrNoFee naming what to send, got %T: %v", err, err)
|
|
}
|
|
if noFee.Have.Sign() != 0 || noFee.Need.Sign() <= 0 {
|
|
t.Fatalf("ErrNoFee should say have=0 need>0, got have=%s need=%s", noFee.Have, noFee.Need)
|
|
}
|
|
// Gas must have been estimated with the token contract as the target and
|
|
// the transfer calldata attached — never as a bare value send.
|
|
if got := n.estimated["to"]; got != strings.ToLower(vector1559To) {
|
|
t.Fatalf("estimated gas against %q, want the token contract", got)
|
|
}
|
|
if got := strings.TrimPrefix(n.estimated["data"], "0x"); got != vector1559Data {
|
|
t.Fatalf("estimated gas with data %q, want the transfer calldata", got)
|
|
}
|
|
}
|
|
|
|
func TestDraftTokenSweepsFullBalance(t *testing.T) {
|
|
n := &node{
|
|
chainID: 1, nonce: 3, tip: gwei(2), base: gwei(30), gas: 65000,
|
|
native: eth(1), // funded with gas
|
|
token: big.NewInt(1_000_000_000),
|
|
}
|
|
srv := n.serve(t)
|
|
defer srv.Close()
|
|
|
|
c, err := New(context.Background(), custody.Ethereum, srv.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d, err := c.Draft(context.Background(), custody.Transfer{
|
|
OrgID: "acme", WalletID: "w1", From: vectorAddr, To: vector1559Dest, Token: vector1559To,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(d.Digests) != 1 {
|
|
t.Fatalf("want one digest, got %d", len(d.Digests))
|
|
}
|
|
// A nil Amount means the whole balance the contract reports, and it must
|
|
// reach the calldata unrounded.
|
|
if got := strings.TrimPrefix(n.estimated["data"], "0x"); got != vector1559Data {
|
|
t.Fatalf("swept amount did not reach the calldata:\n got %s\nwant %s", got, vector1559Data)
|
|
}
|
|
}
|
|
|
|
func TestDraftTokenEmptyIsNotAnError(t *testing.T) {
|
|
n := &node{chainID: 1, nonce: 0, tip: gwei(2), base: gwei(30), gas: 65000, native: eth(1), token: big.NewInt(0)}
|
|
srv := n.serve(t)
|
|
defer srv.Close()
|
|
c, _ := New(context.Background(), custody.Ethereum, srv.URL)
|
|
_, err := c.Draft(context.Background(), custody.Transfer{
|
|
OrgID: "acme", WalletID: "w1", From: vectorAddr, To: vector1559Dest, Token: vector1559To,
|
|
})
|
|
if err != custody.ErrEmpty {
|
|
t.Fatalf("an empty address should report ErrEmpty, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestDraftNativeLeavesTheFee checks the arithmetic that decides how much of a
|
|
// native balance is actually movable.
|
|
//
|
|
// Getting it wrong by one wei produces a transaction that is rejected for
|
|
// insufficient funds after the fee is applied — the classic way a "sweep
|
|
// everything" loop burns gas forever without moving anything.
|
|
func TestDraftNativeLeavesTheFee(t *testing.T) {
|
|
n := &node{chainID: 1, nonce: 5, tip: gwei(2), base: gwei(30), gas: 21000, native: eth(1)}
|
|
srv := n.serve(t)
|
|
defer srv.Close()
|
|
c, _ := New(context.Background(), custody.Ethereum, srv.URL)
|
|
|
|
d, err := c.Draft(context.Background(), custody.Transfer{
|
|
OrgID: "acme", WalletID: "w1", From: vectorAddr, To: vector1559Dest,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = d
|
|
|
|
// Reconstruct what the code should have reserved: gas*(100+margin)/100 at
|
|
// the cap of base*multiplier + tip.
|
|
gas := uint64(21000) * (100 + gasMargin) / 100
|
|
cap := new(big.Int).Add(new(big.Int).Mul(gwei(30), big.NewInt(feeMultiplier)), gwei(2))
|
|
reserve := new(big.Int).Mul(new(big.Int).SetUint64(gas), cap)
|
|
want := new(big.Int).Sub(eth(1), reserve)
|
|
|
|
// The value is not directly observable from the Draft, so assert through
|
|
// the encoding: seal with a real key and decode the result.
|
|
key, _ := crypto.HexToECDSA(vectorKey)
|
|
sig, err := crypto.Sign(d.Digests[0], key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, err := d.Seal([][]byte{sig[:64]})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var tx types.Transaction
|
|
if err := tx.UnmarshalBinary(raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if tx.Value().Cmp(want) != 0 {
|
|
t.Fatalf("native sweep value\n got %s\nwant %s (balance minus gas at the fee cap)", tx.Value(), want)
|
|
}
|
|
if tx.Nonce() != 5 {
|
|
t.Fatalf("nonce %d, want the pending count 5", tx.Nonce())
|
|
}
|
|
if tx.GasFeeCap().Cmp(cap) != 0 {
|
|
t.Fatalf("fee cap %s, want %s", tx.GasFeeCap(), cap)
|
|
}
|
|
// And it must be spendable: value + maxfee cannot exceed the balance.
|
|
total := new(big.Int).Add(tx.Value(), new(big.Int).Mul(new(big.Int).SetUint64(tx.Gas()), tx.GasFeeCap()))
|
|
if total.Cmp(eth(1)) > 0 {
|
|
t.Fatalf("drafted a transaction the address cannot afford: needs %s, holds %s", total, eth(1))
|
|
}
|
|
}
|
|
|
|
func TestDraftNativeRefusesWhenFeeExceedsBalance(t *testing.T) {
|
|
n := &node{chainID: 1, nonce: 0, tip: gwei(2), base: gwei(30), gas: 21000, native: big.NewInt(1000)}
|
|
srv := n.serve(t)
|
|
defer srv.Close()
|
|
c, _ := New(context.Background(), custody.Ethereum, srv.URL)
|
|
_, err := c.Draft(context.Background(), custody.Transfer{
|
|
OrgID: "acme", WalletID: "w1", From: vectorAddr, To: vector1559Dest,
|
|
})
|
|
var noFee *custody.ErrNoFee
|
|
if !asErrNoFee(err, &noFee) {
|
|
t.Fatalf("dust should report ErrNoFee, got %T: %v", err, err)
|
|
}
|
|
}
|
|
|
|
// TestChainIDComesFromTheNode is the replay guard. A chain id taken from config
|
|
// and typed wrong makes a signature valid on a chain it was never meant for.
|
|
func TestChainIDComesFromTheNode(t *testing.T) {
|
|
n := &node{chainID: 8453, tip: gwei(1), base: gwei(1), gas: 21000, native: gwei(1)}
|
|
srv := n.serve(t)
|
|
defer srv.Close()
|
|
c, err := New(context.Background(), custody.Base, srv.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if c.ChainID().Int64() != 8453 {
|
|
t.Fatalf("chain id %s, want the node's 8453", c.ChainID())
|
|
}
|
|
}
|
|
|
|
func TestNewRefusesNonEVMNetworks(t *testing.T) {
|
|
for _, n := range []custody.Network{custody.Solana, custody.TON, custody.Network("avalanche"), custody.Network("zoo")} {
|
|
if _, err := New(context.Background(), n, "http://127.0.0.1:1"); err == nil {
|
|
t.Errorf("New accepted %q as an EVM network", n)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSweepEndToEnd drives the whole path with a signer that holds the vector
|
|
// key: draft against the fake node, sign, seal, broadcast — and requires the
|
|
// broadcast bytes to carry a signature the chain will attribute to the custody
|
|
// address.
|
|
func TestSweepEndToEnd(t *testing.T) {
|
|
n := &node{
|
|
chainID: 1, nonce: 3, tip: gwei(2), base: gwei(30), gas: 65000,
|
|
native: eth(1), token: big.NewInt(1_000_000_000),
|
|
}
|
|
srv := n.serve(t)
|
|
defer srv.Close()
|
|
|
|
c, err := New(context.Background(), custody.Ethereum, srv.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hash, err := custody.Sweep(context.Background(), c, keySigner{t}, custody.Transfer{
|
|
OrgID: "acme", WalletID: "w1", From: vectorAddr, To: vector1559Dest, Token: vector1559To,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if hash == "" {
|
|
t.Fatal("Sweep returned no transaction hash")
|
|
}
|
|
var tx types.Transaction
|
|
if err := tx.UnmarshalBinary(unhex(t, n.sent)); err != nil {
|
|
t.Fatalf("broadcast bytes do not decode as a transaction: %v", err)
|
|
}
|
|
sender, err := types.Sender(types.LatestSignerForChainID(big.NewInt(1)), &tx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if sender != common.HexToAddress(vectorAddr) {
|
|
t.Fatalf("broadcast a transaction from %s, not the custody address %s", sender.Hex(), vectorAddr)
|
|
}
|
|
if tx.To().Hex() != common.HexToAddress(vector1559To).Hex() {
|
|
t.Fatalf("token transfer went to %s, want the token contract %s", tx.To().Hex(), vector1559To)
|
|
}
|
|
}
|
|
|
|
// keySigner stands in for the MPC fleet using the vector's private key. It
|
|
// returns bare r‖s, exactly as mpcd's 65-byte answer looks once the recovery
|
|
// byte is ignored.
|
|
type keySigner struct{ t *testing.T }
|
|
|
|
func (k keySigner) Sign(_ context.Context, org, wallet string, n custody.Network, digest []byte) ([]byte, error) {
|
|
if org == "" || wallet == "" {
|
|
k.t.Fatalf("signer called without identity: org=%q wallet=%q", org, wallet)
|
|
}
|
|
if c, _ := n.Curve(); c != custody.Secp256k1 {
|
|
k.t.Fatalf("EVM sweep asked for a %s signature", c)
|
|
}
|
|
if len(digest) != 32 {
|
|
k.t.Fatalf("EVM digest should be 32 bytes, got %d", len(digest))
|
|
}
|
|
key, err := crypto.HexToECDSA(vectorKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sig, err := crypto.Sign(digest, key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return sig[:64], nil
|
|
}
|
|
|
|
func asErrNoFee(err error, out **custody.ErrNoFee) bool {
|
|
for err != nil {
|
|
if e, ok := err.(*custody.ErrNoFee); ok {
|
|
*out = e
|
|
return true
|
|
}
|
|
u, ok := err.(interface{ Unwrap() error })
|
|
if !ok {
|
|
return false
|
|
}
|
|
err = u.Unwrap()
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Keep husdindex linked here: the sweep's transport lives there and a change to
|
|
// its constructor should break this file, not production.
|
|
var _ = husdindex.NewClient
|