diff --git a/examples/README.md b/examples/README.md index 5628b91..91e3b85 100644 --- a/examples/README.md +++ b/examples/README.md @@ -33,6 +33,9 @@ Refer to each example's README for the exact commands. - [sign_raw_payload](./signing/sign_raw_payload/): Example of signing a raw payload. - [sign_transaction](./signing/sign_transaction/): Example of signing a blockchain transaction. +- [transaction_management](./transaction_management/): Demonstrates sending sponsored and non-sponsored transactions. + - [ethereum](./transaction_management/ethereum/): Send, swap, and list assets on Ethereum. + - [wallets](./wallets/): Shows how to manage wallets and sign transactions with them. - [create_wallet](./wallets/create_wallet/): Example of creating a new wallet. - [create_wallet_accounts](./wallets/create_wallet_accounts/): Example of creating accounts within a wallet. diff --git a/examples/transaction_management/ethereum/.env.example b/examples/transaction_management/ethereum/.env.example new file mode 100644 index 0000000..b35ee85 --- /dev/null +++ b/examples/transaction_management/ethereum/.env.example @@ -0,0 +1,7 @@ +TURNKEY_API_PRIVATE_KEY='YOUR_API_PRIVATE_KEY' +TURNKEY_ORGANIZATION_ID='YOUR_ORGANIZATION_ID' +TURNKEY_SIGN_WITH='YOUR_WALLET_ADDRESS' +# Optional. One of: send (default), swap, assets. +# ACTION='send' +# Optional. Set to 'false' for non-sponsored mode (the EOA pays gas). +# SPONSOR='true' diff --git a/examples/transaction_management/ethereum/README.md b/examples/transaction_management/ethereum/README.md new file mode 100644 index 0000000..05fdc87 --- /dev/null +++ b/examples/transaction_management/ethereum/README.md @@ -0,0 +1,149 @@ +# Transaction Management: Ethereum + +Demonstrates Turnkey's [transaction management](https://docs.turnkey.com/features/transaction-management) feature on Ethereum, in both sponsored and non-sponsored modes. + +## Actions + +Select the action with the `ACTION` environment variable: + +- **send** (default) — self-transfer of 0.0001 ETH +- **swap** — Uniswap V3 swap (ETH → USDC) via SwapRouter02 +- **assets** — list supported assets for Ethereum Sepolia + +The `send` and `swap` actions use Turnkey Gas Station for gas sponsorship by default. Set `SPONSOR=false` to use non-sponsored mode (the EOA pays gas). + +## Prerequisites + +- A Turnkey organization entitled to use the Transaction Management feature +- A wallet with Ethereum Sepolia testnet ETH +- A Turnkey API key (P-256) + +## Configuration + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `TURNKEY_API_PRIVATE_KEY` | Yes | | Turnkey API private key | +| `TURNKEY_ORGANIZATION_ID` | Yes | | Turnkey organization ID | +| `TURNKEY_SIGN_WITH` | Yes | | Wallet address to sign with (0x-prefixed) | +| `ACTION` | No | `send` | Action to perform: `send`, `swap`, or `assets` | +| `SPONSOR` | No | `true` | Set to `false` for non-sponsored mode | + +## Setup + +Copy `.env.example` to `.env` and fill in the values: + +```bash +cp examples/transaction_management/ethereum/.env.example examples/transaction_management/ethereum/.env +``` + +## Running + +```bash +# Send 0.0001 ETH to self (sponsored, the default) +set -a && source examples/transaction_management/ethereum/.env && set +a && \ + go run ./examples/transaction_management/ethereum + +# Swap 0.0001 ETH → USDC via Uniswap V3 (sponsored) +set -a && source examples/transaction_management/ethereum/.env && set +a && \ + ACTION=swap go run ./examples/transaction_management/ethereum + +# Send 0.0001 ETH to self (non-sponsored) +set -a && source examples/transaction_management/ethereum/.env && set +a && \ + SPONSOR=false go run ./examples/transaction_management/ethereum + +# List supported assets +set -a && source examples/transaction_management/ethereum/.env && set +a && \ + ACTION=assets go run ./examples/transaction_management/ethereum +``` + +Inline variables (`ACTION=swap`, `SPONSOR=false`) override the values sourced from `.env` for that run. + +## Example output + +Sample output; balances are abridged for readability and will differ for your wallet. + +``` +$ set -a && source examples/transaction_management/ethereum/.env && set +a && \ + go run ./examples/transaction_management/ethereum +Balances for 0x73e8…A666: + 0.1999 ETH ($0.00) + +Action: send sponsored ETH self-transfer +Gas station nonce: 1 +Transaction submitted, status ID: sha256:5873669520255f06c6617aba4c57cc3ccb4db8a9aba85dfd042fd1f5af108dfb +Polling for confirmation... + Status: INITIALIZED + Status: BROADCASTING +Send complete! Tx hash: 0xb36294c0e0924064f0c527ad651aeef854edc35adae3c43ead0b12d6dae17586 +Balances for 0x73e8…A666: + 0.1999 ETH ($0.00) +``` + +The ETH balance is unchanged: the self-transfer returns the value to the same address, and Gas Station paid the fee. Run the same command with `SPONSOR=false` and the balance drops by the gas cost. + +``` +$ set -a && source examples/transaction_management/ethereum/.env && set +a && \ + ACTION=swap go run ./examples/transaction_management/ethereum +Balances for 0x73e8…A666: + 0.1999 ETH ($0.00) + +Action: swap ETH → USDC via Uniswap V3 (sponsored) +Gas station nonce: 2 +Transaction submitted, status ID: sha256:821511500618ed363444adf86b56bc5abf9389b5a94ed473c480d978d4ec424c +Polling for confirmation... + Status: INITIALIZED + Status: BROADCASTING +Swap complete! Tx hash: 0xc35364a6a000d8ba516c986d0f4fbd29cb0dcd3802417e8436bbdcd6d47574a1 +Balances for 0x73e8…A666: + 0.1998 ETH ($0.00) + 2.6379 USDC ($0.00) +``` + +If a transaction reverts, the failure is reported with the contract revert chain, outermost call first: + +``` +transaction failed: Pre-flight simulation failed: Too little received -> ExecutionFailed() -> ExecutionFailed() + [0] Too little received (at 0x3bfa4769fb09eefc5a80d6e87c3b9c650f7ae48e) + [1] ExecutionFailed() (at 0x73e8aebbad9ac514bca7169f9866e1aa08d5a666) + [2] ExecutionFailed() (at 0x5af5194b4b0909eb978e3cf1e25333852277f07d) +``` + +## How it works + +1. Creates a Turnkey client from the API key stamper. +2. Fetches and displays wallet balances via `GetWalletAddressBalances`. +3. **Sponsored mode** (`SPONSOR=true`, default): fetches a gas station nonce via `GetNonces` for ordering and replay protection. +4. **Non-sponsored mode** (`SPONSOR=false`): Turnkey resolves the on-chain nonce automatically. +5. Submits the transaction via `ETHSendTransaction`. +6. Polls `GetSendTransactionStatus` until a tx hash is returned, an error occurs, or the example's 60-second timeout elapses. On failure, the structured error is rendered with the contract revert chain when one is available. +7. Fetches and displays updated balances. In sponsored mode a self-transfer leaves the balance unchanged — that is the point: the value returns to the sender and Gas Station covers the fee. + +For the **swap** action, the calldata is ABI-encoded for Uniswap V3 [SwapRouter02](https://docs.uniswap.org/contracts/v3/reference/deployments/ethereum-deployments)'s `exactInputSingle` (selector `0x04e45aaf`), targeting the WETH/USDC pool with a 0.3% fee tier. Those contracts are Sepolia-only, so the chain (`eip155:11155111`) is hardcoded alongside them. + +**Warning:** the swap sets `amountOutMinimum` to `0`, so it accepts any output amount, however unfavourable. That keeps the example simple on a testnet with thin liquidity, but it must not be copied to mainnet, where it invites sandwich attacks. Derive a real minimum from a quote before adapting this code. + +## Nonce handling + +There are two distinct nonces involved in transaction management: + +- **On-chain nonce** — the standard Ethereum transaction nonce. Turnkey resolves this automatically in both sponsored and non-sponsored modes; you never need to provide it. To manage it yourself, fetch it via `GetNonces` with `Nonce: ptr(true)` and set `req.Nonce` (see example below). Custom on-chain nonces are not compatible with sponsored transactions. + +- **Gas station nonce** — a nonce specific to the gas station delegate contract, only relevant for sponsored transactions (`SPONSOR=true`). Turnkey handles this internally if omitted, but passing it explicitly provides maximal security against replay attacks: it ensures a signed request can only produce a single transaction. See the [Gas Station security docs](https://docs.turnkey.com/features/transaction-management#security). This example passes it explicitly. + +### Providing a custom on-chain nonce + +```go +resp, err := client.GetNonces(ctx, turnkey.GetNoncesRequest{ + OrganizationID: cfg.organizationID, + Address: cfg.signWith, + Caip2: sepoliaCaip2, + Nonce: ptr(true), +}) +if err != nil { + return err +} + +req.Nonce = resp.Nonce +``` + +**Note:** if you run multiple sponsored transactions back-to-back, wait for the previous one to confirm before sending the next — otherwise the gas station nonce may not have incremented yet, causing an `InvalidNonce` error. diff --git a/examples/transaction_management/ethereum/main.go b/examples/transaction_management/ethereum/main.go new file mode 100644 index 0000000..177db43 --- /dev/null +++ b/examples/transaction_management/ethereum/main.go @@ -0,0 +1,568 @@ +// Package main demonstrates Ethereum transaction management with Turnkey. +// +// It supports three actions, selected via the ACTION environment variable: +// - send: ETH self-transfer (default) +// - swap: Uniswap V3 swap (ETH → USDC) +// - assets: list supported assets for Ethereum Sepolia +// +// The send and swap actions use Turnkey Gas Station for gas sponsorship by +// default. Set SPONSOR=false to use non-sponsored mode (the EOA pays gas). +package main + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "log" + "math/big" + "os" + "strconv" + "strings" + "time" + + turnkey "github.com/tkhq/go-sdk/v2" +) + +// Ethereum Sepolia chain ID, Uniswap V3 SwapRouter02 and token addresses. +// The chain is hardcoded because the swap contracts below are Sepolia-only. +// SwapRouter02 docs: https://docs.uniswap.org/contracts/v3/reference/deployments/ethereum-deployments +const ( + sepoliaCaip2 = "eip155:11155111" + sepoliaSwapRouter02 = "0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E" + sepoliaWETH = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14" + sepoliaUSDC = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" + + // selfTransferWei is 0.0001 ETH in wei: the amount the send action transfers + // and the swap action swaps. Both the string and big.Int forms are derived + // from this one constant so the two paths cannot drift apart. + selfTransferWei = 100_000_000_000_000 +) + +// config holds the example's runtime settings, read from the environment. +type config struct { + apiPrivateKey string + organizationID string + signWith string + action string + sponsor bool +} + +func main() { + cfg, err := loadConfig() + if err != nil { + log.Fatal(err) + } + + stamper, err := turnkey.NewAPIKeyStamper(cfg.apiPrivateKey) + if err != nil { + log.Fatal("failed to create stamper:", err) + } + + client, err := turnkey.NewClient(stamper, cfg.organizationID) + if err != nil { + log.Fatal("failed to create Turnkey client:", err) + } + + if err := run(context.Background(), client, cfg); err != nil { + log.Fatal(err) + } +} + +// loadConfig reads settings from the environment and validates them. +func loadConfig() (config, error) { + // Parse SPONSOR rather than comparing against "false", so that values like + // "False", "0" or "f" are honored instead of silently sponsoring. + sponsorEnv := envOr("SPONSOR", "true") + sponsor, err := strconv.ParseBool(sponsorEnv) + if err != nil { + return config{}, fmt.Errorf("invalid SPONSOR %q: must be a boolean, e.g. true, false, 1 or 0", sponsorEnv) + } + + cfg := config{ + apiPrivateKey: os.Getenv("TURNKEY_API_PRIVATE_KEY"), + organizationID: os.Getenv("TURNKEY_ORGANIZATION_ID"), + signWith: os.Getenv("TURNKEY_SIGN_WITH"), + action: envOr("ACTION", "send"), + sponsor: sponsor, + } + + if cfg.apiPrivateKey == "" || cfg.organizationID == "" || cfg.signWith == "" { + return config{}, errors.New("TURNKEY_API_PRIVATE_KEY, TURNKEY_ORGANIZATION_ID, and TURNKEY_SIGN_WITH are required") + } + if cfg.action != "send" && cfg.action != "swap" && cfg.action != "assets" { + return config{}, fmt.Errorf("invalid ACTION %q: must be 'send', 'swap', or 'assets'", cfg.action) + } + + return cfg, nil +} + +// run dispatches to the selected action, printing balances before and after +// send/swap transactions. +func run(ctx context.Context, client *turnkey.Client, cfg config) error { + if cfg.action == "assets" { + return listSupportedAssets(ctx, client, cfg) + } + + if err := printBalances(ctx, client, cfg); err != nil { + return err + } + + var err error + switch cfg.action { + case "send": + err = sendETH(ctx, client, cfg) + case "swap": + err = swapETH(ctx, client, cfg) + default: + return fmt.Errorf("unknown action: %q", cfg.action) + } + if err != nil { + return err + } + + return printBalances(ctx, client, cfg) +} + +// listSupportedAssets lists all supported assets for Ethereum Sepolia. +func listSupportedAssets(ctx context.Context, client *turnkey.Client, cfg config) error { + resp, err := client.ListSupportedAssets(ctx, turnkey.ListSupportedAssetsRequest{ + OrganizationID: cfg.organizationID, + Caip2: sepoliaCaip2, + }) + if err != nil { + return apiError("failed to list supported assets", err) + } + + fmt.Printf("Supported assets for %s:\n", sepoliaCaip2) + if len(resp.Assets) == 0 { + fmt.Println(" (none)") + } + for _, a := range resp.Assets { + fmt.Printf(" %s (decimals: %d, caip19: %s)\n", str(a.Symbol), intVal(a.Decimals), str(a.Caip19)) + } + + return nil +} + +// printBalances fetches and displays the wallet's asset balances. +func printBalances(ctx context.Context, client *turnkey.Client, cfg config) error { + resp, err := client.GetWalletAddressBalances(ctx, turnkey.GetWalletAddressBalancesRequest{ + OrganizationID: cfg.organizationID, + Address: cfg.signWith, + Caip2: sepoliaCaip2, + }) + if err != nil { + return apiError("failed to get balances", err) + } + + fmt.Printf("Balances for %s:\n", cfg.signWith) + if len(resp.Balances) == 0 { + fmt.Println(" (no balances)") + } + for _, b := range resp.Balances { + fmt.Printf(" %s\n", formatBalance(b)) + } + fmt.Println() + + return nil +} + +// formatBalance renders a single asset balance, preferring the display values +// when present and falling back to raw atomic units. +func formatBalance(b turnkey.AssetBalance) string { + symbol := str(b.Symbol) + if b.Display != nil && b.Display.Crypto != nil && *b.Display.Crypto != "" { + line := fmt.Sprintf("%s %s", *b.Display.Crypto, symbol) + if b.Display.Usd != nil && *b.Display.Usd != "" { + line += fmt.Sprintf(" ($%s)", *b.Display.Usd) + } + return line + } + return fmt.Sprintf("%s %s (decimals: %d)", str(b.Balance), symbol, intVal(b.Decimals)) +} + +// sendETH sends a self-transfer of 0.0001 ETH. +func sendETH(ctx context.Context, client *turnkey.Client, cfg config) error { + fmt.Printf("Action: send %s ETH self-transfer\n", sponsorLabel(cfg.sponsor)) + + calls := []turnkey.ETHCallParams{{ + To: cfg.signWith, + Value: ptr(selfTransferValue()), + }} + + txHash, err := submitAndWait(ctx, client, cfg, calls) + if err != nil { + return err + } + + fmt.Printf("Send complete! Tx hash: %s\n", txHash) + return nil +} + +// swapETH performs a Uniswap V3 swap of 0.0001 ETH → USDC. +func swapETH(ctx context.Context, client *turnkey.Client, cfg config) error { + fmt.Printf("Action: swap ETH → USDC via Uniswap V3 (%s)\n", sponsorLabel(cfg.sponsor)) + + calldata, err := encodeExactInputSingle( + sepoliaWETH, + sepoliaUSDC, + 3000, // 0.3% fee tier + cfg.signWith, + big.NewInt(selfTransferWei), + big.NewInt(0), // amountOutMinimum (0 for demo) + big.NewInt(0), // sqrtPriceLimitX96 + ) + if err != nil { + return fmt.Errorf("failed to encode swap calldata: %w", err) + } + + calls := []turnkey.ETHCallParams{{ + To: sepoliaSwapRouter02, + Value: ptr(selfTransferValue()), // msg.value for ETH→token swap + Data: ptr("0x" + hex.EncodeToString(calldata)), + }} + + txHash, err := submitAndWait(ctx, client, cfg, calls) + if err != nil { + return err + } + + fmt.Printf("Swap complete! Tx hash: %s\n", txHash) + return nil +} + +// submitAndWait submits an ETH transaction and polls until it confirms. +func submitAndWait(ctx context.Context, client *turnkey.Client, cfg config, calls []turnkey.ETHCallParams) (string, error) { + req := turnkey.ETHSendTransactionRequest{ + OrganizationID: cfg.organizationID, + From: cfg.signWith, + Caip2: sepoliaCaip2, + Calls: calls, + Sponsor: ptr(cfg.sponsor), + } + + // For sponsored transactions, pass the gas station nonce explicitly for + // maximal replay protection. Turnkey resolves the on-chain nonce + // automatically in both modes. + if cfg.sponsor { + nonce, err := getGasStationNonce(ctx, client, cfg) + if err != nil { + return "", err + } + req.GasStationNonce = nonce + } + + resp, err := client.ETHSendTransaction(ctx, req) + if err != nil { + return "", apiError("failed to submit transaction", err) + } + + fmt.Printf("Transaction submitted, status ID: %s\n", resp.SendTransactionStatusID) + fmt.Println("Polling for confirmation...") + + return pollTransactionStatus(ctx, client, cfg, resp.SendTransactionStatusID) +} + +// getGasStationNonce fetches the gas station delegate contract nonce, used to +// order and replay-protect sponsored transactions. +// +// This is optional when sponsor=true (Turnkey handles it internally if +// omitted), but including it explicitly provides maximal security against +// replay attacks: a signed request can then only produce a single transaction, +// even if infrastructure outside the enclave is compromised. +// See: https://docs.turnkey.com/features/transaction-management#security +// +// Note: if you run multiple transactions back-to-back, wait for the previous +// one to confirm before sending the next — otherwise the gas station nonce may +// not have incremented yet, causing an InvalidNonce error. +func getGasStationNonce(ctx context.Context, client *turnkey.Client, cfg config) (*string, error) { + resp, err := client.GetNonces(ctx, turnkey.GetNoncesRequest{ + OrganizationID: cfg.organizationID, + Address: cfg.signWith, + Caip2: sepoliaCaip2, + GasStationNonce: ptr(true), + }) + if err != nil { + return nil, apiError("failed to get nonces", err) + } + if resp.GasStationNonce == nil { + return nil, errors.New("gas station nonce not returned (is gas station enabled for this org?)") + } + + fmt.Printf("Gas station nonce: %s\n", *resp.GasStationNonce) + return resp.GasStationNonce, nil +} + +// pollTransactionStatus polls the send transaction status until a tx hash is +// returned, an error occurs, or the timeout elapses. +func pollTransactionStatus(ctx context.Context, client *turnkey.Client, cfg config, statusID string) (string, error) { + const ( + pollInterval = 2 * time.Second + timeout = 60 * time.Second + ) + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + resp, err := client.GetSendTransactionStatus(ctx, turnkey.GetSendTransactionStatusRequest{ + OrganizationID: cfg.organizationID, + SendTransactionStatusID: statusID, + }) + if err != nil { + return "", apiError("failed to get transaction status", err) + } + + if hash, done, err := txResult(resp); done { + return hash, err + } + + if resp.TxStatus != "" { + fmt.Printf(" Status: %s\n", resp.TxStatus) + } + time.Sleep(pollInterval) + } + + return "", fmt.Errorf("timed out waiting for transaction confirmation after %s", timeout) +} + +// txResult reports whether a poll response is terminal, returning the tx hash +// and, on failure, an error. +// +// The hash is read before the failure check because the two are not exclusive: a +// transaction that is included on chain and then reverts reports both, and the +// hash is what lets you look the revert up in an explorer. +func txResult(resp *turnkey.GetSendTransactionStatusResponse) (hash string, done bool, err error) { + if resp.ETH != nil && resp.ETH.TxHash != nil { + hash = *resp.ETH.TxHash + } + + if msg := formatTxError(resp); msg != "" { + if hash != "" { + return hash, true, fmt.Errorf("transaction failed (tx hash: %s): %s", hash, msg) + } + return "", true, fmt.Errorf("transaction failed: %s", msg) + } + if hash != "" { + return hash, true, nil + } + + return "", false, nil +} + +// formatTxError renders a transaction failure, preferring the structured error +// (which carries the contract revert chain) over the flat txError string. It +// returns "" when the response reports no failure. +// +// The revert chain is the useful signal for the swap action, where a revert from +// slippage or thin pool liquidity is the likely failure mode. +func formatTxError(resp *turnkey.GetSendTransactionStatusResponse) string { + flat := str(resp.TxError) + if resp.Error == nil { + return flat + } + + // The chain is reported either at the top level or under the chain-specific + // failure details, depending on where the failure was detected. + chain := resp.Error.RevertChain + if len(chain) == 0 && resp.Error.ETH != nil { + chain = resp.Error.ETH.RevertChain + } + + msg := str(resp.Error.Message) + if msg == "" { + msg = flat + } + if msg == "" { + if len(chain) == 0 { + return "" + } + msg = "transaction reverted" + } + + var b strings.Builder + b.WriteString(msg) + for i, entry := range chain { + fmt.Fprintf(&b, "\n [%d] %s", i, revertEntry(entry)) + } + + return b.String() +} + +// revertEntry renders a single frame of the revert chain, appending the +// contract address when the entry reports one. +func revertEntry(e turnkey.RevertChainEntry) string { + desc := str(e.DisplayMessage) + if desc == "" { + desc = revertDescription(e) + } + if addr := str(e.Address); addr != "" { + desc += fmt.Sprintf(" (at %s)", addr) + } + + return desc +} + +// revertDescription falls back through the decoded error variants, for entries +// that carry no display message. +func revertDescription(e turnkey.RevertChainEntry) string { + switch { + case e.Native != nil && e.Native.Message != nil: + return *e.Native.Message + case e.Native != nil && e.Native.PanicCode != nil: + return fmt.Sprintf("panic(%s)", *e.Native.PanicCode) + case e.Custom != nil && e.Custom.ErrorName != nil: + return fmt.Sprintf("%s(%s)", *e.Custom.ErrorName, str(e.Custom.ParamsJSON)) + case e.Unknown != nil && e.Unknown.Selector != nil: + return fmt.Sprintf("unrecognized revert, selector %s", *e.Unknown.Selector) + default: + return "unrecognized revert" + } +} + +// encodeExactInputSingle ABI-encodes a call to SwapRouter02.exactInputSingle. +// +// Function signature (SwapRouter02 — no deadline field): +// +// exactInputSingle((address tokenIn, address tokenOut, uint24 fee, address recipient, uint256 amountIn, uint256 amountOutMinimum, uint160 sqrtPriceLimitX96)) +// +// Selector: 0x04e45aaf +func encodeExactInputSingle( + tokenIn, tokenOut string, + fee uint32, + recipient string, + amountIn, amountOutMinimum, sqrtPriceLimitX96 *big.Int, +) ([]byte, error) { + selector, err := hex.DecodeString("04e45aaf") + if err != nil { + return nil, fmt.Errorf("failed to decode selector: %w", err) + } + + data := make([]byte, 4+7*32) // selector + 7 ABI-encoded words + copy(data[0:4], selector) + + // Word 0: tokenIn (address, left-padded to 32 bytes) + tokenInBytes, err := addressToBytes(tokenIn) + if err != nil { + return nil, err + } + copy(data[4+12:4+32], tokenInBytes) + // Word 1: tokenOut + tokenOutBytes, err := addressToBytes(tokenOut) + if err != nil { + return nil, err + } + copy(data[4+32+12:4+64], tokenOutBytes) + // Word 2: fee (uint24) + if err := padBigInt(data[4+64:4+96], new(big.Int).SetUint64(uint64(fee))); err != nil { + return nil, fmt.Errorf("fee: %w", err) + } + // Word 3: recipient + recipientBytes, err := addressToBytes(recipient) + if err != nil { + return nil, err + } + copy(data[4+96+12:4+128], recipientBytes) + // Word 4: amountIn + if err := padBigInt(data[4+128:4+160], amountIn); err != nil { + return nil, fmt.Errorf("amountIn: %w", err) + } + // Word 5: amountOutMinimum + if err := padBigInt(data[4+160:4+192], amountOutMinimum); err != nil { + return nil, fmt.Errorf("amountOutMinimum: %w", err) + } + // Word 6: sqrtPriceLimitX96 + if err := padBigInt(data[4+192:4+224], sqrtPriceLimitX96); err != nil { + return nil, fmt.Errorf("sqrtPriceLimitX96: %w", err) + } + + return data, nil +} + +// addressToBytes converts a 0x-prefixed hex address to a 20-byte slice. +// +// The length check matters: a short address would be copied into the low-order +// end of its ABI word and silently encode as a different, valid-looking address. +func addressToBytes(addr string) ([]byte, error) { + b, err := hex.DecodeString(strings.TrimPrefix(addr, "0x")) + if err != nil { + return nil, fmt.Errorf("invalid address %q: %w", addr, err) + } + if len(b) != 20 { + return nil, fmt.Errorf("invalid address %q: got %d bytes, want 20", addr, len(b)) + } + + return b, nil +} + +// padBigInt writes a non-negative big.Int right-aligned into an ABI word. +// +// It reports an error rather than truncating or taking an absolute value. This +// is transaction calldata: a silently mangled amount would still encode as a +// well-formed word and would be signed and broadcast as if correct. +func padBigInt(dst []byte, v *big.Int) error { + if v.Sign() < 0 { + return fmt.Errorf("cannot encode negative value %s as an unsigned integer", v) + } + + b := v.Bytes() + if len(b) > len(dst) { + return fmt.Errorf("value %s overflows a %d-byte ABI word (needs %d bytes)", v, len(dst), len(b)) + } + copy(dst[len(dst)-len(b):], b) + + return nil +} + +// selfTransferValue renders selfTransferWei as the decimal string the +// ETHCallParams value field expects. +func selfTransferValue() string { + return strconv.FormatInt(selfTransferWei, 10) +} + +// sponsorLabel returns a human-readable label for the sponsorship mode. +func sponsorLabel(sponsor bool) string { + if sponsor { + return "sponsored" + } + return "non-sponsored" +} + +// apiError annotates a failed SDK call with the HTTP status and response body +// when the failure came from the Turnkey API. The body carries the actionable +// detail — for example, that the organization is not entitled to use +// transaction management, or that Gas Station is not enabled for it. +func apiError(op string, err error) error { + var reqErr *turnkey.RequestError + if errors.As(err, &reqErr) { + return fmt.Errorf("%s (status=%d): %s", op, reqErr.StatusCode, reqErr.Body) + } + + return fmt.Errorf("%s: %w", op, err) +} + +// envOr returns the environment variable value, or fallback if unset/empty. +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +// ptr returns a pointer to v, for populating optional request fields. +func ptr[T any](v T) *T { return &v } + +// str safely dereferences an optional string. +func str(p *string) string { + if p == nil { + return "" + } + return *p +} + +// intVal safely dereferences an optional int. +func intVal(p *int) int { + if p == nil { + return 0 + } + return *p +}