Skip to content
docs

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'

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.

/** 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 UserRejectedError when 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 confirm step run before the device is asked, so the screen on the phone and the screen on the device agree.
/** 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.

/**
* 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' with derive). The Keyring handles many accounts for seed-based wallets.
  • 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 dispose if your signer holds a session or a connection; wallet.dispose() calls it.