Near Learn

Signing and sending an EVM transaction

Send an EVM transaction from a NEAR account with Chain Signatures: build it with viem, sign through v1.signer, broadcast it, and avoid nonce and gas traps.

Advanced9 min read3-question check

Now we spend from the derived address. The flow is the same for every chain: build an unsigned transaction, hash it the way the target chain expects, ask the signer contract to sign the hash, attach the signature and broadcast. chainsig.js wraps each step for EVM chains on top of viem. Reference: Signing transactions and the library’s examples folder.

A complete script (testnet → Sepolia)#

send-eth.ts — adapted from chainsig.js examples/send-eth.ts
TypeScript
// npm install chainsig.js viem @near-js/accounts @near-js/crypto @near-js/providers @near-js/signers
import { Account } from '@near-js/accounts'
import { KeyPair, type KeyPairString } from '@near-js/crypto'
import { JsonRpcProvider } from '@near-js/providers'
import { KeyPairSigner } from '@near-js/signers'
import { chainAdapters, contracts } from 'chainsig.js'
import { createPublicClient, http, parseEther } from 'viem'
import { sepolia } from 'viem/chains'

const accountId = process.env.NEAR_ACCOUNT_ID!                  // e.g. alice.testnet
const privateKey = process.env.NEAR_PRIVATE_KEY as KeyPairString // ed25519:...
const PATH = 'sepolia-1'

// 1. The NEAR account that will call v1.signer-prod.testnet `sign`
const provider = new JsonRpcProvider({ url: 'https://test.rpc.fastnear.com' })
const account = new Account(accountId, provider, new KeyPairSigner(KeyPair.fromString(privateKey)))

const mpc = new contracts.ChainSignatureContract({
  networkId: 'testnet',
  contractId: 'v1.signer-prod.testnet',
})
const evm = new chainAdapters.evm.EVM({
  publicClient: createPublicClient({ chain: sepolia, transport: http() }),
  contract: mpc,
})

// 2. Our address on Sepolia (fund it with test ETH first)
const { address: from } = await evm.deriveAddressAndPublicKey(accountId, PATH)

// 3. Build: fills nonce, chainId, gas and EIP-1559 fees from the RPC
const { transaction, hashesToSign } = await evm.prepareTransactionForSigning({
  from: from as `0x${string}`,
  to: '0x427F9620Be0fe8Db2d840E2b6145D1CF2975bcaD',
  value: parseEther('0.001'),
})

// 4. Sign: one NEAR transaction calling sign() per hash (300 Tgas, 1 yoctoNEAR)
const rsvSignatures = await mpc.sign({
  payloads: hashesToSign,
  path: PATH,
  keyType: 'Ecdsa',
  signerAccount: account,
})

// 5. Attach the signature and broadcast with eth_sendRawTransaction
const signedTx = evm.finalizeTransactionSigning({ transaction, rsvSignatures })
const { hash } = await evm.broadcastTx(signedTx)
console.log(`https://sepolia.etherscan.io/tx/${hash}`)

In a browser dApp, replace signerAccount: account with { accountId, signAndSendTransactions } from your NEAR wallet connector; the user then approves one NEAR transaction per signature. For mainnet use networkId: 'mainnet', contractId: 'v1.signer' and a mainnet EVM RPC.

What actually goes to NEAR#

mpc.sign() is a thin wrapper. hashesToSign[0] is keccak256 of the serialized unsigned EIP-1559 transaction, and the NEAR transaction it sends is equivalent to this CLI call:

Shell
near contract call-function as-transaction v1.signer-prod.testnet sign \
  json-args '{"request":{"path":"sepolia-1","payload_v2":{"Ecdsa":"<32-byte hash, hex>"},"domain_id":0}}' \
  prepaid-gas '300.0 Tgas' attached-deposit '1 yoctoNEAR' \
  sign-as alice.testnet network-config testnet sign-with-keychain send

Calling a contract instead of sending ETH#

An ERC-20 transfer from the derived address
TypeScript
import { encodeFunctionData, erc20Abi } from 'viem'

const data = encodeFunctionData({
  abi: erc20Abi,
  functionName: 'transfer',
  args: ['0xRecipient...', 1_000_000n], // 1 USDC (6 decimals)
})

const { transaction, hashesToSign } = await evm.prepareTransactionForSigning({
  from: from as `0x${string}`,
  to: '0xTokenContract...',
  data,
  value: 0n,
})
// ...then sign / finalize / broadcast exactly as above

Pitfalls#

TrapWhy it bitesFix
Nonce collisionsprepareTransactionForSigning reads the nonce from the RPC. Two transactions prepared before either is mined get the same nonce; only one can land.Serialize sends per derived address, or pass nonce yourself from your own counter.
Stale feesFees are estimated before MPC signing, which takes seconds. On a busy chain the base fee can move past your maxFeePerGas and the tx sits pending.Pass maxFeePerGas / maxPriorityFeePerGas with headroom; you only pay the actual base fee.
Estimate failsIf gas is not given, the adapter calls estimateGas from the derived address. An unfunded address or a call that would revert makes preparation throw.Fund the address first; simulate the call; pass gas explicitly for known operations.
Signatures cannot be recalledThe result of sign is public in the NEAR transaction outcome. Whoever holds the unsigned transaction (your backend, a relayer, a log) can broadcast it, and there is no revoke.Treat “signed” as “sent”. The only way to cancel is to use up the nonce with another transaction.
Wrong chainEIP-1559 transactions include chainId, but the same key controls the address on every EVM chain.Use one path per chain (sepolia-1, base-1) as the NEAR docs recommend.
Signing the wrong bytesThe signer signs exactly the 32 bytes you give it. Signing the RLP instead of its hash, or a legacy hash for a 1559 tx, gives a valid signature for nothing.Use the adapter’s hashesToSign; if building manually, sign keccak256(serializeTransaction(tx)).

Check yourself

3 questions · progress saved in this browser

  1. 1.You prepare two transfers from the same derived address in parallel, then sign and broadcast both. One never confirms. Most likely cause?
  2. 2.What does chainsig.js pass as the Ecdsa payload for an EVM transaction?
  3. 3.You signed an EVM transaction through v1.signer but changed your mind before broadcasting. How do you cancel it?