How to add a new wallet to this repo, including registering its WalletOption
and error codes through the extensible registries that shipped with
@swapkit/helpers 5.1.0 (swapkit/sdk#346). No @swapkit/helpers release is
needed to add a wallet.
Worked examples in this repo:
packages/wallet-extensions/src/noir-wallet/— injected browser-extension wallet with custom error codes (added via fork PR #111 + #144)packages/wallets/src/tonconnect/— connector living directly in@swapkit/wallets, registry registration only
| Package | Contents |
|---|---|
@swapkit/wallet-extensions |
injected browser-extension providers (window.*) |
@swapkit/wallet-hardware |
Ledger, Trezor, KeepKey |
@swapkit/wallets |
aggregator: loadWallet, SKWallets types, plus a few connectors that need no separate package (tonconnect, xaman, radix, …) |
The WalletOption union, SwapKitError keys, and the registries themselves
are defined upstream in @swapkit/helpers (SDK monorepo).
Create packages/wallet-extensions/src/<wallet>/index.ts built around
createWallet from @swapkit/wallet-core:
connectreceives{ addChain, walletType }and returns theconnect<Name>function. For each supported chain, spread the chain's toolbox intoaddChain({ ...toolbox, address, chain, walletType, ... })and override what the wallet handles itself (transfer,getBalance,signMessage,signAndBroadcastTransaction, …).directSigningSupportmaps chains where the wallet can sign an API-prebuilt transaction (route.tx). With{ [chain]: true }, core routes swaps to the generic SwapKit plugin, which decodesroute.txand calls yoursignAndBroadcastTransactionoverride. With{}, swaps go through the provider plugin, which calls high-level methods liketransfer. Wallets that only expose a "send" RPC can still support direct signing by translating the decoded transaction back into a send — seeextractUtxoTransferIntentinpackages/wallet-extensions/src/helpers/utxoTransferIntent.ts(Vultisig, Ctrl, KeepKey BEX).- If the wallet cannot serve a method the toolbox spread exposes, override it
with a clear
SwapKitErrorthrow instead of letting the toolbox default fail deep inside signing (seeunsupportedUtxoSignTransaction).
Create packages/wallet-extensions/src/<wallet>/register.ts. The type-level
declaration and the runtime registration must sit side by side — one without
the other compiles to a trap (see Gotchas):
import { registerErrorCodes, registerWalletOption } from "@swapkit/helpers";
declare module "@swapkit/helpers" {
interface WalletOptionRegistry {
MY_WALLET: "MY_WALLET";
}
interface SwapKitErrorRegistry {
wallet_my_wallet_not_found: 80201;
}
}
registerWalletOption("MY_WALLET", "MY_WALLET");
registerErrorCodes({
wallet_my_wallet_not_found: 80201,
});Rules:
- The
declare modulemust target"@swapkit/helpers"— that package declares the registries — even when consumers importWalletOptionfrom@swapkit/core. - Error codes must use the 80000–89999 extension range. It is reserved by convention, not enforced at runtime; first-party codes live outside it and collisions throw only when two keys claim the same number. Grep this repo's registers for the next free block (noir-wallet holds 80101–80103).
- Registration is idempotent for identical values and throws
helpers_invalid_paramson conflicting re-registration, so the module may safely load through multiple import paths. - Registered options are appended to
SKConfig's default wallet list automatically (and surviveSKConfig.reinitialize()).
Make the connector's index.ts import it first:
// Registers WalletOption.MY_WALLET and the wallet_my_wallet_* error codes
// before anything below reads them.
import "./register";Add two subpath exports to packages/wallet-extensions/package.json,
mirroring the neighbors' shape:
The separate register subpath exists so loadWallet (and any app that only
references WalletOption.MY_WALLET — wallet pickers, walletType
comparisons) can import a ~20-line side-effect module instead of statically
pulling the whole connector, which would defeat the lazy loading in the match
arms. Apps that import the connector subpath directly need nothing extra —
index.ts imports ./register itself.
Also add the new register module to packages/wallets/src/register.ts — the
roll-up that @swapkit/wallets/register exposes to apps. An app that puts a
registered option in a module-scope wallet list (as the SwapKit UI does in its
wallet dialog) evaluates WalletOption.MY_WALLET before any connector loads;
its entry module must import "@swapkit/wallets/register" first or the list
silently contains undefined.
Three touch points:
-
src/utils.ts— side-effect import at the top (ordering matters: the match readsWalletOption.MY_WALLETat call time, before any connector has loaded), plus a lazy match arm:import "@swapkit/wallet-extensions/my-wallet/register"; // ... .with(WalletOption.MY_WALLET, async () => (await import("@swapkit/wallet-extensions/my-wallet")).myWallet)
-
src/types.ts—SKWalletsandSKWalletsSupportedChainsentries, keyed[WalletOption.MY_WALLET]:(computed keys in type position work because the const's properties are distinct string literals).
Alongside the usual connector tests, always assert the registered value:
test("registers MY_WALLET in the extensible WalletOption registry", () => {
expect(WalletOption.MY_WALLET).toBe("MY_WALLET");
});Without this, a missing registration makes walletType assertions pass
vacuously: expect(wallet.walletType).toBe(WalletOption.MY_WALLET) is
undefined === undefined. This exact false-green happened in the original
Noir Wallet PR.
Add a .changeset/*.md entry (minor for a new connector). If the change
also bumps @swapkit/* dependency versions, run bun generate:dep-changeset
and commit the generated file — CI's check workflow fails without it.
-
Type/runtime skew. The module augmentation is visible program-wide the moment the
.d.tsis in the compilation; the runtime key exists only afterregister.tsexecutes. Code that readsWalletOption.MY_WALLETbefore registration getsundefined— and a ts-pattern.with(undefined, …)arm then matches any unregistered option. Keep the side-effect import above the code that reads the key. -
Tree-shaking. Neither package sets
"sideEffects"in package.json, so bundlers keep the register imports. If"sideEffects": falseis ever added, theregister.tsfiles must be listed as exceptions or registration silently disappears from production bundles. -
One
@swapkit/helperscopy, exactly. Published SDK packages pin exact helpers versions. Bumping helpers alone forks the lockfile into nested copies — two helpers instances at runtime means registrations land in one copy while@swapkit/corereads the other, with no error. Bump the whole@swapkitrelease train together, reinstall clean with the CI-pinned bun (see.github/workflows/ci.yml), and verify:grep -o '@swapkit/helpers@[0-9][^"]*' bun.lock | sort | uniq -c
One
5.xentry is correct. (A legacy4.xcopy nested under the old@swapkit/uidevDep is expected and inert.)