Files
zeekay 1943f1c093 Hanzo Commerce
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
2026-09-23 21:52:16 -07:00

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