Skip to content

Modernized viem SDK + separate Ethers SDK, both using shared core - #74

Draft
bjfresh wants to merge 21 commits into
mainfrom
bj/refactor/viem-first-sdk
Draft

bjfresh wants to merge 21 commits into
mainfrom
bj/refactor/viem-first-sdk

Conversation

@bjfresh

@bjfresh bjfresh commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Splits the SDK in two: @tokenbound/sdk becomes viem-only, and ethers v5/v6 support moves to a new @tokenbound/ethers package. Shared ERC-6551 logic lives in one protocol layer that both build on, so nothing is reimplemented.

  • Published bundle is ~92% smaller (gzipped, main entry: 133 KB → 11 KB)
  • New .extend(tokenboundActions()) viem decorator — everything namespaced under client.tokenbound.*
  • New checkProtocolDeployment() — reports whether ERC-6551 is deployed on the connected chain
  • Ethers users install one package, not two — the method surface matches main exactly, so migration is an import change plus chain
  • Same test bodies run across all 6 variants (viem, ethers v5, ethers v6 × ERC-6551 V2/V3)
  • Cross-chain execution is now tested against LayerZero's deployed executor; main had no coverage

Package size

main this branch change
ESM, gzipped 136,533 B 11,479 B −91.6%
ESM, uncompressed 488,908 B 65,590 B −86.6%
CJS, uncompressed 338,416 B 44,725 B −86.8%

Measured by building both branches and comparing the main entry point's chunk graph (runtime JS only, gzip -9). Subpath entries are smaller again — @tokenbound/sdk/protocol is 7,331 B gzipped.

