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
6 changes: 6 additions & 0 deletions packages/arbitrum-adapter/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# @txkit/arbitrum-adapter

## [Unreleased]

- `previewSequencerFee` is now a live estimation rather than a stub. It reads `NodeInterface.gasEstimateComponents` (precompile 0xC8) through a caller-supplied viem `PublicClient`, splitting the cost into the L2 compute portion and the L1 calldata-posting portion (both priced at the L2 base fee, per the Arbitrum Nitro fee model) and filling every `SequencerFeePreview` field.
- Signature change (alpha, surface was flagged unstable): `previewSequencerFee(client, { chain, to, calldata, from?, l1BaseFeeWei? })` is now async and returns `Promise<SequencerFeePreview | null>`. A `to` is required (the precompile simulates the call) and the viem client is injected so the function stays RPC-agnostic and testable. Any failure (RPC down, simulated call reverts, malformed calldata) returns `null` - the preview is advisory and never blocks signing.
- `viem` added as a peer dependency (`>=2`).

## [0.1.0-alpha.0] - 2026-05-29

Initial skeleton release for the Arbitrum Open House London Buildathon Week 1 deliverable.
Expand Down
34 changes: 30 additions & 4 deletions packages/arbitrum-adapter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

Bridge Arbitrum specifics and `@txkit/tx-protocol` `PreparedEnvelope`. Attach L1->L2 bridge intents, retryable-ticket UX hints, and sequencer-fee previews to an envelope; decode Arbitrum-flavoured calldata.

> **v0.1.0-alpha** - skeleton scaffolded for the Arbitrum Open House London Buildathon (June 14 deadline). Surface stable; helper bodies will harden in alpha.1 with viem integration.
> **v0.1.0-alpha** - scaffolded for the Arbitrum Open House London Buildathon (June 14 deadline). `previewSequencerFee` is live (viem-driven precompile read); the decoder registry and the remaining helper coverage harden in alpha.1.

## Why this exists

