diff --git a/cmd/wallet/main.go b/cmd/wallet/main.go index 56d2411..d96a490 100644 --- a/cmd/wallet/main.go +++ b/cmd/wallet/main.go @@ -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) diff --git a/manifest.json b/manifest.json index 73af3a8..186d5b7 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "id": "io.pilot.wallet", - "app_version": "0.4.2", + "app_version": "0.4.3", "manifest_version": 2, "binary": { "runtime": "go", @@ -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", diff --git a/pkg/evm/tx.go b/pkg/evm/tx.go new file mode 100644 index 0000000..f5a71fb --- /dev/null +++ b/pkg/evm/tx.go @@ -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...) +} diff --git a/pkg/evm/tx_test.go b/pkg/evm/tx_test.go new file mode 100644 index 0000000..873db97 --- /dev/null +++ b/pkg/evm/tx_test.go @@ -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") + } +} diff --git a/pkg/wallet/sendevm.go b/pkg/wallet/sendevm.go new file mode 100644 index 0000000..2493139 --- /dev/null +++ b/pkg/wallet/sendevm.go @@ -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 +} diff --git a/pkg/wallet/sendevm_test.go b/pkg/wallet/sendevm_test.go new file mode 100644 index 0000000..bc009d0 --- /dev/null +++ b/pkg/wallet/sendevm_test.go @@ -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) + } + } +} diff --git a/pkg/walletipc/api.go b/pkg/walletipc/api.go index ab4c517..3db9be6 100644 --- a/pkg/walletipc/api.go +++ b/pkg/walletipc/api.go @@ -13,16 +13,16 @@ import "github.com/pilot-protocol/wallet/pkg/wallet" // `ipc.call:wallet.` grants. Keep these constants in sync with // the manifest at /manifest.json. const ( - MethodBalance = "wallet.balance" - MethodBalances = "wallet.balances" - MethodAddress = "wallet.address" - MethodRequest = "wallet.request" - MethodPay = "wallet.pay" - MethodVerify = "wallet.verify" - MethodSettle = "wallet.settle" - MethodTopup = "wallet.topup" - MethodHistory = "wallet.history" - MethodSpendCaps = "wallet.spend_caps" + MethodBalance = "wallet.balance" + MethodBalances = "wallet.balances" + MethodAddress = "wallet.address" + MethodRequest = "wallet.request" + MethodPay = "wallet.pay" + MethodVerify = "wallet.verify" + MethodSettle = "wallet.settle" + MethodTopup = "wallet.topup" + MethodHistory = "wallet.history" + MethodSpendCaps = "wallet.spend_caps" // EVM / x402 surface — only registered when the wallet was // constructed with EVM support. Callers can probe via Has() in @@ -30,8 +30,12 @@ const ( MethodEVMAddress = "wallet.evm.address" MethodEVMBalance = "wallet.evm.balance" MethodEVMSatisfy = "wallet.evm.satisfy" - MethodEVMVerify = "wallet.evm.verify" - MethodEVMChains = "wallet.evm.chains" + // MethodEVMSignTransfer signs a USDC transfer from this wallet as an + // EIP-1559 transaction, gas paid from its own ETH, for the host to + // broadcast. Counted against the spend caps. + MethodEVMSignTransfer = "wallet.evm.sign_transfer" + MethodEVMVerify = "wallet.evm.verify" + MethodEVMChains = "wallet.evm.chains" // Settler surface — only registered when the wallet was wired // with a settler client at startup. Routes IPC calls through @@ -55,7 +59,7 @@ var AllMethods = []string{ // wallet has EVM support. NewDispatcher returns AllMethods + (this set // when applicable). var AllEVMMethods = []string{ - MethodEVMAddress, MethodEVMBalance, MethodEVMSatisfy, MethodEVMVerify, MethodEVMChains, + MethodEVMAddress, MethodEVMBalance, MethodEVMSatisfy, MethodEVMVerify, MethodEVMChains, MethodEVMSignTransfer, } // AllSettlerMethods is the wallet-side settler surface. Like AllEVMMethods, @@ -223,6 +227,27 @@ type EVMSatisfyResp struct { Receipt any `json:"receipt"` // payment.Receipt } +// EVMSignTransferReq asks for a signed USDC transfer. Numbers are decimal +// strings: amount in the token's smallest unit, fees in wei per gas. +type EVMSignTransferReq struct { + ChainID uint64 `json:"chain_id,omitempty"` + To string `json:"to"` + Amount string `json:"amount"` + Nonce uint64 `json:"nonce"` + Gas string `json:"gas"` + MaxFee string `json:"max_fee_per_gas"` + MaxPriorityFee string `json:"max_priority_fee_per_gas"` +} + +type EVMSignTransferResp struct { + RawTx string `json:"raw_tx"` // 0x…, for eth_sendRawTransaction + Hash string `json:"hash"` // the transaction's id on chain + From string `json:"from"` + To string `json:"to"` + Amount string `json:"amount"` + ChainID uint64 `json:"chain_id"` +} + type EVMVerifyReq struct { ChainID uint64 `json:"chain_id,omitempty"` Contract any `json:"contract"` // payment.Contract @@ -296,9 +321,9 @@ type SettlerTransaction struct { } type SettlerTransferReq struct { - To string `json:"to"` // hex ed25519 pubkey - Asset string `json:"asset"` // e.g. "USDC" - Amount uint64 `json:"amount"` // smallest unit + To string `json:"to"` // hex ed25519 pubkey + Asset string `json:"asset"` // e.g. "USDC" + Amount uint64 `json:"amount"` // smallest unit Memo string `json:"memo,omitempty"` ExpiresInSeconds int64 `json:"expires_in_seconds,omitempty"` // 0 → 5 min default } diff --git a/pkg/walletipc/dispatcher_evm.go b/pkg/walletipc/dispatcher_evm.go index 1e88001..110db66 100644 --- a/pkg/walletipc/dispatcher_evm.go +++ b/pkg/walletipc/dispatcher_evm.go @@ -2,12 +2,15 @@ package walletipc import ( "context" + "encoding/hex" "encoding/json" "errors" "fmt" + "math/big" "github.com/pilot-protocol/app-store/pkg/ipc" "github.com/pilot-protocol/app-store/pkg/payment" + "github.com/pilot-protocol/wallet/pkg/evm" "github.com/pilot-protocol/wallet/pkg/wallet" ) @@ -32,6 +35,43 @@ func RegisterEVM(d *ipc.Dispatcher, w *wallet.Wallet) { d.Register(MethodEVMSatisfy, evmSatisfyHandler(w)) d.Register(MethodEVMVerify, evmVerifyHandler(w)) d.Register(MethodEVMChains, evmChainsHandler(w)) + d.Register(MethodEVMSignTransfer, evmSignTransferHandler(w)) +} + +func evmSignTransferHandler(w *wallet.Wallet) ipc.Handler { + return func(ctx context.Context, env *ipc.Envelope) (json.RawMessage, error) { + var req EVMSignTransferReq + if err := json.Unmarshal(env.Payload, &req); err != nil { + return nil, fmt.Errorf("decode sign_transfer req: %w", err) + } + to, err := evm.ParseAddress(req.To) + if err != nil { + return nil, fmt.Errorf("wallet.evm.sign_transfer: to: %w", err) + } + num := func(name, s string) (*big.Int, error) { + n, ok := new(big.Int).SetString(s, 10) + if !ok { + return nil, fmt.Errorf("wallet.evm.sign_transfer: %s must be a decimal number", name) + } + return n, nil + } + t := wallet.TransferTx{To: to, Nonce: req.Nonce} + for _, f := range []struct { + name, s string + dst **big.Int + }{{"amount", req.Amount, &t.Amount}, {"gas", req.Gas, &t.Gas}, {"max_fee_per_gas", req.MaxFee, &t.MaxFee}, {"max_priority_fee_per_gas", req.MaxPriorityFee, &t.MaxPriorityFee}} { + if *f.dst, err = num(f.name, f.s); err != nil { + return nil, err + } + } + chainID := chainOrPrimary(w, req.ChainID) + raw, hash, err := w.SignUSDCTransferOn(ctx, chainID, t) + if err != nil { + return nil, err + } + return encode(EVMSignTransferResp{RawTx: "0x" + hex.EncodeToString(raw), Hash: "0x" + hex.EncodeToString(hash), + From: w.EVMAddress().Hex(), To: to.Hex(), Amount: t.Amount.String(), ChainID: chainID}) + } } // chainOrPrimary returns the requested chain id when non-zero, or the diff --git a/pkg/walletipc/help.go b/pkg/walletipc/help.go index 8b8cff3..7b7b80a 100644 --- a/pkg/walletipc/help.go +++ b/pkg/walletipc/help.go @@ -46,6 +46,7 @@ var helpEVM = []helpMethod{ {MethodEVMAddress, "This wallet's EVM address on a chain.", map[string]string{"chain_id": "int — omit for the primary chain"}, "fast"}, {MethodEVMBalance, "USDC balance on a chain.", map[string]string{"chain_id": "int — omit for the primary chain"}, "med"}, {MethodEVMSatisfy, "Pay an x402 payment contract with an EIP-3009 authorization. Enforced against spend caps.", map[string]string{"contract": "object (required) — the x402 payment contract", "chain_id": "int"}, "med"}, + {MethodEVMSignTransfer, "Sign a USDC transfer from this wallet as an EIP-1559 transaction, gas paid from its own ETH; the caller broadcasts the raw transaction. Enforced against spend caps.", map[string]string{"to": "string (required) — recipient address", "amount": "string (required) — USDC in its smallest unit", "nonce": "int (required)", "gas": "string (required) — at most 200000", "max_fee_per_gas": "string (required) — wei, at most 100 gwei", "max_priority_fee_per_gas": "string (required) — wei", "chain_id": "int"}, "fast"}, {MethodEVMVerify, "Verify an x402 receipt against its contract.", map[string]string{"contract": "object (required)", "receipt": "object (required)", "chain_id": "int"}, "med"}, } diff --git a/tools/txref.py b/tools/txref.py new file mode 100644 index 0000000..5f51690 --- /dev/null +++ b/tools/txref.py @@ -0,0 +1,16 @@ +# Prints the reference raw transaction for pkg/evm/tx_test.go, signed by an +# independent implementation (eth-account), so the Go signer is checked +# against it: +# +# TX_REFERENCE=$(uv run -q --with eth-account==0.13.4 python tools/txref.py) go test ./pkg/evm/ +from eth_account import Account + +KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' # Hardhat's first test key +USDC_BASE = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' +PAYEE = '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' + +selector = bytes.fromhex('a9059cbb') # transfer(address,uint256) +data = selector + bytes(12) + bytes.fromhex(PAYEE[2:]) + (250000).to_bytes(32, 'big') +tx = {'type': 2, 'chainId': 8453, 'nonce': 7, 'maxPriorityFeePerGas': 1_000_000, 'maxFeePerGas': 50_000_000, + 'gas': 80_000, 'to': USDC_BASE, 'value': 0, 'data': '0x' + data.hex(), 'accessList': []} +print('0x' + Account.sign_transaction(tx, KEY).raw_transaction.hex().removeprefix('0x'))