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
5 changes: 5 additions & 0 deletions .changeset/khaki-lemons-mix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@relayprotocol/relay-sdk': minor
---

Add executeGaslessBatch action to support 7702 gasless flow
99 changes: 99 additions & 0 deletions packages/sdk/scripts/test-gasless-batch.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Integration test for executeGaslessBatch
*
* Swaps Pudgy Penguins (PENGU) on Base → USDC on Optimism
*
* Usage:
* PRIVATE_KEY=0x... RELAY_API_KEY=... npx tsx scripts/test-gasless-batch.mts
*/

import { createWalletClient, http, type Hex } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { base } from 'viem/chains'
import {
createClient,
getQuote,
executeGaslessBatch,
convertViemChainToRelayChain,
LogLevel
} from '../src/index.js'

const PRIVATE_KEY = process.env.PRIVATE_KEY as Hex | undefined
const RELAY_API_KEY = process.env.RELAY_API_KEY

if (!PRIVATE_KEY) {
console.error('Missing PRIVATE_KEY env var')
process.exit(1)
}

if (!RELAY_API_KEY) {
console.error('Missing RELAY_API_KEY env var')
process.exit(1)
}

// Pudgy Penguins (PENGU) on Base
const PENGU_BASE = '0x01e6bd233f7021e4f5698a3ae44242b76a246c0a'
// USDC on Optimism
const USDC_OPTIMISM = '0x0b2c639c533813f4aa9d7837caf62653d097ff85'

const account = privateKeyToAccount(PRIVATE_KEY)

const walletClient = createWalletClient({
account,
chain: base,
transport: http()
})

createClient({
baseApiUrl: 'https://api.relay.link',
apiKey: RELAY_API_KEY,
source: 'relay.link',
logLevel: LogLevel.Verbose,
chains: [convertViemChainToRelayChain(base)]
})

console.log(`\n── Gasless Batch Test ──`)
console.log(`Account: ${account.address}`)
console.log(`Swap: PENGU (Base) → USDC (Optimism)`)
console.log(`Amount: 1000000000000000000 (1 PENGU)\n`)

// ── 1. Get Quote ─────────────────────────────────────────────────────────────

console.log('→ Getting quote...')

const quote = await getQuote({
chainId: 8453,
currency: PENGU_BASE,
toChainId: 10,
toCurrency: USDC_OPTIMISM,
amount: '10000000000000000000000', // 10000 PENGU (18 decimals)
tradeType: 'EXACT_INPUT',
user: account.address,
recipient: account.address,
options: {
subsidizeFees: true
}
})

console.log(`✓ Quote received — ${quote.steps.length} step(s)`)

for (const step of quote.steps) {
const items = step.items?.length ?? 0
console.log(` step: kind=${step.kind}, items=${items}, requestId=${step.requestId ?? 'none'}`)
}

// ── 2. Execute Gasless Batch ─────────────────────────────────────────────────

console.log('\n→ Executing gasless batch...')

const result = await executeGaslessBatch({
quote,
walletClient,
//Subsidize the origin tx fees
subsidizeFees: true,
onProgress: (progress) => {
console.log(` [progress] ${progress.status}${progress.requestId ? ` (${progress.requestId})` : ''}`)
}
})

console.log(`\n✓ Done — requestId: ${result.requestId}`)
249 changes: 249 additions & 0 deletions packages/sdk/src/actions/gaslessBatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createClient } from '../client.js'
import { MAINNET_RELAY_API } from '../constants/index.js'
import { axios } from '../utils/index.js'
import {
createCaliburExecutor,
CALIBUR_ORIGIN_GAS_OVERHEAD
} from '../utils/caliburExecutor.js'
import type { BatchExecutorConfig } from '../types/BatchExecutor.js'
import type { Execute } from '../types/Execute.js'
import { zeroAddress, type Address } from 'viem'

// ── helpers ──────────────────────────────────────────────────────────────────

const MOCK_USER = '0x1111111111111111111111111111111111111111' as Address

/** Minimal quote fixture with a single transaction step */
const createMockQuote = (chainId = 8453): Execute =>
({
steps: [
{
kind: 'transaction',
requestId: 'req-abc',
items: [
{
data: {
to: '0x2222222222222222222222222222222222222222',
value: '0',
data: '0xdeadbeef',
chainId
}
}
]
}
]
}) as unknown as Execute

/** Build a mock executor based on Calibur but with overridable fields */
const createMockExecutor = (
overrides: Partial<BatchExecutorConfig> = {}
): BatchExecutorConfig => {
const calibur = createCaliburExecutor()
return {
...calibur,
...overrides
}
}

const mockWalletClient = () => ({
account: { address: MOCK_USER },
signAuthorization: vi.fn().mockResolvedValue({
chainId: 8453,
address: zeroAddress,
nonce: 0,
yParity: 0,
r: '0x' + '00'.repeat(32),
s: '0x' + '00'.repeat(32)
}),
signTypedData: vi.fn().mockResolvedValue('0x' + 'ab'.repeat(65))
})

// ── mocks ────────────────────────────────────────────────────────────────────

// eslint-disable-next-line @typescript-eslint/no-explicit-any
let axiosRequestSpy: any

