> ## Documentation Index
> Fetch the complete documentation index at: https://docs.goldsky.com/llms.txt
> Use this file to discover all available pages before exploring further.

# evm Wallets

> Create and use Compose smart wallets, EOA private-key wallets, webhook wallets, and impersonated wallets for blockchain transactions.

There are several types of wallets and wallet behaviors that compose supports.

## Smart wallets

If you just need to interact with smart contracts, then the easiest thing to do is just build one of our smart wallets.  You'll be able to see
all of your wallets in the dashboard at `https://app.goldsky.com/{projectId}/dashboard/compose/{appName}`.

### Create a smart wallet

```typescript theme={null}
import { TaskContext } from "compose";

export async function main({ evm, env }: TaskContext) {
  // this is idempotent so the wallet is only created the first time this is called and is "retrieved" after that.
  // passing no args will create a "default" wallet for your app
  const wallet = await evm.wallet();
  console.log(wallet.address);

  // you can create multiple named wallets too.  This will generate multiple saved smart wallets that can be referenced by name in different 
  // tasks and task runs
  const walletOne = await evm.wallet({ name: "wallet-one" });
  const walletTwo = await evm.wallet({ name: "wallet-two" });

  // private key wallet
  const privateKeyWallet = await evm.wallet({ privateKey: env.MY_KEY });

  // now you can use these wallet to make transactions (see below for details)
}
```

### Using wallets

Once you have a wallet created you can use it to write to smart contracts, see the full [smart contract docs](./contracts) for more details

```typescript theme={null}
import { TaskContext } from "compose";

export async function main({ evm, env, fetch }: TaskContext) {
  const wallet = await evm.wallet();

  const response = await fetch<{ bitcoin: { usd: number } }>(
    "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd"
  );

  // Convert timestamp and price to bytes32 format
  const timestamp = Date.now();
  const bitcoinPrice = response.bitcoin.usd;
  const timestampAsBytes32 = `0x${timestamp.toString(16).padStart(64, "0")}`;
  const priceAsBytes32 = `0x${Math.round(bitcoinPrice * 100).toString(16).padStart(64, "0")}`;

  const bitcoinOracleContract = new evm.contracts.BitcoinOracleContract(
    env.ORACLE_ADDRESS,
    evm.chains.base,
    wallet
  );
  const { hash } = await bitcoinOracleContract.setPrice(
    timestampAsBytes32,
    priceAsBytes32
  );
}
```

You can also make or simulate transactions with methods on the Wallet class:

```typescript theme={null}
import { TaskContext } from "compose";

export async function main({ evm, env }: TaskContext) {
  const wallet = await evm.wallet();

  const resultId = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
  const payouts = [1000n, 2000n, 3000n];

  const { hash, receipt, userOpHash } = await wallet.writeContract(
    evm.chains.polygon,
    env.CONTRACT_ADDRESS as `0x${string}`,
    "reportPayouts(bytes32,uint256[])",
    [resultId, payouts],
    {
      confirmations: 3, // this will not resolve the promise until the transaction has been seen in 3 blocks
      onReorg: {
        // this will replay the transaction with new nonce and new gas if it's reorged later on after the three confirmations have passed
        // see "Reorg Handling" for more info
        action: {
          type: "replay",
        },
        depth: 200,
      },
    }
  );

  // userOpHash is set for gas-sponsored transactions (ERC-4337 UserOperation hash)
  // useful for debugging on bundler explorers
  if (userOpHash) {
    console.log(`UserOp hash: ${userOpHash}`);
  }
}
```

### sendTransaction

For lower-level control, use `sendTransaction` to send a transaction with pre-encoded calldata. This is useful when you need to encode the transaction data yourself, pass specific gas parameters, or interact with contracts in ways that `writeContract` doesn't cover.