Expand All @@ -21,9 +21,11 @@ The PreparedTransaction Envelope (ERC-8265, [PR #1753](https://github.com/ethere
## Install

```bash
npm install @txkit/arbitrum-adapter@alpha @txkit/tx-protocol@alpha
npm install @txkit/arbitrum-adapter@alpha @txkit/tx-protocol@alpha viem
```

`viem` is a peer dependency - `previewSequencerFee` reads Arbitrum precompiles through a viem `PublicClient` you supply.

## Usage

### Attach an L1->L2 bridge intent
Expand Down Expand Up @@ -67,6 +69,30 @@ const withRetryable = attachRetryableHints(envelope, {

### Preview the sequencer fee

Compute a live preview from an Arbitrum RPC, then attach it to the envelope:

```ts
import { createPublicClient, http } from 'viem'
import { arbitrum } from 'viem/chains'
import { previewSequencerFee, attachSequencerFeePreview } from '@txkit/arbitrum-adapter'

const client = createPublicClient({ chain: arbitrum, transport: http() })

const preview = await previewSequencerFee(client, {
chain: 'eip155:42161',
to: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', // inner call target
calldata: '0xa9059cbb...', // inner calldata
})
// -> { l2GasEstimate, l1CalldataBytes, l1FeeWei, l2FeeWei, totalFeeWei, isCompressed, previewBlock }
// or null if the RPC is unreachable or the simulated call reverts

const withFee = preview ? attachSequencerFeePreview(envelope, preview) : envelope
```

`previewSequencerFee` reads `NodeInterface.gasEstimateComponents` (precompile 0xC8), which simulates the `to` + `calldata` call and splits the cost into the L2 compute portion and the L1 calldata-posting portion. Both are priced at the L2 base fee (the L1 component is denominated in L2 gas units). Pass `from` to set the simulated `msg.sender`, or `l1BaseFeeWei` to pin the reported L1 base fee.

You can also attach a producer-supplied preview directly, without an RPC:

```ts
import { attachSequencerFeePreview } from '@txkit/arbitrum-adapter'

Expand Down Expand Up @@ -112,7 +138,7 @@ const decoded = decodeArbitrumCall({
- `attachSequencerFeePreview(envelope, preview)` - attach `meta.arbitrum.sequencerFee`
- `extractSequencerFeePreview(envelope)` - read `meta.arbitrum.sequencerFee`
- `isSequencerFeePreview(value)` - type guard
- `previewSequencerFee({ chain, calldata, l1BaseFeeWei? })` - skeleton stub, returns `null` until alpha.1
- `previewSequencerFee(client, { chain, to, calldata, from?, l1BaseFeeWei? })` - async; live preview via `NodeInterface.gasEstimateComponents` read through the supplied viem `PublicClient`. Returns `null` on RPC failure or if the simulated call reverts
- `NOVA_USES_COMPRESSED_CALLDATA` - constant flag (`true`)

### Decoder
Expand All @@ -122,7 +148,7 @@ const decoded = decodeArbitrumCall({

## Status

Skeleton. Helper bodies for `previewSequencerFee` and the decoder registry will harden in alpha.1 with viem-driven precompile reads (`ArbGasInfo`, `NodeInterface`) plus expanded coverage of Hop bonders per token, Across SpokePool per chain, Stargate routers, Camelot, GMX, Pendle, and Aave V3 on Arbitrum One.
`previewSequencerFee` is live via a viem-driven `NodeInterface` read. The decoder registry is still a seed and will harden in alpha.1 with expanded coverage of Hop bonders per token, Across SpokePool per chain, Stargate routers, Camelot, GMX, Pendle, and Aave V3 on Arbitrum One. Two known alpha.1 refinements for the fee preview: Nova `l1CalldataBytes` currently reports the raw byte count (not the post-Brotli/DAC size, though the fee itself is accurate), and a revert-resilient `ArbGasInfo` fallback would let the L1 portion survive when the simulated call reverts.

Tracking issue: open one in [github.com/txkit/mono/issues](https://github.com/txkit/mono/issues) if you have a buildathon use case that needs prioritisation.

Expand Down
4 changes: 4 additions & 0 deletions packages/arbitrum-adapter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,13 @@
"dependencies": {
"@txkit/tx-protocol": "workspace:^"
},
"peerDependencies": {
"viem": ">=2"
},
"devDependencies": {
"tsup": "^8.4.0",
"typescript": "^5.7.3",
"viem": "^2.48.4",
"vitest": "^4.1.1"
},
"sideEffects": false
Expand Down
126 changes: 124 additions & 2 deletions packages/arbitrum-adapter/src/__tests__/arbitrum.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { toHex, type PublicClient } from 'viem'
import { describe, expect, it } from 'vitest'

import { attachBridgeIntent, extractBridgeIntent, isBridgeIntent } from '../bridge'
Expand Down Expand Up @@ -50,6 +51,44 @@ const SAMPLE_PREVIEW: SequencerFeePreview = {
isCompressed: false,
}

const DEAD_ADDRESS = '0x000000000000000000000000000000000000dEaD' as const

type MockReadParams = {
address: string,
functionName: string,
args: readonly unknown[],
account?: unknown,
}

/**
* Minimal fake viem PublicClient. previewSequencerFee only touches
* readContract + getBlockNumber, so we stub exactly those two and cast.
* The gasEstimateComponents tuple defaults to the happy-path values used
* across the assertions below.
*/
const createMockClient = (options: {
components?: readonly [ bigint, bigint, bigint, bigint ],
blockNumber?: bigint,
throwOnRead?: boolean,
onRead?: (params: MockReadParams) => void,
}): PublicClient => {
const { components, blockNumber, throwOnRead, onRead } = options
const componentsValue = components ?? [ 1000000n, 200000n, 100000000n, 30000000000n ]
const blockNumberValue = blockNumber ?? 12345n

return {
readContract: async (params: MockReadParams) => {
onRead?.(params)
if (throwOnRead) {
throw new Error('rpc down')
}

return componentsValue
},
getBlockNumber: async () => blockNumberValue,
} as unknown as PublicClient
}

describe('arbitrum-adapter / bridge', () => {
it('attachBridgeIntent populates meta.arbitrum.bridge', () => {
const envelope = attachBridgeIntent(MINIMAL_ENVELOPE as unknown as Parameters<typeof attachBridgeIntent>[0], SAMPLE_BRIDGE)
Expand Down Expand Up @@ -119,11 +158,94 @@ describe('arbitrum-adapter / sequencer', () => {
expect(isSequencerFeePreview({ l2GasEstimate: '0x1' })).toBe(false)
})

it('previewSequencerFee is a skeleton stub - returns null', () => {
const preview = previewSequencerFee({ chain: 'eip155:42161', calldata: '0x' })
it('previewSequencerFee computes every field from gasEstimateComponents', async () => {
const client = createMockClient({
components: [ 1000000n, 200000n, 100000000n, 30000000000n ],
blockNumber: 12345n,
})
const preview = await previewSequencerFee(client, {
chain: 'eip155:42161',
to: DEAD_ADDRESS,
calldata: '0xabcdef',
})

expect(preview).toEqual({
l2GasEstimate: toHex(800000n),
l1CalldataBytes: 3,
l1BaseFeeWei: toHex(30000000000n),
l1FeeWei: toHex(200000n * 100000000n),
l2FeeWei: toHex(800000n * 100000000n),
totalFeeWei: toHex(1000000n * 100000000n),
isCompressed: false,
previewBlock: 12345,
})
})

it('previewSequencerFee reads gasEstimateComponents on NodeInterface 0xC8', async () => {
let captured: MockReadParams | undefined
const client = createMockClient({ onRead: (params) => { captured = params } })
await previewSequencerFee(client, {
chain: 'eip155:42161',
to: DEAD_ADDRESS,
calldata: '0x1234',
})

expect(captured?.address).toBe('0x00000000000000000000000000000000000000C8')
expect(captured?.functionName).toBe('gasEstimateComponents')
expect(captured?.args).toEqual([ DEAD_ADDRESS, false, '0x1234' ])
})

it('previewSequencerFee flags Nova calldata compression', async () => {
const client = createMockClient({})
const preview = await previewSequencerFee(client, {
chain: 'eip155:42170',
to: DEAD_ADDRESS,
calldata: '0x',
})

expect(preview?.isCompressed).toBe(true)
})

it('previewSequencerFee honours an l1BaseFeeWei override', async () => {
const client = createMockClient({ components: [ 1000000n, 200000n, 100000000n, 30000000000n ] })
const preview = await previewSequencerFee(client, {
chain: 'eip155:42161',
to: DEAD_ADDRESS,
calldata: '0x',
l1BaseFeeWei: '0x1',
})

expect(preview?.l1BaseFeeWei).toBe('0x1')
})

it('previewSequencerFee returns null when the precompile read fails', async () => {
const client = createMockClient({ throwOnRead: true })
const preview = await previewSequencerFee(client, {
chain: 'eip155:42161',
to: DEAD_ADDRESS,
calldata: '0x',
})

expect(preview).toBeNull()
})

it('previewSequencerFee counts calldata bytes', async () => {
const client = createMockClient({})
const empty = await previewSequencerFee(client, {
chain: 'eip155:42161',
to: DEAD_ADDRESS,
calldata: '0x',
})
const filled = await previewSequencerFee(client, {
chain: 'eip155:42161',
to: DEAD_ADDRESS,
calldata: '0xdeadbeef',
})

expect(empty?.l1CalldataBytes).toBe(0)
expect(filled?.l1CalldataBytes).toBe(4)
})

it('exposes the Nova compression flag', () => {
expect(NOVA_USES_COMPRESSED_CALLDATA).toBe(true)
})
Expand Down
101 changes: 85 additions & 16 deletions packages/arbitrum-adapter/src/sequencer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { toHex, type PublicClient } from 'viem'

import type { PreparedEnvelope } from '@txkit/tx-protocol'

import type { ArbitrumChainId, EnvelopeWithArbitrum, SequencerFeePreview } from './types'
Expand All @@ -12,7 +14,7 @@ import type { ArbitrumChainId, EnvelopeWithArbitrum, SequencerFeePreview } from
*/
export const NOVA_USES_COMPRESSED_CALLDATA = true

const isNova = (chainId: ArbitrumChainId): boolean => chainId === 'eip155:42170'
const checkIsNova = (chainId: ArbitrumChainId): boolean => chainId === 'eip155:42170'

/**
* Attach a sequencer-fee preview to a PreparedEnvelope's
Expand Down Expand Up @@ -53,26 +55,93 @@ export const extractSequencerFeePreview = (envelope: EnvelopeWithArbitrum): Sequ
}

/**
* Compute a sequencer-fee preview for a calldata payload on the given
* Arbitrum chain. Skeleton stub - returns `null` until alpha.1, when the
* estimation will use viem's `arbGasInfo.getPricesInWei` precompile read
* plus `NodeInterface.gasEstimateL1Component`.
* NodeInterface is a virtual precompile (0xC8) the Arbitrum node resolves
* via eth_call - it is not actually deployed. `gasEstimateComponents`
* simulates the (to, data) call and splits the estimate into the L2
* compute portion plus the L1 calldata-posting component.
*
* The canonical method is `payable`, but we own this ABI and mark it
* `view` so viem's `readContract` accepts it; eth_call ignores the
* declared mutability. Verified live against Arbitrum One mainnet.
*/
const NODE_INTERFACE_ADDRESS = '0x00000000000000000000000000000000000000C8' as const

const NODE_INTERFACE_GAS_ESTIMATE_COMPONENTS_ABI = [
{
type: 'function',
name: 'gasEstimateComponents',
stateMutability: 'view',
inputs: [
{ name: 'to', type: 'address' },
{ name: 'contractCreation', type: 'bool' },
{ name: 'data', type: 'bytes' },
],
outputs: [
{ name: 'gasEstimate', type: 'uint64' },
{ name: 'gasEstimateForL1', type: 'uint64' },
{ name: 'baseFee', type: 'uint256' },
{ name: 'l1BaseFeeEstimate', type: 'uint256' },
],
},
] as const

/**
* Compute a live sequencer-fee preview for a calldata payload on the given
* Arbitrum chain. Reads NodeInterface.gasEstimateComponents (precompile
* 0xC8) through the supplied viem client, which simulates the `to` + `data`
* call and splits the cost into the L2 compute portion and the L1
* calldata-posting portion. Both portions are priced at the L2 base fee
* returned by the same call - the L1 component is expressed in L2 gas units,
* so `l1FeeWei = gasEstimateForL1 * baseFee`.
*
* Returns `null` on any failure - RPC unreachable, the simulated call
* reverts, or malformed calldata. The preview is advisory wallet UX and
* must never block signing, so failures degrade to `null` rather than throw.
*
* Reference contract addresses (precompiles, identical across Arbitrum
* One / Sepolia / Nova):
* - ArbSys 0x0000000000000000000000000000000000000064
* - ArbGasInfo 0x000000000000000000000000000000000000006C
* - NodeInterface 0x00000000000000000000000000000000000000C8
*/
export const previewSequencerFee = (_args: {
chain: ArbitrumChainId,
calldata: `0x${string}`,
l1BaseFeeWei?: `0x${string}`,
}): SequencerFeePreview | null => {
// Skeleton: surface the chain compression flag so callers can still
// render the Nova vs One distinction even before live estimation lands.
const { chain } = _args
void chain
void isNova
return null
export const previewSequencerFee = async (
client: PublicClient,
args: {
chain: ArbitrumChainId,
to: `0x${string}`,
calldata: `0x${string}`,
from?: `0x${string}`,
l1BaseFeeWei?: `0x${string}`,
},
): Promise<SequencerFeePreview | null> => {
const { chain, to, calldata, from, l1BaseFeeWei } = args

try {
const [ gasEstimate, gasEstimateForL1, baseFee, l1BaseFeeEstimate ] = await client.readContract({
address: NODE_INTERFACE_ADDRESS,
abi: NODE_INTERFACE_GAS_ESTIMATE_COMPONENTS_ABI,
functionName: 'gasEstimateComponents',
args: [ to, false, calldata ],
account: from,
})

const blockNumber = await client.getBlockNumber()
const l2GasUnits = gasEstimate - gasEstimateForL1
const l1BaseFeeWeiValue = l1BaseFeeWei ? BigInt(l1BaseFeeWei) : l1BaseFeeEstimate
const calldataByteCount = (calldata.length - 2) / 2

return {
l2GasEstimate: toHex(l2GasUnits),
l1CalldataBytes: calldataByteCount,
l1BaseFeeWei: toHex(l1BaseFeeWeiValue),
l1FeeWei: toHex(gasEstimateForL1 * baseFee),
l2FeeWei: toHex(l2GasUnits * baseFee),
totalFeeWei: toHex(gasEstimate * baseFee),
isCompressed: checkIsNova(chain),
previewBlock: Number(blockNumber),
}
} catch {

return null
}
}
Loading
Loading