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)#
// 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:
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 sendCalling a contract instead of sending ETH#
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 abovePitfalls#
| Trap | Why it bites | Fix |
|---|---|---|
| Nonce collisions | prepareTransactionForSigning 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 fees | Fees 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 fails | If 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 recalled | The 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 chain | EIP-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 bytes | The 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