@gasfree-kit/bridge
v0.2.0
Published
Standalone LayerZero USDT0 bridge for gasfree-kit — production-grade, with idempotency and resilient status tracking
Maintainers
Readme
@gasfree-kit/bridge
Production-grade USDT0 bridge for EVM chains, powered by LayerZero and ERC-4337 smart accounts.
Standalone cross-chain USDT transfers with required slippage guards, idempotent submission, resilient status tracking, and full observability. Used internally by @gasfree-kit/intent and consumable directly when you only need bridging.
What you get
- Required slippage cap on every quote and send — submissions throw
BridgeSlippageErrorrather than silently accepting any fee - Idempotent submission via caller-supplied keys (Stripe-style); retries return the original result instead of double-submitting
- Resilient status tracking — LayerZero Scan first, balance-poll fallback. Cache → circuit breaker → retry with exponential backoff and full jitter
- Pluggable signer / status tracker / idempotency store — defaults work out of the box; swap in custom implementations as you grow
- Typed errors and events — 12 typed error classes, 14
BridgeEventshapes for observability sinks - Strict TypeScript —
noUncheckedIndexedAccessclean, noanyin public types, zod-validated inputs at every boundary
Supported chains
Ethereum, Base, Arbitrum, Optimism, Polygon, Celo, Plasma — the same set as @gasfree-kit/core.
Install
pnpm add @gasfree-kit/bridge @gasfree-kit/core @gasfree-kit/evm-4337After installing, your package manager will prompt you to install the required peer dependencies listed in package.json (an upstream bridge-protocol module and an upstream ERC-4337 wallet module). Both are required — install-time failure beats opaque runtime errors when a peer is missing.
Quick start
import { createBridge, BalancePollTracker, createDefaultStatusTracker } from '@gasfree-kit/bridge';
const config = {
chains: ['base', 'arbitrum'],
signer: { type: 'seedPhrase', seedPhrase: process.env.SEED_PHRASE! },
bundlerUrl: process.env.BUNDLER_URL!,
paymasterUrl: process.env.PAYMASTER_URL!,
isSponsored: true,
sponsorshipPolicyId: process.env.SPONSORSHIP_POLICY_ID!,
};
const bridge = createBridge({
...config,
statusTracker: createDefaultStatusTracker(new BalancePollTracker(config)),
});
// Capture balance before submission so the balance-poll fallback can detect landing.
const balanceBefore = await bridge.snapshotDestBalance(myAddress, 'arbitrum');
const result = await bridge.send({
sourceChain: 'base',
destinationChain: 'arbitrum',
amount: '50.00',
maxFee: { type: 'percent', value: 0.5 }, // required
idempotencyKey: `payout-${jobId}`, // optional but recommended
});
const status = await bridge.waitForCompletion(result, {
sender: myAddress,
balanceBefore,
timeout: 300_000,
});
console.log(status.state, status.destTxHash);Security model
| Concern | Mitigation |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
| Slippage / fee griefing | maxFee is required on every quote and send. Re-validated immediately before submission. |
| Recipient spoofing | EIP-55 address validation at the boundary. Default recipient = sender. |
| Amount overflow / dust | zod-enforced 6-decimal max, positive-only. Optional maxBridgeAmount cap on BridgeConfig. |
| USDT-on-Ethereum allowance race | Preserved from the upstream protocol — surface returns resetAllowanceHash. |
| Secret leakage in errors | redactSeedPhrase strips any 12/24-word BIP-39 sequence from error messages before they reach observability sinks. |
| RPC URL injection | http(s)-only validation. file://, javascript:, etc. rejected at config time. |
| Supply chain | Required peer deps — install-time failure, not opaque runtime import error. |
| Replay attacks | LayerZero handles cross-chain nonce. Idempotency layer on top prevents application-level double-submits. |
| Cancellation | AbortSignal plumbed through quote, send, waitForCompletion. Polling loops respect aborts mid-cycle. |
| Observability sinks crashing the SDK | onEvent is wrapped in try/catch — your logger throwing never breaks a bridge submission. |
Idempotency
Pass idempotencyKey on BridgeRequest. Retries with the same key return the cached result; concurrent callers serialize on the store's atomic acquire.
// First call — submits.
const r1 = await bridge.send({ ...request, idempotencyKey: 'invoice-99' });
// Network blip → app retries with the same key.
const r2 = await bridge.send({ ...request, idempotencyKey: 'invoice-99' });
// r1.hash === r2.hash — protocol.bridge() was called exactly once.The default InMemoryIdempotencyStore protects against double-submits within a single process. For multi-instance deployments, implement IdempotencyStore against Redis / Postgres / a KV store and pass it as config.idempotencyStore:
import type { IdempotencyStore, IdempotencyRecord } from '@gasfree-kit/core';
class RedisIdempotencyStore implements IdempotencyStore {
async acquire(key: string, lockedUntil: number, ttlMs: number): Promise<boolean> {
// SET key payload NX PX ttl — atomic; returns true only if the slot was free.
}
async get<T>(key: string): Promise<IdempotencyRecord<T> | null> {
/* GET + JSON.parse */
}
async set<T>(key: string, record: IdempotencyRecord<T>, ttlMs: number): Promise<void> {
/* SET PX ttl */
}
async delete(key: string): Promise<void> {
/* DEL */
}
}The interface requires an atomic acquire so non-trivial stores can't accidentally race.
Status tracking
Bridge.waitForCompletion polls the configured StatusTracker. The default CompositeStatusTracker tries LayerZero Scan first with a 2-second aggregate timeout, then falls through to balance polling. Once a tx falls through, subsequent reads skip Scan entirely for that hash.
The LayerZeroScanTracker is wrapped in a four-layer resilience stack:
TtlCache (30s)
└─► CircuitBreaker (5 failures / 60s cooldown / half-open trial)
└─► retry (3 attempts, exponential backoff + full jitter, cap 2s)
└─► fetch with per-request AbortController (5s timeout)
└─► zod-validated response (schema-mismatch falls through)Every layer emits an event you can subscribe to via BridgeConfig.onEvent:
const bridge = createBridge({
...config,
onEvent: (event) => {
switch (event.type) {
case 'bridge:submitted':
metrics.increment('bridge.submitted');
break;
case 'scan:circuit-open':
logger.warn('LZ Scan circuit open', { cooldownMs: event.cooldownMs });
break;
case 'scan:schema-mismatch':
sentry.captureMessage('LZ Scan schema drift', event);
break;
case 'bridge:idempotent-hit':
metrics.increment('bridge.idempotent_hit');
break;
case 'bridge:landed':
logger.info('Bridge landed', { srcTxHash: event.srcTxHash, elapsedMs: event.elapsedMs });
break;
}
},
});Custom signers
Only seed-phrase is shipped in v0.1.0. The BridgeSigner type is a discriminated union ready for passkey and external-wallet adapters:
export type BridgeSigner = SeedPhraseSigner;
// future: | PasskeySigner | ExternalWalletSigner;Use the seedPhraseSigner factory or pass the discriminated-union literal directly. Both work.
Error model
All errors extend GasfreeError from @gasfree-kit/core and carry a stable code:
| Error | Code | When |
| --------------------------------- | ------------------------------- | -------------------------------------------------------- |
| BridgeValidationError | BRIDGE_VALIDATION | Config or request fails zod schema |
| BridgeQuoteError | BRIDGE_QUOTE | Underlying protocol rejects the quote |
| BridgeSlippageError | BRIDGE_SLIPPAGE | Quoted fee exceeds the request's maxFee |
| BridgeTimeoutError | BRIDGE_TIMEOUT | waitForCompletion deadline elapsed |
| BridgeStatusSchemaError | BRIDGE_STATUS_SCHEMA | LayerZero Scan response failed schema validation |
| BridgeCircuitOpenError | BRIDGE_CIRCUIT_OPEN | Status tracker's circuit breaker is open |
| BridgeIdempotencyConflictError | BRIDGE_IDEMPOTENCY_CONFLICT | Another caller holds the lock and didn't resolve in time |
| BridgeProtocolNotInstalledError | BRIDGE_PROTOCOL_NOT_INSTALLED | Bridge-protocol peer dep not present at runtime |
| BridgeError | BRIDGE_ERROR | Catch-all for protocol-layer failures |
Versioning
Starting at 0.1.0. The public API surface is stable for everything documented above. Internal modules (protocol/, status/circuit-breaker, etc.) may evolve between minor versions.
License
MIT