What makes it smaller

  • LayerZero is installed rather than copied in. main compiled @layerzerolabs/lz-v2-utilities — plus the six @ethersproject/* packages and Solana reference it pulls in — directly into the published JS, so you downloaded it twice: once as a dependency, once baked into the bundle. It's now left as a normal dependency for npm to install. Same install command, no extra step.
  • supported viem chains no longer auto-imported main accepted a bare chainId and resolved it through a chainIdToChain() lookup table that statically imported 16 chain objects from viem/chains, so every consumer shipped all 16 regardless of which they used. The SDK now imports no chains at all — the chain is supplied by the caller, reducing the overhead.
  • Code splitting across three entry points (., ./viem, ./protocol)
  • dist/stats.html is no longer published — main currently wrongly ships bundle-analyzer output inside dist

New: viem decorator

tokenboundActions() is a standard viem decorator, so the SDK composes the way the rest of the viem ecosystem does. Everything lands under a single .tokenbound namespace and can never collide with viem's own actions:

import { createWalletClient, custom } from "viem"
import { mainnet } from "viem/chains"
import { tokenboundActions } from "@tokenbound/sdk/viem"

const client = createWalletClient({
  chain: mainnet,
  account: "0x...",
  transport: custom(window.ethereum),
}).extend(tokenboundActions())

const addresses = await client.getAddresses()                        // viem's
const account = client.tokenbound.getAccount({ tokenContract, tokenId })  // Tokenbound's

The chain comes from the client; the decorator's optional argument pins the ERC-6551 deployment and nothing else.

Reads work on any client, including a PublicClient with no account. Writes require a client that can sign, and that split is enforced by the type system rather than a runtime check:

const publicClient = createPublicClient({ chain: mainnet, transport: http() })
  .extend(tokenboundActions())

publicClient.tokenbound.getAccount({ tokenContract, tokenId })   // ✅
publicClient.tokenbound.createAccount({ tokenContract, tokenId })
// ❌ Property 'createAccount' does not exist on type 'TokenboundPublicActions'

This sits alongside the existing new TokenboundClient() path. Both run the same protocol code; neither is deprecated.

New: checkProtocolDeployment()

Answers "is ERC-6551 usable on this chain?" — distinct from checkAccountDeployment(), which asks whether one particular account exists.

const status = await client.tokenbound.checkProtocolDeployment()
// { isRegistryDeployed, isImplementationDeployed, isFullyDeployed,
//   registryAddress, implementationAddress }
  • Reports each contract separately rather than one boolean, because partial deployments are real: Gnosis carries the ERC-6551 registry but no Tokenbound account implementation. Collapsing that to false would discard the one fact that makes it diagnosable
  • Probes whatever the client is pinned to — V2, V3, or custom addresses
  • Purely opt-in: nothing calls it internally, so no RPC is added to any existing path

Cross-chain execution

Both packages support it, through one shared encoder. encodeCrossChainCall takes a minimal read interface rather than a viem client:

export type ProtocolCaller = {
  call: (tx: { to: Address; data: Hex }) => Promise<Hex>
}

A viem client satisfies it with a thin wrapper; @tokenbound/ethers passes its adapter, which already has this shape. So @tokenbound/ethers constructs no viem client — the size win holds — and protocol/ is transport-agnostic throughout.

Pass a chainId other than the client's and the call routes through LayerZero:

await client.tokenbound.execute({
  account,
  to,
  value: 0n,
  data: "0x",
  chainId: base.id,   // origin is the client's chain
})

The LayerZero fee is quoted live from the origin chain's executor and attached to the outer transaction, which targets the tokenbound account.

Two things worth knowing:

  • The quote uses encodeFunctionData + call + decodeFunctionResult rather than viem's readContract, so a revert arrives as a bare call failure. It is wrapped with the executor address and destination chain.
  • Executor gas is fixed at 200,000 in the options blob. Destination calls needing more will arrive and revert.

Structure

  • Core protocol logic extracted to src/protocol/, viem-specific surface to src/viem/
  • hasBytecode() and toProtocolDeploymentStatus() live in the protocol layer, so the viem core and @tokenbound/ethers share one definition of "deployed" and cannot drift. Each package still fetches bytecode through its own transport
  • Redundant re-export barrels removed across both packages
  • scripts/validate-package-contents.mjs added to keep published tarballs clean
  • @tokenbound/ethers now ships usable types. Its types/exports pointed at ./dist/index.d.ts, but the dts plugin emits ./dist/src/index.d.ts (as @tokenbound/sdk already declared), so consumers resolved the package as any

Tests

  • Shared suite runs the same test bodies across a 6-cell matrix: viem, ethers v5 and ethers v6, each against the ERC-6551 V2 and V3 deployments
  • TestAllViem.test.ts and TestAllEthers.test.ts are structurally identical — same helpers, naming, describe/it layout — differing only where the package genuinely differs
  • All 33 core test cases from main verified present (1:1, no drops)
  • Test ABIs now come from @wagmi/cli + etherscan against verified deployment addresses, not hand-written fragments
  • Verbose diagnostics gated behind USE_VERBOSE_TESTS=true; default output is clean
  • pnpm test:viem / pnpm test:ethers to run one side only

Cross-chain coverage

14 cases against LayerZero's deployed executor on the mainnet fork — a real eth_call, not a stub:

  • packages/sdk/src/test/crossChain.test.ts (6) — quote through a viem client
  • packages/ethers/src/test/TestCrossChain.test.ts (8, v5 and v6) — quote through the ethers adapter, calldata compared byte-for-byte against the viem core

They assert the transaction targets the account rather than the executor, carries a fee the executor itself quoted, that quote and execute agree on payload and options, and that same-chain execution is unaffected.

Not covered: delivery. The executor is origin-side only and the fork is one chain, so the destination account is never reached — nothing here proves the message arrives, that the destination account exists or accepts it, or that 200,000 gas suffices. That needs two forks plus a relayer stand-in.

Typechecking

Both packages' tsconfig.json excludes src/test, so the test trees were checked by nothing. Each package now has a tsconfig.test.json, and pnpm typecheck runs both source and test configs through tsc.

API parity with main

All 16 of main's public methods exist on both packages, with the same names and
shapes. checkProtocolDeployment() is the only addition. Nothing was dropped — a
migrating consumer keeps every method they had, including getSDKVersion().

What changes is construction, not the methods: chain replaces chainId, and the
ethers package takes a signer where the viem one takes a walletClient. See
MIGRATION.md.

Behavior changes

  • chain is now required where chainId was previously accepted
  • Removed catch (e) { console.log(e); throw e } from transfer and signing methods — the SDK no longer prints to consumer consoles. Throwing behavior is unchanged
  • @tokenbound/ethers reuses SDK-defined param types rather than redefining them
  • @tokenbound/ethers does not accept publicClient or publicClientRPCUrl; reads go through the signer's provider, so point that at the RPC you want
  • signer is required rather than optional, so a missing one throws at construction instead of on first use
  • encodeCrossChainCall takes caller: ProtocolCaller instead of publicClient: PublicClient. It is exported, so a direct caller must update; both client APIs are unaffected

Example Apps

Consolidated to four, all on Vite:

Example Stack
vite-wagmi-viem wagmi 2 + RainbowKit
vite-wagmi-ethers ethers v5, wagmi 2 + ConnectKit
vite-wagmi-ethers6 ethers v6, wagmi 2 + ConnectKit
vite-wagmi3-viem wagmi 3 + useConnect (EIP-6963)

Each takes a chain, NFT contract and token id, derives the account with getAccount(), and shows ownership, deployment status and balance before anything is clickable. Actions are numbered steps gated on their own preconditions — create needs an NFT, execute needs a deployed account the connected wallet holds, transfer needs a funded account — and disabled buttons say which precondition is missing.

Previously they were hardcoded to one NFT owned by the original author, so write actions reverted with NotAuthorized() for everyone else.

  • Deleted vite-wagmi-ethers-rainbowkit (duplicated vite-wagmi-viem's wallet story)
  • Fixed a silently-dead WalletConnect config: examples read process.env.NEXT_PUBLIC_*, which is always undefined under Vite. Now import.meta.env.VITE_* with typed ImportMetaEnv
  • Chains are picked from a dropdown (Base Sepolia default, mainnets labelled), replacing a hardcoded baseGoerli destination that was sunset in 2023
  • The ethers clients build lazily, since @tokenbound/ethers requires a signer and the wagmi hook has none until a wallet connects

Security

  • pnpm.overrides pins @walletconnect/logger>pino to 10.0.0, clearing the pino@7.11.0 advisory pulled in via @walletconnect/ethereum-provider → @reown/appkit. Zero references to the vulnerable version remain in the lockfile

Docs

Both package READMEs and the root README rewritten — the SDK README documented a removed API (signer, chainId, prepareExecuteCall) and had an unclosed code fence. The testing section also told contributors env setup was required (it isn't — the fork falls back to a public RPC) and referenced renderWithWagmiConfig, which no longer exists.

Notes for review

  • Fork-backed suites occasionally fail on the public RPC fallback (block not found, pruned mid-run) and pass on retry. Pinning a fork block would fix it but requires an archive endpoint — documented in .env.example
  • VITE_ANVIL_MAINNET_FORK_BLOCK_NUMBER was documented on main but read by no code. It is wired up now, off by default

bjfresh and others added 10 commits September 14, 2026 19:08
VITE_ANVIL_MAINNET_FORK_BLOCK_NUMBER was documented in .env.example and the
README but read by no code, on this branch and on main alike — the fork always
used the chain head. Wire it into CREATE_ANVIL_OPTIONS, validated and omitted
entirely when unset so the default behaviour is unchanged.

Pinning is left off by default because it requires an archive endpoint: public
RPCs refuse historical state, and anvil then fails before genesis with
"Archive requests require a personal token". Anvil's on-disk cache masks this
locally, so a machine that has already fetched the block passes while a clean
checkout or CI fails.

Also record why the Zora 721 fixture is an open edition: an unpinned fork keeps
minting from it, so a drop that could sell out would fail these suites over
time with no code change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every example was hardcoded to MoonTrees #0 and the tokenbound account derived
from it. That NFT belongs to the original author, so clicking any write action
as anyone else opened MetaMask and reverted with NotAuthorized() — the account
only accepts calls from the holder of its NFT. Nothing signalled why.

Replace the hardcoded values with inputs and derive the account instead:

- chain picker (Base Sepolia first, so the default costs nothing; mainnets are
  labelled), which also exercises the SDK now taking any viem Chain
- NFT contract + token id inputs, starting empty so no misleading defaults
- the account address comes from getAccount(), recomputed as you type
- ownership, deployment (via checkAccountDeployment) and balance are shown
  before anything is clickable

Structure the actions as numbered steps, each owning the fields it needs, and
gate each on its own precondition: create needs an NFT, execute needs a
deployed account the connected wallet holds, transfer needs a funded account.
Disabled buttons say which precondition is missing.

Also fixes, found along the way:

- baseGoerli was the cross-chain destination in both viem examples, but it was
  sunset in 2023 and the client runs on Base Sepolia. The destination now comes
  from the chain picker.
- the ethers examples built a client with sepolia while wagmi was configured
  for baseSepolia, so the signer's network never matched.
- @tokenbound/ethers throws without a signer, which the hook lacks until a
  wallet connects — the client is now built lazily instead of throwing during
  render.
- Account no longer reprints the connected address the connect button already
  shows, and a non-owner's address is abbreviated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Run Tests section opened with "Set up environment variables in .env.test"
and an Alchemy key, implying the suites could not run without one. They can:
the mainnet fork falls back to a public RPC, verified by moving .env.test and
.env.local aside and running the fork suite green.

Other corrections in that section:

- Foundry was never listed as a prerequisite, though anvil is the one genuinely
  required install
- it recommended setting VITE_ANVIL_MAINNET_FORK_BLOCK_NUMBER, which breaks a
  clean checkout unless the endpoint serves archive requests
- `pnpm prep` and `pnpm wagmi` were given as root commands; they only exist in
  packages/sdk
- it described integration tests rendered with renderWithWagmiConfig, which no
  longer exists
- USE_VERBOSE_TESTS was documented as an env var, but it is read from
  process.env with no VITE_ prefix, so Vite never loads it from .env.test — it
  only works inline

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
encodeCrossChainCall needed a viem PublicClient for one read — the LayerZero
fee quote — so the ethers client could not use it and threw instead. Take a
one-method interface instead:

    type ProtocolCaller = { call: (tx) => Promise<Hex> }

A viem client satisfies it with a thin wrapper; the ethers adapter already had
exactly this shape, so @tokenbound/ethers still constructs no viem client and
protocol/ no longer quietly requires one.

The quote is assembled with encodeFunctionData + call + decodeFunctionResult
rather than readContract, which is client-only. A revert then arrives as a bare
call failure, so it is wrapped with the executor and destination chain.

The LayerZero fee is paid by the sender, so it rides on the outer transaction.
encodeExecution zeroes that (correct for same-chain, where the account spends
its own balance), so the cross-chain path sets it explicitly.

Tests run against LayerZero's deployed executor on the mainnet fork rather than
a stub: 6 cases via a viem client, 8 via the ethers adapter across v5 and v6,
with the calldata compared byte-for-byte between the two. Delivery is still
uncovered — the executor is origin-side only and the fork is one chain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both packages' tsconfig excludes src/test, so nothing checked the test files:
the SDK's vitest typecheck covers only *.test-d.ts, and ethers had no test
tsconfig at all. Add one for ethers, and run both packages' test configs
through tsc as part of `typecheck` and `test`.

That surfaced one existing error: a v6 signer mock in TestEthersVersions whose
provider was missing the call/getCode the adapter routes reads through. Version
detection ignores them, but EthersProvider requires them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bjfresh

bjfresh commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor Author

@jaydenwindle this PR consolidates some improvements I had baked months ago and some big improvements I've chipped away at over time. Note the breaking change (chainId -> chain), which is significant but helps unlock very notable bundle size improvements.

For now, this is just for visibility. I reviewed code manually a couple of weeks back but will revisit. We'd probably want to do a more significant point release (to 0.6), and would need to update docs.tokenbound.org and deploy the ethers project separately for NPM. Happy to follow up with this.

I'll also work on some of the CI issues.

@jaydenwindle

Copy link
Copy Markdown
Contributor

@bjfresh this is awesome, thanks so much for putting this together! Would love to merge this in and cut a 0.6 release. Have also been considering publishing the sdk under the @erc6551/sdk namespace - this release might be a good opportunity to do that.

@bjfresh

bjfresh commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

All sounds good. I should have a chance to pore over this PR in more detail and make some tweaks in the next week. Let me know if you have specific timelines in mind.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants