Skip to content

@mailam/bruno

A client for Bruno, the permissionless gas relay. You can send a call on Ethereum without holding ETH. Bruno is paid in ETH or a token during the call itself, and only if the call succeeds and lands on-chain.

  • Post-paid and atomic: your call and Bruno's payment are in the same transaction. If the call reverts, or never gets included, you pay nothing.
  • Three ways to pay:
  • The call pays for itself: in any token the relay takes, from your own account's signed batch.
  • A signed transfer: a USDC transfer that any signer of typed data signs, which the relay makes just before your call.
  • Over x402: the same signed transfer, paid the x402 way.
  • Smart accounts: a smart account can pay before it's deployed. Bruno deploys it in the same transaction (ERC-6492), so it never needs ETH.
  • No API key, no dependencies: tested on Deno, Node 20 or later, and Bun. It only uses web-standard APIs, so it's meant to run in browsers and Cloudflare Workers too, but isn't tested there yet.
  • Chains: Ethereum mainnet ("ethereum") and Sepolia ("sepolia").

Install

From npm, or from JSR:

npm install @mailam/bruno    # npm
pnpm add @mailam/bruno       # pnpm
bun add @mailam/bruno        # Bun
deno add jsr:@mailam/bruno   # Deno, from JSR

Its versions are prereleases (-dev) until its API settles. On JSR, for npm, pnpm and Bun: npx jsr add @mailam/bruno, pnpm i jsr:@mailam/bruno, bunx jsr add @mailam/bruno.

Tokens and amounts

You can name a token by its alias on the chain, or by its address:

Chain Aliases
ethereum ETH, WETH, USDC, USDT, DAI
sepolia ETH, WETH, USDC

An amount always says its unit:

  • { decimal: "2.5" } or { decimal: 2.5 }: whole units. The value must be exact. An amount with more digits than the token has (such as 0.1 + 0.2 of USDC) is refused, never rounded. This form only works for tokens whose decimals are known, which means the aliased ones.
  • { baseUnits: 2_500_000n }: the token's smallest unit (wei, for ETH). This works for any token.

Another chain's tokens, or a token without an alias, can be given to the client along with the chain:

const bruno = new Bruno({
  chain: 31337, // any chain your relay serves, by id
  url: "http://127.0.0.1:8000",
  tokens: [{
    symbol: "USDC",
    address: "0x…",
    decimals: 6,
    eip3009: { name: "USDC", version: "2" },
  }],
});

eip3009 is the token's EIP-712 domain, its name and version. A token needs it to pay by a signed transfer with payAndSubmit; x402 reads it from the relay's offer instead.

Every amount the client returns is a Value, which carries both forms: baseUnits, and decimal (or null if the token's decimals aren't known).

Pay over x402

import { Bruno, viemSigner } from "@mailam/bruno";
import { privateKeyToAccount } from "viem/accounts";

const account = privateKeyToAccount(process.env.KEY as `0x${string}`);
const bruno = new Bruno({ chain: "sepolia" });

const paid = await bruno.x402.payAndSend(
  { call: { to, data }, gas: 100_000n },
  viemSigner(account),
  { pay: [{ token: "USDC", max: { decimal: "2.5" } }] },
);
console.log(paid.transaction, paid.paid.decimal, paid.paid.token.symbol);

pay lists the tokens you're willing to pay in, in your order of preference. Each entry has:

  • token: an alias or address.
  • max: the most you'll pay, with the price and tip together.
  • tip (optional): an amount on top of the price, which ranks the call ahead of others when the relay's queue is full.

payAndSend asks for the price, which comes back as a 402. It pays in the first entry the relay offers whose price plus tip is within its max. If no entry qualifies, it throws NoPayment with each entry's reason, before anything is signed. Otherwise it signs the token's transferWithAuthorization for that amount, sends the call with it, and resolves once the transaction is included. If the relay refuses the payment, it throws PaymentRefused with x402's error code, and nothing is spent.

bruno.x402.price(request) gives the price in each offered token without paying.

Note: over x402, the call runs from Bruno's contract, not your account. This path is for calls whose effect doesn't depend on who sends them: a signed authorization (a USDC transferWithAuthorization, an EIP-2612 permit), a claim for a named recipient, or a mint for an address. For anything that has to act as your account, use the call-pays-for-itself path below.

A Signer is an address plus signTypedData over standard EIP-712 JSON (the eth_signTypedData_v4 shape, with numbers as decimal strings). viemSigner adapts a viem account. Any other signer works too, such as a browser wallet:

const signer = {
  address,
  signTypedData: (typedData) =>
    window.ethereum.request({
      method: "eth_signTypedData_v4",
      params: [address, JSON.stringify(typedData)],
    }),
};

Pay with a signed transfer

payAndSubmit pays for a call with a signed USDC transfer. Unlike x402, it's a normal submission, so you get its id straight away and follow its status:

const { id, paid } = await bruno.payAndSubmit(
  { call: { to, data }, gas: 100_000n },
  viemSigner(account),
  { token: "USDC", max: { decimal: "2.5" } },
);
const status = await bruno.wait(id);

It asks for a quote with the signer as the payer, signs the transfer at that price if it's within max, and submits the call with it. If the price has risen by the time it's submitted, the relay says what it requires, and the transfer is signed once more at that, if it's still within max. The submission must be included by the quote's validUntilBlock, or up to deadlineBlocks blocks after it if you give that option. As over x402, the call runs from Bruno's contract, not your account, and the transfer only happens if the call succeeds and lands.

Smart accounts

A smart account pays the same way, through x402 or payAndSubmit, whether it's deployed or not. With viem:

import { toCoinbaseSmartAccount } from "viem/account-abstraction";

const smart = await toCoinbaseSmartAccount({ client, owners: [owner], version: "1.1" });
const paid = await bruno.x402.payAndSend(request, viemSigner(smart), {
  pay: [{ token: "USDC", max: { decimal: "2.5" } }],
});

While the account is undeployed, viem wraps its signatures as ERC-6492 says, and Bruno deploys it in the same transaction that takes its payment. The client tells Bruno who's paying and how the account is deployed (from getFactoryArgs), so the first price includes both. Over x402, if a payment still turns out short, it signs once more at the price Bruno asks for.

A signer that isn't viem's can do the same: give it getFactoryArgs, returning the account's factory and factoryData while it's undeployed. If you know what your account's signature check costs, pass it as verifyGas in the request; otherwise Bruno uses its own figure for the wallets it knows, and the most it knows for any other.

The call pays for itself

Your account's call can pay Bruno's payee itself, in any token the relay takes. A typical call is a batch your account signs through its own EIP-7702 delegate, ending with the payment. The client never builds, signs or delegates your account: you bring the call, along with any authorization you've signed.

const { payee } = await bruno.terms();
const quote = await bruno.quote(gas, callBytes, ["USDC", "ETH"]);
// if the call needs an account deployed, quote it with the deployment too:
// await bruno.quote(gas, callBytes, ["USDC"], undefined, { deployment })
const [price] = quote.prices; // a Value: price.baseUnits, price.decimal
// ...build and sign your account's call, paying `price` of USDC to `payee`...
// and add `deployment: { account, factory, factoryData }` here
const id = await bruno.submit({
  call,
  gas,
  token: "USDC",
  amount: { baseUnits: price.baseUnits },
  maxBlockNumber: quote.validUntilBlock,
});
const status = await bruno.wait(id); // "done" or "failed", with its transaction

License

Apache-2.0. Copyright mai-lam-labs.

API reference

Every export, from its documentation comments: jsr.io/@mailam/bruno/doc.