```typescript theme={null}
import { TaskContext } from "compose";

export async function main({ evm, env }: TaskContext) {
  const wallet = await evm.wallet();

  // encode your calldata however you like
  const data = "0x..." as `0x${string}`;

  const { hash, receipt } = await wallet.sendTransaction(
    {
      to: env.CONTRACT_ADDRESS as `0x${string}`,
      data,
      chain: evm.chains.ethereum,
    },
    { confirmations: 3 }
  );

  console.log(`Transaction confirmed: ${hash}, status: ${receipt.status}`);
}
```

You can also pass explicit gas parameters for full control over fees and gas limits:

```typescript theme={null}
const { hash, receipt } = await wallet.sendTransaction(
  {
    to: env.CONTRACT_ADDRESS as `0x${string}`,
    data,
    chain: evm.chains.ethereum,
    value: 0n,                        // ETH value to send with the transaction
    gas: 500000n,                     // gas limit
    maxFeePerGas: 30000000000n,       // EIP-1559 max fee per gas
    maxPriorityFeePerGas: 1000000000n, // EIP-1559 priority fee
    nonce: 42,                        // explicit nonce (EOA wallets only)
  },
  {
    confirmations: 5,
    onReorg: {
      depth: 200,
      action: { type: "replay" },
    },
  }
);
```

<Note>
  The `nonce` parameter is only supported with EOA (private key) wallets. Smart wallets manage nonces internally.
</Note>

### getBalance

Check the native token balance of any wallet:

```typescript theme={null}
import { TaskContext } from "compose";

export async function main({ evm }: TaskContext) {
  const wallet = await evm.wallet();

  // Returns balance in wei as a string
  const balance = await wallet.getBalance(evm.chains.ethereum);
  console.log(`Balance: ${balance} wei`);
}
```

### Wallet properties

Every wallet exposes `name` and `address` as read-only properties:

```typescript theme={null}
const wallet = await evm.wallet({ name: "my-wallet" });
console.log(wallet.name);    // "my-wallet"
console.log(wallet.address); // "0x..."
```

## EOA wallets

You can also use EOAs that you already own, this allows you to self-fund gas, send and receive tokens in your tasks, and interact with smart contracts
in which an EOA you already own has privileges on particular contract methods. Currently we support storing your EOA private key in Compose's secret
management system, but in the future you'll be able to use private keys that are secured within TEEs.  EOA wallets never pass their private keys outside of
the task process and they sign requests passed in unsigned from the host process.  When tasks run in TEEs they'll be able to use private keys very
securely within the TEE, never exposing it to any part of the stack outside of the TEE.  This will empower Compose to run the most security sensitive
use cases.

First, you'll need to store the private key in Goldsky's secret management system and reference it in your compose.yaml file.  You can see details on
how to do that in the [Secrets docs](../../secrets). Once you have your private key secret stored, you can use it to create a wallet:

```typescript theme={null}
import { TaskContext } from "compose";

export async function main({ evm, env }: TaskContext) {
  const privateKeyWallet = await evm.wallet({ privateKey: env.MY_PRIVATE_KEY_SECRET });

  // now you can use the wallet the same as any other wallet
}
```

## Webhook wallets

`evm.webhookWallet` sends gas-sponsored transactions from an address you already hold. Compose never sees the private key. It builds the ERC-4337 UserOperation, including paymaster data, POSTs that payload to a signing URL you host, then submits the signed operation.

### Create a webhook wallet

```typescript theme={null}
import { TaskContext } from "compose";

export async function main({ evm, env }: TaskContext) {
  const wallet = await evm.webhookWallet({
    url: env.SIGN_WEBHOOK_URL,
    address: env.WALLET_ADDRESS as `0x${string}`,
    headers: { Authorization: `Bearer ${env.SIGN_WEBHOOK_TOKEN}` },
    name: "treasury", // optional; defaults to webhook:<address>
  });

  const { hash, userOpHash } = await wallet.writeContract(
    evm.chains.base,
    env.CONTRACT_ADDRESS as `0x${string}`,
    "setPrice(uint256)",
    [1234n],
  );
}
```

