Custom signer
A signer is the only object that touches a private key. Accounts build transactions, talk to the network and hand the signer exactly the bytes to sign. Replace the signer, and the key can live anywhere: on a hardware wallet, in an MPC network, in an HSM, behind your own service. Everything else (addresses, balances, transfers, swaps, policies) works unchanged.
Snippets come from examples/signer, which our CI typechecks and tests against the same
addresses and signatures a seed wallet produces.
import { UserRejectedError, externalSigner, type SignOptions, type SignScheme, type Signer } from '@anywalletkit/core'import { Wallet, type Policies } from '@anywalletkit/kit'import { WalletManagerBtc } from '@anywalletkit/wallet-btc'import { WalletManagerEvm } from '@anywalletkit/wallet-evm'import { WalletManagerSolana } from '@anywalletkit/wallet-solana'What a signer receives
Section titled “What a signer receives”externalSigner({ curve, publicKey, sign }) wraps your callback. The SDK calls sign with the
payload and options, and checks the length of what comes back.
scheme |
Curve | Payload | Return |
|---|---|---|---|
ecdsa-recoverable |
secp256k1 | 32-byte hash | 65 bytes r‖s‖v, v = 0 or 1 |
ecdsa |
secp256k1 | 32-byte hash | 64 bytes r‖s, low-S |
schnorr |
secp256k1 | 32-byte message | 64 bytes, BIP-340 (Taproot) |
ed25519 |
ed25519 | message bytes | 64 bytes |
options.context says what the bytes are, so a device can show the right screen:
| Context | Chain | Payload |
|---|---|---|
evm-tx, evm-191, evm-712 |
EVM | Transaction hash, personal_sign hash, EIP-712 hash |
btc-sighash, btc-taproot-keypath, btc-message |
Bitcoin | SegWit sighash, Taproot sighash (with tweak), BIP-137 hash |
tron-tx, tron-message |
Tron | Transaction hash, TIP-191 hash |
solana-message, solana-offchain |
Solana | Transaction message bytes, off-chain message |
ton-external, ton-message |
TON | Signing-cell hash, message bytes (never 32 bytes) |
ton-proof, ton-sign-data |
TON | TON Connect ton_proof digest, signData digest |
near-tx, near-message, near-nep413 |
NEAR | sha256 of the transaction, message bytes (never 32 bytes), NEP-413 digest |
One ed25519 key behind several chains (an external signer registered for Solana, TON and NEAR)
is guarded across them: TON and NEAR sign() refuse bytes that name the key as a Solana signer,
and Solana sign() on an external key refuses 32-byte messages (a TON or NEAR transaction digest).
A signer advertises the schemes it can produce. By default that is the curve’s main one:
ecdsa-recoverable for secp256k1, ed25519 for ed25519. Add ecdsa for Bitcoin SegWit
inputs and schnorr for Taproot. Asking for a scheme the signer doesn’t list throws
SignerError before your callback runs.
A hardware wallet
Section titled “A hardware wallet”/** What a hardware wallet SDK typically gives you. */export interface Device { publicKey(path: string): Promise<Uint8Array> // 33-byte compressed secp256k1 signDigest(path: string, digest: Uint8Array, prompt: string): Promise<Uint8Array | 'rejected'>}
const PROMPTS: Record<string, string> = { 'evm-tx': 'Sign transaction', 'evm-191': 'Sign message', 'evm-712': 'Sign typed data',}
export async function deviceSigner(device: Device, path = "m/44'/60'/0'/0/0"): Promise<Signer> { return externalSigner({ curve: 'secp256k1', publicKey: await device.publicKey(path), sign: async (digest, options) => { // options.context says what the bytes are, so the device can show the right screen. const sig = await device.signDigest(path, digest, PROMPTS[options.context ?? ''] ?? 'Sign') if (sig === 'rejected') throw new UserRejectedError('Rejected on the device.') return sig // 65 bytes r‖s‖v (v = 0 or 1) }, })}
export async function walletOnDevice(device: Device, policies: Policies): Promise<Wallet> { const wallet = new Wallet(await deviceSigner(device), { policies }) // no seed in the app at all wallet.registerWallet('eip155:1', WalletManagerEvm, { provider: 'https://ethereum-rpc.publicnode.com', chainId: 1 }) return wallet}- Throw
UserRejectedErrorwhen the user declines on the device. Every module treats it as “cancelled”, not as a failure to retry. - The app never sees a seed.
new Wallet(signer)takes the signer instead of a phrase. - Policies still apply. Fee caps, allowlists and your
confirmstep run before the device is asked, so the screen on the phone and the screen on the device agree.
A signing service
Section titled “A signing service”/** A signing service you run (MPC node, HSM, KMS proxy): it holds the key, the app holds a session token. */export function remoteSigner(options: { url: string token: string curve: 'secp256k1' | 'ed25519' publicKey: Uint8Array schemes?: readonly SignScheme[] fetch?: typeof fetch}): Signer { return externalSigner({ curve: options.curve, publicKey: options.publicKey, ...(options.schemes ? { schemes: options.schemes } : {}), sign: async (payload: Uint8Array, opts: SignOptions & { scheme: SignScheme }) => { const res = await (options.fetch ?? fetch)(options.url, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${options.token}` }, body: JSON.stringify({ payload: toHex(payload), scheme: opts.scheme, context: opts.context ?? null }), }) if (res.status === 403) throw new UserRejectedError('The signing service refused.') if (!res.ok) throw new Error(`Signing service: HTTP ${res.status}`) const { signature } = (await res.json()) as { signature: string } return fromHex(signature) // the SDK checks the length for the scheme }, })}This is the shape for an MPC node, an HSM, a cloud KMS behind your proxy, or a backend signer for bots and treasury accounts. The service returns a signature over the bytes it is given.
Several chains, two curves
Section titled “Several chains, two curves”/** * One external signer = one key = one curve. Two curves → two wallets. * The secp256k1 key serves EVM and Bitcoin, but it is ONE key: the Bitcoin * address is that key's SegWit address, not the m/84' one a seed wallet shows. */export function walletsOnService(service: { url: string; token: string; secpKey: Uint8Array; edKey: Uint8Array; fetch?: typeof fetch }) { const evmAndBitcoin = new Wallet( remoteSigner({ ...service, curve: 'secp256k1', publicKey: service.secpKey, schemes: ['ecdsa-recoverable', 'ecdsa'], // EVM and BTC messages use the first, SegWit inputs the second }), ) evmAndBitcoin.registerWallet('eip155:1', WalletManagerEvm, { provider: 'https://ethereum-rpc.publicnode.com', chainId: 1 }) evmAndBitcoin.registerWallet('bip122', WalletManagerBtc, { client: { type: 'esplora-http', clientConfig: { url: 'https://mempool.space/api' } }, })
const solana = new Wallet(remoteSigner({ ...service, curve: 'ed25519', publicKey: service.edKey })) solana.registerWallet('solana', WalletManagerSolana, { provider: 'https://api.mainnet-beta.solana.com' }) return { evmAndBitcoin, solana }}- One external signer is one key. It gives one account per chain (index 0), and it signs on one curve. EVM, Bitcoin, Tron and Zcash share secp256k1; Solana, TON and NEAR use ed25519. Cover both families with two signers and two wallets.
- The same secp256k1 key on EVM and Bitcoin gives that key’s addresses on both. A seed
wallet derives Bitcoin at
m/84'/0'/0'/0/0, a different key, so the addresses differ. Tell users who move from a seed wallet. - Several accounts need several keys from the device or service: one signer per path, or a
signer that derives (
kind: 'seed'withderive). The Keyring handles many accounts for seed-based wallets.
Checklist
Section titled “Checklist”- Prove the public key matches before the first send: sign a test message and verify it
with
account.verify. - Return signatures in the exact shape from the table. The SDK rejects a wrong length or a recovery id other than 0 or 1.
- Implement
disposeif your signer holds a session or a connection;wallet.dispose()calls it.