/**
* Mock axios so all HTTP calls resolve.
* – POST /execute → returns a requestId
* – GET /intents/status/v3 → returns success immediately
*/
const mockAxios = () =>
vi.spyOn(axios, 'request').mockImplementation((config: any) => {
if (config?.url?.includes('/execute')) {
return Promise.resolve({
data: { requestId: 'req-mock-123', message: 'ok' },
status: 200
})
}
// status polling
return Promise.resolve({
data: { status: 'success' },
status: 200
})
})

// Mock viem's createPublicClient to avoid real RPC calls
vi.mock('viem', async () => {
const actual = await vi.importActual('viem')
return {
...(actual as object),
createPublicClient: () => ({
getCode: vi.fn().mockResolvedValue('0x'),
getTransactionCount: vi.fn().mockResolvedValue(0),
readContract: vi.fn().mockResolvedValue(0n)
})
}
})

/** Extract the request body sent to /execute from the axios spy calls */
const getExecuteBody = (): Record<string, any> => {
const call = axiosRequestSpy.mock.calls.find((c: any) =>
(c[0] as any)?.url?.includes('/execute')
)
if (!call) throw new Error('No /execute call found')
return (call[0] as any).data as Record<string, any>
}

// ── tests ────────────────────────────────────────────────────────────────────

describe('executeGaslessBatch', () => {
beforeEach(() => {
vi.clearAllMocks()
axiosRequestSpy = mockAxios()
})

// ---------- basic validation ----------

it('should throw when client has no API url', async () => {
createClient({ baseApiUrl: '' })

const { executeGaslessBatch } = await import('./gaslessBatch.js')

await expect(
executeGaslessBatch({
quote: createMockQuote(),
walletClient: mockWalletClient() as any
})
).rejects.toThrow('RelayClient missing api url configuration')
})

it('should throw when client has no API key', async () => {
createClient({ baseApiUrl: MAINNET_RELAY_API })

const { executeGaslessBatch } = await import('./gaslessBatch.js')

await expect(
executeGaslessBatch({
quote: createMockQuote(),
walletClient: mockWalletClient() as any
})
).rejects.toThrow('API key is required')
})

it('should throw when walletClient has no account', async () => {
createClient({ baseApiUrl: MAINNET_RELAY_API, apiKey: 'test-key' })

const { executeGaslessBatch } = await import('./gaslessBatch.js')

await expect(
executeGaslessBatch({
quote: createMockQuote(),
walletClient: {} as any
})
).rejects.toThrow('WalletClient must have an account')
})

// ---------- originGasOverhead: default (Calibur) ----------

it('should include Calibur default originGasOverhead (80k) in /execute body', async () => {
createClient({ baseApiUrl: MAINNET_RELAY_API, apiKey: 'test-key' })

const { executeGaslessBatch } = await import('./gaslessBatch.js')

await executeGaslessBatch({
quote: createMockQuote(),
walletClient: mockWalletClient() as any
})

const body = getExecuteBody()
expect(body.originGasOverhead).toBe(CALIBUR_ORIGIN_GAS_OVERHEAD)
expect(body.originGasOverhead).toBe(80_000)
})

// ---------- originGasOverhead: custom executor ----------

it('should use custom executor originGasOverhead when provided', async () => {
createClient({ baseApiUrl: MAINNET_RELAY_API, apiKey: 'test-key' })

const { executeGaslessBatch } = await import('./gaslessBatch.js')

const customExecutor = createMockExecutor({ originGasOverhead: 120_000 })

await executeGaslessBatch({
quote: createMockQuote(),
walletClient: mockWalletClient() as any,
executor: customExecutor
})

const body = getExecuteBody()
expect(body.originGasOverhead).toBe(120_000)
})

// ---------- originGasOverhead: explicit override ----------

it('should let originGasOverhead parameter override the executor default', async () => {
createClient({ baseApiUrl: MAINNET_RELAY_API, apiKey: 'test-key' })

const { executeGaslessBatch } = await import('./gaslessBatch.js')

await executeGaslessBatch({
quote: createMockQuote(),
walletClient: mockWalletClient() as any,
originGasOverhead: 200_000
})

const body = getExecuteBody()
expect(body.originGasOverhead).toBe(200_000)
})

it('should let originGasOverhead parameter override a custom executor default', async () => {
createClient({ baseApiUrl: MAINNET_RELAY_API, apiKey: 'test-key' })

const { executeGaslessBatch } = await import('./gaslessBatch.js')

const customExecutor = createMockExecutor({ originGasOverhead: 120_000 })

await executeGaslessBatch({
quote: createMockQuote(),
walletClient: mockWalletClient() as any,
executor: customExecutor,
originGasOverhead: 50_000
})

const body = getExecuteBody()
expect(body.originGasOverhead).toBe(50_000)
})

// ---------- originGasOverhead: executor with no default ----------

it('should omit originGasOverhead when executor has none and no override given', async () => {
createClient({ baseApiUrl: MAINNET_RELAY_API, apiKey: 'test-key' })

const { executeGaslessBatch } = await import('./gaslessBatch.js')

const executorWithoutGas = createMockExecutor({
originGasOverhead: undefined
})

await executeGaslessBatch({
quote: createMockQuote(),
walletClient: mockWalletClient() as any,
executor: executorWithoutGas
})

const body = getExecuteBody()
expect(body).not.toHaveProperty('originGasOverhead')
})
})
Loading