`url` must be `http://` or `https://`. `address` must be a 20-byte hex address. `headers` are copied onto every POST. Store the URL, address, and any auth token as [secrets](../../secrets).

Compose saves the address on the app, so the wallet shows up in the dashboard.

Webhook wallets implement the same `IWallet` as smart wallets and EOAs (`writeContract`, `sendTransaction`, `readContract`, `simulate`, `getBalance`). Writes are always gas-sponsored.

### Cloud only

Webhook wallets run in deployed apps. Creating one in local dev throws:

```
Webhook wallets are only available in cloud deployments. Restart with --fork-chains to exercise the signing callback locally.
```

`--fork-chains` still POSTs to your webhook so you can test signing. Submit fails after that because there is no local bundler.

### Request bodies

Compose POSTs JSON to `url` with `Content-Type: application/json` plus any `headers` you set. The request times out after 120 seconds. Bigint fields in the body are sent as decimal strings.

If the wallet is not yet EIP-7702 delegated on the target chain, Compose asks the webhook to sign the authorization first:

```json theme={null}
{
  "type": "signAuthorization",
  "address": "0xYourWallet",
  "chainId": 8453,
  "authorizationRequest": {
    "contractAddress": "0x7702Implementation",
    "chainId": 8453,
    "nonce": 0
  }
}
```

Respond with `r` and `s` as 32-byte hex strings, and either `yParity` (`0` or `1`) or `v` (`27`, `28`, `0`, or `1`). If you also return `address`, `chainId`, or `nonce`, they must match the request. `contractAddress` is the EIP-7702 implementation, not your wallet.

Every sponsored write then asks the webhook to sign the UserOperation's EIP-712 typed data:

```json theme={null}
{
  "type": "signUserOperation",
  "address": "0xYourWallet",
  "chainId": 8453,
  "userOpTypedData": {}
}
```

Respond with `{ "signature": "0x..." }`. A non-JSON body, a missing `signature`, or a non-2xx status fails the task. Do not broadcast from the webhook. Return the signature; Compose submits the sponsored UserOperation.

`nonce` on `sendTransaction` is ignored, as with other gas-sponsored wallets. A task retry prepares a new UserOperation, so Compose does not reuse a previous webhook signature.

### Signing server

The other side of `webhookWallet` is one POST handler on a server you host. `sign7702Authorization` and `signTypedData` are whatever holds the key (Turnkey, Fireblocks, an HSM, a local key). Sign and return. Do not broadcast.

```typescript theme={null}
// POST /
async function handleSign(req) {
  const body = await req.json();

  if (body.type === "signAuthorization") {
    const { contractAddress, chainId, nonce } = body.authorizationRequest;
    const { r, s, yParity } = await sign7702Authorization({
      address: body.address, // the wallet
      contractAddress,       // 7702 implementation, not the wallet
      chainId,
      nonce,
    });
    return json({ r, s, yParity }); // v: 27|28 also works
  }

  if (body.type === "signUserOperation") {
    const signature = await signTypedData({
      address: body.address,
      ...body.userOpTypedData, // EIP-712 from Compose
    });
    return json({ signature }); // 0x...
  }

  return status(400);
}
```

## Impersonated wallets

When testing locally with `--fork-chains`, you may need to call contract methods that are restricted to a specific address — for example, an owner-only admin function or a method guarded by an access control list. Normally you'd need the private key for that address, but with the `--impersonate` flag you can act as any address on the local TEVM fork using only its public address.

This is useful when:

* You want to test privileged contract methods without giving your local environment access to the private key
* You need to debug interactions with a contract where only a specific address has permission to call certain methods
* You're testing against a forked mainnet contract and want to simulate actions from an address you control on-chain but don't want to expose the key locally

### Usage

