Near Learn

Deriving Ethereum, Bitcoin and Solana addresses

Derive the EVM, Bitcoin and Solana addresses a NEAR account controls through Chain Signatures with chainsig.js, then cross-check them against v1.signer.

Advanced8 min read3-question check

Before anything is signed, you need the address: where users send funds, what you show in the UI, what you put in an allowlist. Derivation is pure math on public data, so it costs nothing and needs no transaction. This lesson uses chainsig.js (npm chainsig.js, 1.1.x in October 2026), the library used by the official implementation guide.

Install#

Shell
npm install chainsig.js viem @solana/web3.js

Derive all three from one NEAR account#

derive.ts — run with `npx tsx derive.ts alice.near`
TypeScript
import { chainAdapters, contracts } from 'chainsig.js'
import { createPublicClient, http } from 'viem'
import { base } from 'viem/chains'
import { Connection } from '@solana/web3.js'

const nearAccount = process.argv[2] ?? 'alice.near'

// Mainnet signer. For testnet: networkId 'testnet', contractId 'v1.signer-prod.testnet'
const mpc = new contracts.ChainSignatureContract({
  networkId: 'mainnet',
  contractId: 'v1.signer',
})

// EVM: one adapter per chain, chosen by the RPC you give it
const evm = new chainAdapters.evm.EVM({
  publicClient: createPublicClient({ chain: base, transport: http() }),
  contract: mpc,
})

// Bitcoin: needs a network name and an RPC adapter (Mempool.space API here)
const btc = new chainAdapters.btc.Bitcoin({
  network: 'mainnet',
  btcRpcAdapter: new chainAdapters.btc.BTCRpcAdapters.Mempool('https://mempool.space/api'),
  contract: mpc,
})

// Solana: note the option name is solanaConnection
const sol = new chainAdapters.solana.Solana({
  solanaConnection: new Connection('https://api.mainnet-beta.solana.com'),
  contract: mpc,
})

// One path per chain, so a signature for one chain is useless on another
const { address: baseAddr } = await evm.deriveAddressAndPublicKey(nearAccount, 'base-1')
const { address: btcAddr, publicKey: btcPubKey } = await btc.deriveAddressAndPublicKey(nearAccount, 'btc-1')
const { address: solAddr } = await sol.deriveAddressAndPublicKey(nearAccount, 'solana-1')

console.log({ baseAddr, btcAddr, btcPubKey, solAddr })

// Balances use the same adapters
const { balance, decimals } = await evm.getBalance(baseAddr)
console.log(`Base ETH: ${balance} (${decimals} decimals)`)

No RPC call to NEAR happens here. chainsig.js ships the root public keys of v1.signer and v1.signer-prod.testnet and derives child keys locally. For any other signer deployment it falls back to the contract’s derived_public_key view.

ChainDomain / keyAddress
EVM (Ethereum, Base, Arbitrum…)0, secp256k1Last 20 bytes of keccak256(x ‖ y) of the uncompressed key: a normal EOA. Same address on every EVM chain for the same path.
Bitcoin0, secp256k1Compressed key → P2WPKH (native SegWit): bc1q… on mainnet, tb1q… on testnet. Keep publicKey: building a BTC transaction needs it.
Solana1, ed25519The base58 public key is the address.
From derived key to address

Cross-check on-chain#

The signer contract exposes the same derivation as a view, derived_public_key(path, predecessor, domain_id). Use it in tests or a startup check so a library bug or a wrong network can never send funds to an address you do not control.

near-cli-rs: derived keys for alice.near (secp256k1, then ed25519)
Shell
near contract call-function as-read-only v1.signer derived_public_key \
  json-args '{"path":"base-1","predecessor":"alice.near","domain_id":0}' \
  network-config mainnet now
# "secp256k1:…"  (64-byte uncompressed key, base58)

near contract call-function as-read-only v1.signer derived_public_key \
  json-args '{"path":"solana-1","predecessor":"alice.near","domain_id":1}' \
  network-config mainnet now
# "ed25519:…"  (the base58 part is the Solana address)
SpoilerTurn the secp256k1 key into an EVM address yourself
TypeScript
import { keccak256, getAddress, toHex } from 'viem'
import bs58 from 'bs58' // npm install bs58

export function evmAddressFromNearKey(nearKey: string): string {
  const raw = bs58.decode(nearKey.replace('secp256k1:', '')) // 64 bytes: x ‖ y
  if (raw.length !== 64) throw new Error('expected an uncompressed secp256k1 key')
  const hash = keccak256(toHex(raw))                          // 0x + 64 hex chars
  return getAddress(`0x${hash.slice(-40)}`)                 // checksummed EOA
}

Choosing paths#

  • A path is any string. It is not BIP-32: m/44'/60'/0'/0/0 is accepted but is just another string to hash.
  • Namespace it like a key in a database: base-1, btc-treasury, user/<id>/sol. Changing the string changes the address.
  • Testnet and mainnet signers have different root keys, so the same account and path give different addresses on each.
  • Addresses belong to the account name. Moving to a new NEAR account means new addresses; funds must be moved with a signed transaction from the old one.

Check yourself

3 questions · progress saved in this browser

  1. 1.You derive alice.near + eth-1 with chainsig.js configured for testnet, fund that address, then deploy to mainnet. What is wrong?
  2. 2.Which address type does chainsig.js derive for Bitcoin?
  3. 3.Your contract vault.near will call sign with path eth-1. Which address should users fund?