Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/wallet/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const shutdownGrace = 5 * time.Second
// Version is the wallet binary's release tag. Kept in sync with the
// app_version field in manifest.json — `manifest_test.go` cross-checks
// they agree, so a release without bumping both fails CI.
const Version = "0.4.2"
const Version = "0.4.3"

func main() {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
Expand Down
3 changes: 2 additions & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"id": "io.pilot.wallet",
"app_version": "0.4.2",
"app_version": "0.4.3",
"manifest_version": 2,
"binary": {
"runtime": "go",
Expand All @@ -21,6 +21,7 @@
"wallet.evm.address",
"wallet.evm.balance",
"wallet.evm.satisfy",
"wallet.evm.sign_transfer",
"wallet.evm.verify",
"wallet.evm.chains",
"wallet.settler.identity",
Expand Down
89 changes: 89 additions & 0 deletions pkg/evm/tx.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package evm

import (
"errors"
"fmt"
"math/big"
)

// EIP-1559 transactions, for a wallet that sends USDC itself and pays its
// own gas: the fields, their RLP encoding and the signature. Only what a
// token transfer needs: an empty access list, no contract creation.
// Checked byte for byte against an independent signer (eth-account) in
// TestTx1559MatchesReference.

// Tx1559 is an EIP-1559 (type 2) transaction with an empty access list.
type Tx1559 struct {
ChainID, Nonce uint64
MaxPriorityFee, MaxFee, Gas *big.Int
To Address
Value *big.Int
Data []byte
}

func (t Tx1559) fields() []any {
return []any{new(big.Int).SetUint64(t.ChainID), new(big.Int).SetUint64(t.Nonce), t.MaxPriorityFee, t.MaxFee, t.Gas,
t.To[:], t.Value, t.Data, []any{}}
}

// SigningHash is what the signer signs: keccak256(0x02 || rlp(fields)).
func (t Tx1559) SigningHash() []byte {
return Keccak256([]byte{0x02}, rlpEncode(t.fields()))
}

// Sign returns the raw transaction, 0x02 || rlp(fields, yParity, r, s), and
// its hash, which is the transaction's id on chain.
func (t Tx1559) Sign(s *EVMSigner) (raw, hash []byte, err error) {
if t.MaxPriorityFee == nil || t.MaxFee == nil || t.Gas == nil || t.Value == nil {
return nil, nil, errors.New("evm: a transaction needs its fees, gas and value")
}
sig, err := s.SignDigest(t.SigningHash()) // R || S || V, V = 27 + yParity
if err != nil {
return nil, nil, err
}
yParity := new(big.Int).SetUint64(uint64(sig[64] - 27))
r, sv := new(big.Int).SetBytes(sig[:32]), new(big.Int).SetBytes(sig[32:64])
raw = append([]byte{0x02}, rlpEncode(append(t.fields(), yParity, r, sv))...)
return raw, Keccak256(raw), nil
}

// TransferData is the call data of an ERC-20 transfer(to, amount).
func TransferData(to Address, amount *big.Int) []byte {
data := make([]byte, 4+32+32)
copy(data, []byte{0xa9, 0x05, 0x9c, 0xbb}) // transfer(address,uint256)
copy(data[4+12:], to[:])
amount.FillBytes(data[4+32:])
return data
}

// rlpEncode encodes byte strings, unsigned integers (*big.Int: minimal
// big-endian, zero as the empty string) and lists: all a transaction holds.
func rlpEncode(v any) []byte {
switch x := v.(type) {
case []byte:
if len(x) == 1 && x[0] < 0x80 {
return []byte{x[0]}
}
return append(rlpHead(0x80, len(x)), x...)
case *big.Int:
if x == nil || x.Sign() == 0 {
return []byte{0x80}
}
return rlpEncode(x.Bytes())
case []any:
var body []byte
for _, item := range x {
body = append(body, rlpEncode(item)...)
}
return append(rlpHead(0xc0, len(body)), body...)
}
panic(fmt.Sprintf("rlp: %T", v))
}

func rlpHead(base byte, n int) []byte {
if n < 56 {
return []byte{base + byte(n)}
}
l := new(big.Int).SetInt64(int64(n)).Bytes()
return append([]byte{base + 55 + byte(len(l))}, l...)
}
64 changes: 64 additions & 0 deletions pkg/evm/tx_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package evm

import (
"encoding/hex"
"math/big"
"os"
"testing"
)

// A well-known test key (Hardhat's first), never funded on mainnet.
const hardhatKey = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"

func referenceTx(t *testing.T) (Tx1559, *EVMSigner) {
t.Helper()
seed, _ := hex.DecodeString(hardhatKey)
s, err := EVMSignerFromSeed(seed)
if err != nil {
t.Fatal(err)
}
usdc, _ := ParseAddress("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913")
payee, _ := ParseAddress("0x70997970c51812dc3a010c7d01b50e0d17dc79c8")
return Tx1559{ChainID: 8453, Nonce: 7, MaxPriorityFee: big.NewInt(1_000_000), MaxFee: big.NewInt(50_000_000),
Gas: big.NewInt(80_000), To: usdc, Value: big.NewInt(0), Data: TransferData(payee, big.NewInt(250000))}, s
}

// The raw transaction must match what an independent signer produces for
// the same fields: Python's eth-account, through tools/txref.py, whose hex
// TX_REFERENCE carries:
//
// TX_REFERENCE=$(uv run -q --with eth-account==0.13.4 python tools/txref.py) go test ./pkg/evm/
func TestTx1559MatchesReference(t *testing.T) {
want := os.Getenv("TX_REFERENCE")
if want == "" {
t.Skip("TX_REFERENCE not set (tools/txref.py prints it)")
}
tx, s := referenceTx(t)
raw, _, err := tx.Sign(s)
if err != nil {
t.Fatal(err)
}
if got := "0x" + hex.EncodeToString(raw); got != want {
t.Fatalf("raw tx differs:\n got %s\nwant %s", got, want)
}
}

// Without the reference: the signature recovers to the signer, and the
// encoding is a type-2 transaction for the USDC contract.
func TestTx1559SignsAsTheWallet(t *testing.T) {
tx, s := referenceTx(t)
raw, hash, err := tx.Sign(s)
if err != nil || raw[0] != 0x02 || len(hash) != 32 {
t.Fatalf("sign: %v %x", err, raw[:1])
}
if s.Address().Hex() != "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" {
t.Fatalf("the test key's address: %s", s.Address().Hex())
}
sig, _ := s.SignDigest(tx.SigningHash())
if got, err := Recover(tx.SigningHash(), sig); err != nil || got != s.Address() {
t.Fatalf("recovered %s, want %s (%v)", got.Hex(), s.Address().Hex(), err)
}
if hex.EncodeToString(TransferData(s.Address(), big.NewInt(1))[:4]) != "a9059cbb" {
t.Fatal("the transfer selector")
}
}
76 changes: 76 additions & 0 deletions pkg/wallet/sendevm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package wallet

import (
"context"
"errors"
"fmt"
"math/big"

"github.com/pilot-protocol/wallet/pkg/evm"
)

// Sending USDC on chain, paying gas from this wallet's own ETH: the wallet
// signs a plain ERC-20 transfer as an EIP-1559 transaction and hands back
// the raw transaction for its host to broadcast. It never dials the chain
// itself, it only ever signs a transfer of its own USDC token, and every
// transfer counts against the same rolling spend cap as Pay and satisfy.

// TransferTx is what the host supplies to sign a transfer: where to, how
// much (in the token's smallest unit), and the chain's nonce, gas and fees
// for this wallet's address.
type TransferTx struct {
To evm.Address
Amount *big.Int
Nonce uint64
Gas *big.Int
MaxFee *big.Int // wei per gas
MaxPriorityFee *big.Int // wei per gas
}

// Sanity limits on what a host may ask the wallet to pay in gas, so a
// caller cannot spend the wallet's ETH on absurd fees.
var (
transferGasMax = big.NewInt(200_000)
transferMaxFeeMax = big.NewInt(100_000_000_000) // 100 gwei
)

// ErrBadTransfer is a transfer request the wallet will not sign.
var ErrBadTransfer = errors.New("wallet.evm.sign_transfer")

// SignUSDCTransferOn signs a USDC transfer from this wallet on chainID and
// returns the raw transaction and its hash.
func (w *Wallet) SignUSDCTransferOn(_ context.Context, chainID uint64, t TransferTx) (raw, hash []byte, err error) {
b := w.evmByChain[chainID]
if b == nil {
return nil, nil, fmt.Errorf("%w: chain %d not configured", ErrBadTransfer, chainID)
}
switch {
case t.To == evm.Address{}:
return nil, nil, fmt.Errorf("%w: a recipient is required", ErrBadTransfer)
case t.To == b.signer.Address():
return nil, nil, fmt.Errorf("%w: that is this wallet's own address", ErrBadTransfer)
case t.Amount == nil || t.Amount.Sign() <= 0 || !t.Amount.IsInt64():
return nil, nil, fmt.Errorf("%w: the amount must be more than zero", ErrBadTransfer)
case t.Gas == nil || t.Gas.Sign() <= 0 || t.Gas.Cmp(transferGasMax) > 0:
return nil, nil, fmt.Errorf("%w: gas must be from 1 to %s", ErrBadTransfer, transferGasMax)
case t.MaxFee == nil || t.MaxFee.Sign() <= 0 || t.MaxFee.Cmp(transferMaxFeeMax) > 0:
return nil, nil, fmt.Errorf("%w: the fee per gas must be from 1 wei to 100 gwei", ErrBadTransfer)
case t.MaxPriorityFee == nil || t.MaxPriorityFee.Sign() < 0 || t.MaxPriorityFee.Cmp(t.MaxFee) > 0:
return nil, nil, fmt.Errorf("%w: the priority fee must be at most the fee per gas", ErrBadTransfer)
}
asset := NormalizeEVMAsset("USDC")
amount := Amount(t.Amount.Int64())
w.capMu.Lock()
defer w.capMu.Unlock()
if err := w.checkSpendCapLocked(asset, amount); err != nil {
return nil, nil, err
}
tx := evm.Tx1559{ChainID: chainID, Nonce: t.Nonce, MaxPriorityFee: t.MaxPriorityFee, MaxFee: t.MaxFee, Gas: t.Gas,
To: b.method.Token(), Value: big.NewInt(0), Data: evm.TransferData(t.To, t.Amount)}
raw, hash, err = tx.Sign(b.signer)
if err != nil {
return nil, nil, err
}
w.recordSpendLocked(asset, amount)
return raw, hash, nil
}
67 changes: 67 additions & 0 deletions pkg/wallet/sendevm_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package wallet

import (
"context"
"errors"
"math/big"
"testing"
"time"

"github.com/pilot-protocol/wallet/pkg/evm"
)

// A transfer is signed by the wallet's own key, for its own USDC token,
// within the spend cap, and refused when the host asks for something
// wrong: no recipient, itself, nothing, or gas and fees out of bounds.
func TestSignUSDCTransfer(t *testing.T) {
w := newDualWallet(t)
w.SetSpendCaps(SpendCap{Asset: "USDC", Limit: 3_000_000, Window: 24 * time.Hour})
ctx := context.Background()
chain := w.EVMChainID()
to, _ := evm.ParseAddress("0x000000000000000000000000000000000000bEEF")
ok := func() TransferTx {
return TransferTx{To: to, Amount: big.NewInt(2_000_000), Nonce: 3, Gas: big.NewInt(80_000),
MaxFee: big.NewInt(50_000_000), MaxPriorityFee: big.NewInt(1_000_000)}
}
raw, hash, err := w.SignUSDCTransferOn(ctx, chain, ok())
if err != nil || len(hash) != 32 || raw[0] != 0x02 {
t.Fatalf("sign: %v", err)
}
// It is the same transaction, signed by this wallet, to its token contract.
tx := evm.Tx1559{ChainID: chain, Nonce: 3, MaxPriorityFee: big.NewInt(1_000_000), MaxFee: big.NewInt(50_000_000), Gas: big.NewInt(80_000),
To: w.EVMTokenFor(chain), Value: big.NewInt(0), Data: evm.TransferData(to, big.NewInt(2_000_000))}
if want := evm.Keccak256(raw); string(want) != string(hash) {
t.Fatal("the hash is not the raw transaction's")
}
// Exactly that transaction, signed by this wallet's key (signing is
// deterministic, RFC 6979).
if want, _, err := tx.Sign(w.evmByChain[chain].signer); err != nil || string(want) != string(raw) {
t.Fatalf("not the expected transaction: %v", err)
}
// Over the cap: 2 + 2 > 3 USDC.
if _, _, err := w.SignUSDCTransferOn(ctx, chain, ok()); !errors.Is(err, ErrSpendCapExceeded) {
t.Fatalf("over the cap: %v", err)
}
bad := map[string]func(*TransferTx){
"no recipient": func(x *TransferTx) { x.To = evm.Address{} },
"to itself": func(x *TransferTx) { x.To = w.EVMAddress() },
"nothing": func(x *TransferTx) { x.Amount = big.NewInt(0) },
"too much gas": func(x *TransferTx) { x.Gas = big.NewInt(300_000) },
"an absurd fee": func(x *TransferTx) { x.MaxFee = big.NewInt(200_000_000_000) },
"priority > fee": func(x *TransferTx) { x.MaxPriorityFee = big.NewInt(60_000_000) },
"an unknown chain": nil,
}
for name, f := range bad {
x := ok()
x.Amount = big.NewInt(1)
c := chain
if f == nil {
c = 999999
} else {
f(&x)
}
if _, _, err := w.SignUSDCTransferOn(ctx, c, x); !errors.Is(err, ErrBadTransfer) {
t.Errorf("%s: %v", name, err)
}
}
}
Loading
Loading