@valve-tech/wallet-adapter
v0.19.0
Published
Framework-agnostic vocabulary + runtime helpers for EVM dapp wallet integration. WalletAdapter interface (sign + send), WriteHookParams full lifecycle with rich TxContext payloads (chainId + original request) on every event so consumers don't side-channel
Maintainers
Readme
@valve-tech/wallet-adapter
Framework-agnostic vocabulary for EVM dapp wallet integration. Pure
types + a few as const lifecycle constants — no runtime
implementation, no opinion about which wallet library you use.
Five concerns under one package so SDK authors, UI authors, and apps all agree on the same surface:
WalletAdapter— the contract an SDK accepts in lieu of coupling to wagmi / ethers / viem direct / a smart account.WriteHookParams— every phase a tracked tx can be in. Six named hooks (onAwaitingSignature,onTransactionHash,onConfirmed,onFailed,onDropped,onReplaced) plus a complementary single-callback shape (onPhase(event)) with a discriminated-union payload. Every payload is aTxContextinfo bag —{ chainId, request, ...phase-specific }— so consumers never have to side-channel the originating chain or the original send request. Fire-ers fire BOTH shapes for every transition — exactly once each — so wiring named hooks doesn't precludeonPhaseand vice versa.sendTransactionWithHooks(options)— wallet-side helper. Fires the pre-wallet (onAwaitingSignature,onPhase('awaiting-signature')) and post-hash (onTransactionHash,onPhase('pending', { ..., hash })) transitions. Converts wallet rejections to a typedWalletRejectedError, firesonFailed+onPhase('failed', { ..., error }), then throws.awaitReceiptWithHooks(options)— chain-side helper. AwaitswaitForTransactionReceipt, fetches the containing block (so downstream consumers don't re-fetch it fortimestamp/baseFeePerGas), then firesonConfirmed+onPhase('confirmed', { ..., hash, receipt, block })on success, oronFailed+onPhase('failed', ...)with a typedContractRevertedErroronstatus: 'reverted'. Other receipt-await errors re-thrown unchanged after firing the failure hooks. PassincludeBlock: falseto skip the block fetch.TX_STATUS/TrackedTx— vocabulary for "this transaction is in flight" UIs (toast strips, inline indicators, history panes) so they can sit on top of any tracker without redefining state names.
The two helpers split by concern: the wallet side and the chain side.
SDKs chain them with whatever protocol-specific work goes in the
middle (gating-service signatures, log decoding, indexer sync). The
only runtime dependency is @valve-tech/viem-errors for the
rejection-detection check; viem is a peer dependency for the Hex
and TransactionReceipt types.
onDropped and onReplaced are part of the contract; the helpers
in this package don't fire them. Honestly distinguishing "still
propagating" from "permanently dropped" requires observing the tx
across many blocks with a configurable timeout policy, and detecting
replacement requires nonce-watching across the same nonce — that's
@valve-tech/tx-tracker's job (per-tx state machine). The contract
defines the hooks here so consumers wire one set of callbacks; the
tracker fires them when it ships. Wiring onDropped / onReplaced
against awaitReceiptWithHooks is harmless but they will not fire
from this package.
Why
Every SDK invents its own wallet shape; every dapp invents its own "awaiting signature" state machine; every UI invents its own list of status names. The result is a small ecosystem where the same word means slightly different things at every boundary, and dapps end up writing translation glue between them.
This package is the shared vocabulary. Use WalletAdapter so a single
wagmi/ethers/smart-account adapter works across every SDK. Use
WriteHookParams so the UI sees consistent transitions across every
SDK write. Use TX_STATUS so the in-flight strip and the receipt-poll
agree on what 'pending' means.
Install
npm install @valve-tech/wallet-adapter viem
# or
yarn add @valve-tech/wallet-adapter viemQuick start
Defining an SDK that accepts any wallet
import {
sendTransactionWithHooks,
awaitReceiptWithHooks,
WalletRejectedError,
ContractRevertedError,
type WalletAdapter,
type WriteHookParams,
} from '@valve-tech/wallet-adapter'
export interface MyWriteParams { depositId: bigint; amount: bigint }
export class MyClient {
constructor(
private wallet: WalletAdapter,
private chainId: number,
private escrow: `0x${string}`,
/**
* Optional global / analytics channel — fires alongside the per-call hook.
* Receives the rich `{ chainId, request, hash }` info bag, so analytics
* observers see the originating chain and request without a side channel.
*/
private onTransactionHash?: WriteHookParams['onTransactionHash'],
) {}
async deposit(params: MyWriteParams & WriteHookParams) {
const request = {
to: this.escrow,
data: this.encodeDeposit(params),
chainId: this.chainId,
}
try {
const hash = await sendTransactionWithHooks({
wallet: this.wallet,
request,
hooks: params,
onTransactionHash: this.onTransactionHash,
})
const receipt = await awaitReceiptWithHooks({
publicClient: this.publicClient,
hash,
request, // carried into every phase event as part of TxContext
hooks: params,
})
// protocol-specific work here (decode logs, etc.) — onConfirmed already fired
// with { chainId, request, hash, receipt, block } in scope
return { hash, receipt }
} catch (err) {
if (err instanceof WalletRejectedError) {
throw new MySdkError('WALLET_REJECTED', err.message, err.cause)
}
if (err instanceof ContractRevertedError) {
throw new MySdkError('TX_REVERTED', err.message, err)
}
throw new MySdkError('CONTRACT_ERROR', (err as Error).message, err as Error)
}
}
}sendTransactionWithHooks guarantees:
onAwaitingSignaturefires once with{ chainId, request }, immediately beforewallet.sendTransaction.onTransactionHash(per-call and global) fires once each with{ chainId, request, hash }, aftersendTransactionresolves and before any receipt-await.- Wallet rejections — detected via the three-signal check in
@valve-tech/viem-errors(EIP-1193code === 4001, viem class name, message regex, anywhere in the cause chain) — are thrown asWalletRejectedError.onFailedfires with{ chainId, request, error: <WalletRejectedError> }before the throw. - Any other thrown error fires
onFailed(witherror: <thrown>) and re-throws unchanged so the SDK keeps control of its error mapping.
awaitReceiptWithHooks guarantees:
- On
receipt.status === 'success': fetches the containing block (unlessincludeBlock: false), then firesonConfirmed({ chainId, request, hash, receipt, block? })and resolves with the receipt. - On
receipt.status === 'reverted': fetches the block, then firesonFailed({ chainId, request, hash, receipt, block?, error: <ContractRevertedError> })and throws the error.ContractRevertedErrorcarrieshash+ the fullreceiptfor log inspection. - On any thrown error during the receipt-await (network / RPC /
abort): fires
onFailed({ chainId, request, error })(nohash/receipt/block) and re-throws unchanged. The block fetch is skipped when the receipt itself fails to arrive.
A WriteHookParams consumer (toast strip, inline indicator, etc.)
that wires all four hooks can drive its full state machine — pre-wallet
"preparing", post-wallet "pending", terminal "confirmed" or "failed" —
purely from the contract, without any SDK-specific glue.
A tx-flight UI built on TX_STATUS
import { TX_STATUS, type TrackedTx } from '@valve-tech/wallet-adapter'
function subtitle(tx: TrackedTx): string {
switch (tx.status) {
case TX_STATUS.preparing: return 'preparing transaction'
case TX_STATUS.awaitingSignature: return 'awaiting wallet signature'
case TX_STATUS.pending: return 'waiting for inclusion'
case TX_STATUS.mined: return 'confirmed on-chain'
case TX_STATUS.failed: return tx.notes ?? 'transaction failed'
case TX_STATUS.dropped: return 'dropped from mempool'
case TX_STATUS.replaced: return 'replaced by speed-up'
}
}Bridging a real wallet to WalletAdapter
The package is vocabulary, not a connection layer — you bring the
wallet plumbing. The most universal bridge is EIP-1193 provider →
viem WalletClient → WalletAdapter, which works for Reown
(WalletConnect, 200+ wallets), MetaMask SDK, RainbowKit, raw
window.ethereum, hardware wallets in browser context (Ledger
Live, MetaMask + Ledger, Trezor Suite — they all surface as standard
EIP-1193 providers), and anything else that surfaces an EIP-1193
provider.
The examples/ directory has runnable bridges for the five common
classes of wallet plumbing — read the comments at the top of each
to see what it covers and which npm install you'd add for the real
thing:
| Example | Covers | Bridge helper |
|---|---|---|
| 01-reown-adapter.ts | Reown / WalletConnect / MetaMask SDK / RainbowKit / raw window.ethereum / hardware wallets in-browser. Universal EIP-1193 path. | walletAdapterFromEip1193(...) |
| 02-wagmi-adapter.ts | wagmi React stack — wraps the useWalletClient() viem WalletClient directly, skipping the round-trip through EIP-1193. | walletAdapterFromWalletClient(...) |
| 03-server-relayer.ts | Backend code: relayer signing from a private key (env var / KMS). No provider, no chain-switching, hard-fail on cross-chain. Right for sponsored-tx services, indexer write paths, integration tests. | walletAdapterFromRelayer(...) |
| 04-erc4337-smart-account.ts | ERC-4337 smart accounts via permissionless.js or similar. adapter.address is the smart-account address (not the EOA signer). | walletAdapterFromSmartAccount(...) |
| 05-hardware-wallet-direct.ts | Hardware wallets attached directly via USB/HID (no wallet app in between) — @ledgerhq/hw-app-eth for Ledger; the same shape works for Trezor via @trezor/connect. For backend code, kiosk apps, dev tooling. | walletAdapterFromLedger(...) |
| 06-ethers-adapter.ts | Dapps still on ethers v6 (or in mid-migration), bridging an ethers.Signer (BrowserProvider.getSigner() or Wallet) without re-wiring to viem. | walletAdapterFromEthersSigner(...) |
| 07-privy-embedded.ts | Privy embedded wallets via @privy-io/react-auth's useWallets(). Handles CAIP-2 chain encoding and lazy provider fetching across wallet swaps. | walletAdapterFromPrivyWallet(...) |
| 08-safe-multisig.ts | Safe (Gnosis Safe) multisig via @safe-global/protocol-kit + @safe-global/api-kit. Returns a safeTxHash, not an on-chain tx hash — UIs need to fork to await the executed hash separately. | walletAdapterFromSafe(...) |
Each example includes a no-network sanity check at the bottom so you
can run it (yarn tsx examples/0X-...ts) without installing any of
the wallet libraries.
Exports
| Export | Kind | Shape |
| --- | --- | --- |
| WalletAdapter | type | { address?, sendTransaction(req), readContract?(req) } |
| WalletSendTransactionRequest | type | EIP-1559 send shape — { to, data, value?, chainId, maxFeePerGas?, maxPriorityFeePerGas? } |
| WalletReadContractRequest | type | { address, abi, functionName, args?, chainId? } |
| WriteHookParams | type | six named hooks (onAwaitingSignature, onTransactionHash, onConfirmed, onFailed, onDropped, onReplaced) + onPhase(event). Every callback receives a TxContext<Steps[K]> info bag. |
| WritePhase | type | 'preparing' \| 'awaiting-signature' \| 'pending' \| 'confirmed' \| 'failed' \| 'dropped' \| 'replaced' |
| WritePhaseSteps | interface | per-phase data delta map. pending: { hash }, confirmed: { hash, receipt, block? }, etc. Open to declaration merging. |
| TxContext<Extra> | type | { chainId, request } & Extra. The always-present context intersected with the per-phase delta. Defaults Extra to object. |
| WritePhaseEvent | type | derived { [K in keyof WritePhaseSteps]: { phase: K } & TxContext<WritePhaseSteps[K]> }[keyof WritePhaseSteps]. |
| sendTransactionWithHooks(opts) | function | { wallet, request, hooks?, onTransactionHash? } → Promise<Hex>. Wallet-side helper. |
| awaitReceiptWithHooks(opts) | function | { publicClient, hash, request, includeBlock?, hooks? } → Promise<TransactionReceipt>. Chain-side helper; fetches the containing block by default. |
| WalletRejectedError | class | Error subclass with cause: Error. Thrown by sendTransactionWithHooks on user rejection. |
| ContractRevertedError | class | Error subclass with hash + receipt. Thrown by awaitReceiptWithHooks on status: reverted. |
| SendTransactionWithHooksOptions / AwaitReceiptWithHooksOptions / ReceiptAwaiter | type | options + minimal client shape |
| TX_STATUS | const | lifecycle states |
| TX_FLOW | const | empty by design — protocols extend |
| TrackedTx | type | { id, hash?, chainId, flow, submittedAt, ... status, notes? } |
| STALE_TX_AGE_MS / CONFIRMED_DISPLAY_MS / FAILED_DISPLAY_MS | const | window defaults |
Design notes
- One hook contract, two complementary shapes.
WriteHookParamsdescribes every phase a tracked tx can be in. Six named callbacks cover the common transitions;onPhase(event)provides the same information as a discriminated-union single-callback shape for state-machine consumers. Fire-ers fire BOTH shapes for every transition — exactly once each. Wiring named hooks doesn't precludeonPhaseand vice versa. - Rich payloads, not bare arguments. Every event carries
TxContext(chainId+ the originalrequest) on top of its phase-specific fields. The lib already has all of that in scope when it fires events; the alternative —(receipt) => voidand(hash) => void— forces every consumer to maintain a side-channelhash → requestmap and callclient.chain.idfrom inside their callbacks.awaitReceiptWithHooksalso fetches the containing block once and includes it onconfirmed/ receipt-bearingfailedevents, so downstream consumers (notably@valve-tech/tx-tracker) skip the round trip. WritePhaseStepsis the single source of truth for phase shapes.WritePhaseEventis derived mechanically as{ [K in keyof WritePhaseSteps]: { phase: K } & TxContext<WritePhaseSteps[K]> }, so adding a phase is one entry in the map plus a fire-er. Adding a shared field is one entry inTxContext. Both stay in lockstep with the named hook signatures.onFailedis the unified failure callback for revert / rejection / network errors. Wallet rejection, on-chain revert, and network errors all flow through it.instanceofagainstWalletRejectedError/ContractRevertedErrordiscriminates the source. Distinct fromonDropped(no inclusion observed) andonReplaced(different tx mined for the same nonce) — those are their own terminal states with their own typed payloads.onDroppedandonReplacedare part of the contract; this package's helpers don't fire them. Detecting drop vs replacement requires multi-block observation with nonce-watching — that's@valve-tech/tx-tracker's job. The hooks live inWriteHookParamsso consumers wire one set of callbacks; the tracker fires them when it ships.TX_FLOWis intentionally empty. Every protocol's flow names (fulfillIntent,addFunds,mintNFT, etc.) are its own concern. Extend theTxFlowtype via your own union.- Pre-hash states are first-class.
preparingandawaiting-signaturecarry nohash— they exist so the UI has something to show during the wallet-sign window. idis stable,hashis not. Registries assignidatbeginTxtime and attachhashlater. This lets pre-hash UI render before the wallet returns.
For AI agents
Machine-readable integration skills ship in this tarball under
skills/. Run npx @valve-tech/agent-skills install to copy all
installed @valve-tech/* skills into .claude/skills/, or read them
in place at node_modules/@valve-tech/wallet-adapter/skills/.
License
MIT
