Repository navigation
Conversation
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>
|
@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. |
|
@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. |
|
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. |
Summary
Splits the SDK in two:
@tokenbound/sdkbecomes viem-only, and ethers v5/v6 support moves to a new@tokenbound/etherspackage. Shared ERC-6551 logic lives in one protocol layer that both build on, so nothing is reimplemented..extend(tokenboundActions())viem decorator — everything namespaced underclient.tokenbound.*checkProtocolDeployment()— reports whether ERC-6551 is deployed on the connected chainmainexactly, so migration is an import change pluschainmainhad no coveragePackage size
mainMeasured 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/protocolis 7,331 B gzipped.What makes it smaller
maincompiled@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.mainaccepted 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..,./viem,./protocol)dist/stats.htmlis no longer published —maincurrently wrongly ships bundle-analyzer output insidedistNew: 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.tokenboundnamespace and can never collide with viem's own actions: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
PublicClientwith no account. Writes require a client that can sign, and that split is enforced by the type system rather than a runtime check: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.falsewould discard the one fact that makes it diagnosableCross-chain execution
Both packages support it, through one shared encoder.
encodeCrossChainCalltakes a minimal read interface rather than a viem client:A viem client satisfies it with a thin wrapper;
@tokenbound/etherspasses its adapter, which already has this shape. So@tokenbound/ethersconstructs no viem client — the size win holds — andprotocol/is transport-agnostic throughout.Pass a
chainIdother than the client's and the call routes through LayerZero: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:
encodeFunctionData+call+decodeFunctionResultrather than viem'sreadContract, so a revert arrives as a bare call failure. It is wrapped with the executor address and destination chain.Structure
src/protocol/, viem-specific surface tosrc/viem/hasBytecode()andtoProtocolDeploymentStatus()live in the protocol layer, so the viem core and@tokenbound/ethersshare one definition of "deployed" and cannot drift. Each package still fetches bytecode through its own transportscripts/validate-package-contents.mjsadded to keep published tarballs clean@tokenbound/ethersnow ships usable types. Itstypes/exportspointed at./dist/index.d.ts, but the dts plugin emits./dist/src/index.d.ts(as@tokenbound/sdkalready declared), so consumers resolved the package asanyTests
TestAllViem.test.tsandTestAllEthers.test.tsare structurally identical — same helpers, naming, describe/it layout — differing only where the package genuinely differsmainverified present (1:1, no drops)@wagmi/cli+ etherscan against verified deployment addresses, not hand-written fragmentsUSE_VERBOSE_TESTS=true; default output is cleanpnpm test:viem/pnpm test:ethersto run one side onlyCross-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 clientpackages/ethers/src/test/TestCrossChain.test.ts(8, v5 and v6) — quote through the ethers adapter, calldata compared byte-for-byte against the viem coreThey 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.jsonexcludessrc/test, so the test trees were checked by nothing. Each package now has atsconfig.test.json, andpnpm typecheckruns both source and test configs throughtsc.API parity with
mainAll 16 of
main's public methods exist on both packages, with the same names andshapes.
checkProtocolDeployment()is the only addition. Nothing was dropped — amigrating consumer keeps every method they had, including
getSDKVersion().What changes is construction, not the methods:
chainreplaceschainId, and theethers package takes a
signerwhere the viem one takes awalletClient. SeeMIGRATION.md.
Behavior changes
chainis now required wherechainIdwas previously acceptedcatch (e) { console.log(e); throw e }from transfer and signing methods — the SDK no longer prints to consumer consoles. Throwing behavior is unchanged@tokenbound/ethersreuses SDK-defined param types rather than redefining them@tokenbound/ethersdoes not acceptpublicClientorpublicClientRPCUrl; reads go through the signer's provider, so point that at the RPC you wantsigneris required rather than optional, so a missing one throws at construction instead of on first useencodeCrossChainCalltakescaller: ProtocolCallerinstead ofpublicClient: PublicClient. It is exported, so a direct caller must update; both client APIs are unaffectedExample Apps
Consolidated to four, all on Vite:
vite-wagmi-viemvite-wagmi-ethersvite-wagmi-ethers6vite-wagmi3-viemuseConnect(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.vite-wagmi-ethers-rainbowkit(duplicatedvite-wagmi-viem's wallet story)process.env.NEXT_PUBLIC_*, which is alwaysundefinedunder Vite. Nowimport.meta.env.VITE_*with typedImportMetaEnvbaseGoerlidestination that was sunset in 2023@tokenbound/ethersrequires a signer and the wagmi hook has none until a wallet connectsSecurity
pnpm.overridespins@walletconnect/logger>pinoto10.0.0, clearing thepino@7.11.0advisory pulled in via@walletconnect/ethereum-provider → @reown/appkit. Zero references to the vulnerable version remain in the lockfileDocs
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 referencedrenderWithWagmiConfig, which no longer exists.Notes for review
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.exampleVITE_ANVIL_MAINNET_FORK_BLOCK_NUMBERwas documented onmainbut read by no code. It is wired up now, off by default