Pass `--impersonate` alongside `--fork-chains` when starting your app. The flag takes a comma-separated list of `walletName=address` mappings:

```bash theme={null}
goldsky compose start --fork-chains --impersonate "my-wallet=0x1234...abcd"
```

You can impersonate multiple wallets at once:

```bash theme={null}
goldsky compose start --fork-chains --impersonate "admin=0xAdminAddr,treasury=0xTreasuryAddr"
```

Your task code stays exactly the same — just reference the wallet by name as usual. The wallet's `.address` will resolve to the impersonated address, and all contract interactions (reads, writes, and simulations) will execute as that address on the local fork.

```typescript theme={null}
import { TaskContext } from "compose";

export async function main({ evm }: TaskContext) {
  // When started with --impersonate "admin=0xOwnerAddress",
  // this wallet's .address will be 0xOwnerAddress
  const wallet = await evm.wallet({ name: "admin" });

  console.log(wallet.address); // 0xOwnerAddress

  // This call executes as the impersonated address on the fork,
  // so owner-only methods will succeed
  const { hash } = await wallet.writeContract(
    evm.chains.base,
    "0xMyContract..." as `0x${string}`,
    "adminFunction(uint256)",
    [42n]
  );
}
```

Impersonation also works with private-key wallets. If a private-key wallet has a `name` that matches an entry in the impersonate map, the impersonated address takes precedence and the private key is effectively ignored on the fork:

```typescript theme={null}
const wallet = await evm.wallet({
  name: "my-pk-wallet",
  privateKey: env.MY_KEY,
});
// With --impersonate "my-pk-wallet=0xTargetAddr",
// wallet.address is 0xTargetAddr, not the address derived from MY_KEY
```

<Note>
  `--impersonate` requires `--fork-chains` and only works in local development. All impersonated transactions execute on your local TEVM fork — nothing is sent to the actual chain. When you deploy to cloud, wallets resolve normally.
</Note>

## Gas sponsoring

By default, smart wallets (wallets created without a private key) use gas sponsoring so you don't have to think about managing gas funding.
Smart wallets use EIP-7702 delegation for account abstraction, with gas costs handled through ERC-4337 UserOperations.
Webhook wallets are always gas-sponsored.
You pay the gas bill as part of your normal monthly Goldsky bill, avoiding the complex budgetary and tax issues of purchasing gas tokens.

When you don't use gas sponsoring, you'll need to get your wallet address from the compose dashboard at `https://app.goldsky.com/{projectId}/dashboard/compose/{appName}`
and then transfer gas tokens to that wallet through a wallet or an exchange.

### Gas sponsoring with EOA (private key) wallets

You can also opt into gas sponsoring for EOA wallets by passing `sponsorGas: true` when creating the wallet. Compose delegates the EOA via EIP-7702 on first use and routes transactions through ERC-4337 UserOperations, just like smart wallets — you keep full control of the key and signing, but you don't have to fund the wallet with native gas tokens.

```typescript theme={null}
import { TaskContext } from "compose";

export async function main({ evm, env }: TaskContext) {
  const wallet = await evm.wallet({
    privateKey: env.MY_PRIVATE_KEY,
    sponsorGas: true,
  });

  // transactions now go through the sponsored UserOp flow
  // you pay for gas on your monthly Goldsky bill instead of from this EOA
}
```

<Note>
  Sponsored EOA transactions only run in deployed apps. Running with `sponsorGas: true` locally will throw a clear error that tells you to either remove `sponsorGas`, fund the wallet manually, or re-run with `--fork-chains` to exercise the sponsored flow against a forked chain. In `--fork-chains` mode the transaction is executed against the local fork (where gas is free), and you'll see a warning reminding you the sponsored flow only takes effect once deployed to cloud.
</Note>

### Gas usage in the dashboard

Every sponsored or self-paid transaction emits a run event with `evm.gas_used` and `evm.total_cost_wei` attributes (both decimal strings in wei) so you can audit per-transaction gas spend from the Compose dashboard at `https://app.goldsky.com/{projectId}/dashboard/compose/{appName}`. On OP Stack L2s (Lisk, Base, Optimism, and friends) `evm.total_cost_wei` includes the L1 data fee, which typically dominates the total cost — reading `gasUsed × effectiveGasPrice` from the receipt alone will under-report by roughly two orders of magnitude.

## Gas pricing

For non-sponsored transactions, Compose uses automatic gas estimation with sensible defaults. If you need precise control over gas parameters, use [`sendTransaction`](#sendtransaction) instead of `writeContract` and specify `maxFeePerGas`, `maxPriorityFeePerGas`, and `gas` explicitly.

<Note>
  `writeContract` automatically simulates the transaction before submitting it, catching revert errors early. `sendTransaction` does not simulate — it submits directly.
</Note>

#### Override default gas sponsoring behavior

```typescript theme={null}
import { TaskContext } from "compose";

export async function main({ evm, env }: TaskContext) {
  // disable gas sponsoring on a smart wallet (default is true for smart wallets)
  const smartWallet = await evm.wallet({ name: "self-funded", sponsorGas: false });

  // enable gas sponsoring on an EOA wallet (default is false for EOA wallets)
  const sponsoredEoa = await evm.wallet({
    privateKey: env.MY_PRIVATE_KEY,
    sponsorGas: true,
  });
}
```

## Full wallet interface

```typescript theme={null}
export interface WalletConfig {
  name?: string; // defaults to "default"
  privateKey?: string;
  sponsorGas?: boolean; // defaults to true if no privateKey and false if privateKey
}

export interface WebhookWalletConfig {
  url: string; // http(s) signing webhook
  address: Address;
  headers?: Record<string, string>;
  name?: string; // defaults to webhook:<address>
}

export interface IWallet {
  readonly name: string;
  readonly address: `0x${string}`;
  writeContract(
    chain: Chain,
    contractAddress: `0x${string}`,
    functionSig: string,
    args: unknown[],
    confirmation?: TransactionConfirmation,
    retryConfig?: ContextFunctionRetryConfig
  ): Promise<{
    hash: string;
    receipt: TransactionReceipt;
    userOpHash?: string; // set for gas-sponsored transactions (ERC-4337)
  }>;
  readContract<T = unknown>(
    chain: Chain,
    contractAddress: `0x${string}`,
    functionSig: string,
    args: unknown[],
    retryConfig?: ContextFunctionRetryConfig
  ): Promise<T>;
  sendTransaction(
    config: {
      to: `0x${string}`;
      data: `0x${string}`;
      chain: Chain;
      value?: bigint;
      maxFeePerGas?: bigint;
      maxPriorityFeePerGas?: bigint;
      gas?: bigint;
      nonce?: number;
    },
    confirmation?: TransactionConfirmation,
    retryConfig?: ContextFunctionRetryConfig
  ): Promise<{
    hash: string;
    receipt: TransactionReceipt;
    userOpHash?: string; // set for gas-sponsored transactions (ERC-4337)
  }>;
  simulate(
    chain: Chain,
    contractAddress: `0x${string}`,
    functionSig: string,
    args: unknown[],
    retryConfig?: ContextFunctionRetryConfig
  ): Promise<unknown>;
  getBalance(
    chain: Chain,
    retryConfig?: ContextFunctionRetryConfig
  ): Promise<string>; // native token balance in wei
}
```


## Related topics

- [evm Overview](/compose/context/evm/overview.md)
- [ctx.hypercore: trade and deploy on Hyperliquid HyperCore](/compose/context/hypercore.md)
- [Build a Bitcoin price oracle](/compose/guides/build-a-bitcoin-oracle.md)
- [Migrate from Gelato W3F](/compose/gelato.md)
- [Build a VRF system](/compose/guides/build-a-vrf-system.md)
