Skip to main content

EVM Smart Contracts

Tachi runs an EVM. You can deploy Solidity contracts and call them with the same keys and VTXOs you already use. This guide compiles a small contract, deploys it to signet, calls it and reads the result back, then deploys and sends an ERC-20 token, all with @tachibtc/tachi-sdk-ts.

How it works:

  • Gas is paid in sats from your VTXOs. A deploy or call spends your VTXOs. Your inputs must cover your outputs (usually change back to yourself) plus gasLimit × gasPrice / 1000 sats, rounded up. Whatever gas goes unused comes back as a refund VTXO.
  • gasPrice is in millisats per gas. The minimum is 1, so 1,000 gas costs 1 sat.
  • Your EVM address is derived from your Tachi key. It is the standard Ethereum address of the same secp256k1 key. msg.sender is that address.
  • Rules: the EVM runs Cancun rules (PUSH0, MCOPY, transient storage). The chain ID is 0x54434849 ("TCHI"). One transaction may use at most 1,000,000 gas.
Live on signet

Everything below runs against https://rpc-signet.tachibtc.com. The Counter contract from this guide is deployed at 0x44569176fb95c275d3f7334327f12c9c795fe850 if you want to try the read calls first.

Setup​

npm install @tachibtc/tachi-sdk-ts @noble/curves viem solc
  • @noble/curves signs (BIP-340 Schnorr). The SDK takes any signer, so you can use your own.
  • viem encodes calldata and decodes results and events. ethers works too.
  • solc compiles Solidity. You can also use bytecode from Foundry or Hardhat.

You need a key that holds VTXOs. On signet you can deposit a few thousand sats, which is enough for many deploys and calls. If your key comes from a tb1p… wallet, sign with the tweaked (BIP-86 output) private key: the address's output key is the one that owns the VTXOs.

Step 1: Compile a contract​

import solc from "solc";
import type { Hex } from "viem";

const source = `
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Counter {
uint256 public count;
event Incremented(address indexed by, uint256 count);

function increment() external {
count += 1;
emit Incremented(msg.sender, count);
}
}`;

const output = JSON.parse(solc.compile(JSON.stringify({
language: "Solidity",
sources: { "Counter.sol": { content: source } },
settings: {
evmVersion: "cancun",
outputSelection: { "*": { "*": ["abi", "evm.bytecode.object"] } },
},
})));
const { abi, evm } = output.contracts["Counter.sol"].Counter;
const initCode = `0x${evm.bytecode.object}` as Hex;
Set evmVersion: "cancun"

Solidity 0.8.30 and later target Prague by default, and Tachi doesn't run Prague yet. With Foundry, set evm_version = "cancun" in foundry.toml. With Hardhat, set evmVersion: "cancun" in the compiler settings.

Step 2: Connect and find your addresses​

import { schnorr } from "@noble/curves/secp256k1.js";
import { TachiClient, evmAddressFromPubKey } from "@tachibtc/tachi-sdk-ts";

const client = new TachiClient({ baseUrl: "https://rpc-signet.tachibtc.com" });

const privateKey = Uint8Array.from(Buffer.from(process.env.TACHI_PRIVATE_KEY!, "hex"));
const pubKey = Buffer.from(schnorr.getPublicKey(privateKey)).toString("hex"); // x-only, owns your VTXOs
const signer = (sigHash: Uint8Array) => schnorr.sign(sigHash, privateKey);
const me = evmAddressFromPubKey(pubKey); // your msg.sender

console.log("EVM address", me);

Two helpers that later steps use. The first gathers the VTXOs that pay for gas. The second waits until a transaction is in a block (getEvmReceipt rejects until then):

async function spendable() {
const { vtxos } = await client.getAddressVtxos(pubKey);
const coins = vtxos.filter((v) => !v.spent && !v.locked);
return { ids: coins.map((v) => v.id), total: coins.reduce((sum, v) => sum + BigInt(v.amount), 0n) };
}

async function waitForReceipt(hash: string) {
for (let i = 0; i < 60; i++) {
try {
return await client.getEvmReceipt(hash);
} catch {
await new Promise((r) => setTimeout(r, 1000));
}
}
throw new Error(`no receipt for ${hash} after 60s`);
}

Step 3: Deploy​

import { buildDeployTx, signEVMTx, evmTxHash, maxGasFeeSats, contractAddress } from "@tachibtc/tachi-sdk-ts";

// Size the gas: simulate the deploy (no `to`), then add headroom.
const sim = await client.evmCall({ from: me, data: initCode });
const gasLimit = BigInt(Math.ceil(sim.gas_used * 1.2));

const nonce = await client.getEvmNonce(pubKey);
const coins = await spendable();
const maxFee = maxGasFeeSats({ gasLimit, gasPrice: 1n }); // sats set aside for gas

const deploy = await signEVMTx(
buildDeployTx({
inputs: coins.ids,
outputs: [{ owner: pubKey, amount: coins.total - maxFee }], // change
pubKey,
nonce,
gasLimit,
gasPrice: 1n,
initCode,
}),
signer,
);

// sendEvmTx resolves with CheckTx's result: code 0 means accepted.
type CheckTx = { result: { code: number; log: string } };
const sent = (await client.sendEvmTx(deploy)) as CheckTx;
if (sent.result.code !== 0) throw new Error(`rejected: ${sent.result.log}`);

const deployReceipt = await waitForReceipt(await evmTxHash(deploy));
if (deployReceipt.status !== 1) throw new Error(deployReceipt.error);

const counter = deployReceipt.contract_address!;
console.log("deployed at", counter); // same as contractAddress(me, nonce)
console.log(`https://signet.tachibtcscan.com/tx/${await evmTxHash(deploy)}`);

A successful broadcast (code 0) only means the transaction entered the mempool. The receipt tells you what happened when it ran.

Open the link to see the transaction on the Tachi explorer. EVM transactions show an EVM Execution section with the gas, fee, refund and events. For regtest, use regtest.tachibtcscan.com.

Step 4: Call a function​

Calls work the same way. Use buildCallTx with the contract address and ABI-encoded calldata:

import { buildCallTx } from "@tachibtc/tachi-sdk-ts";
import { decodeEventLog, encodeFunctionData } from "viem";

const data = encodeFunctionData({ abi, functionName: "increment" });
const callSim = await client.evmCall({ from: me, to: counter, data });
const callGas = BigInt(Math.ceil(callSim.gas_used * 1.2));

const callCoins = await spendable(); // the deploy spent the old ones
const call = await signEVMTx(
buildCallTx({
inputs: callCoins.ids,
outputs: [{ owner: pubKey, amount: callCoins.total - maxGasFeeSats({ gasLimit: callGas, gasPrice: 1n }) }],
pubKey,
nonce: await client.getEvmNonce(pubKey),
gasLimit: callGas,
to: counter,
data,
}),
signer,
);
const callSent = (await client.sendEvmTx(call)) as CheckTx;
if (callSent.result.code !== 0) throw new Error(`rejected: ${callSent.result.log}`);

const callReceipt = await waitForReceipt(await evmTxHash(call));
for (const log of callReceipt.logs) {
console.log(decodeEventLog({ abi, data: log.data as Hex, topics: log.topics as [Hex, ...Hex[]] }));
// { eventName: "Incremented", args: { by: "0x…", count: 1n } }
}

Step 5: Read state​

Reads are free. evmCall runs against committed state and commits nothing, like eth_call:

import { decodeFunctionResult } from "viem";

const read = await client.evmCall({ to: counter, data: encodeFunctionData({ abi, functionName: "count" }) });
console.log("count =", decodeFunctionResult({ abi, functionName: "count", data: read.return_data as Hex })); // 1n

const { code } = await client.getEvmCode(counter); // runtime bytecode, "0x" if none

The daemon also serves an account summary and raw storage slots. The SDK doesn't wrap these yet, so call them over HTTP:

const account = await (await fetch(`https://rpc-signet.tachibtc.com/tachi_evmAccount?address=${pubKey}`)).json();
// { address: "0x…", pubkey: "…", nonce: 2, is_contract: false, code_size: 0 }

const slot0 = await (await fetch(`https://rpc-signet.tachibtc.com/tachi_evmStorage?address=${counter}&slot=0x0`)).json();
// { address: "0x…", slot: "0x00…00", value: "0x00…01" }

Step 6: An ERC-20 token​

The same steps deploy a token. Constructor arguments go on the end of the init code; viem's encodeDeployData does that. Here is a compact ERC-20 (OpenZeppelin's ERC20 works the same way):

import { encodeDeployData } from "viem";

const tokenSource = `
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Token {
string public name = "Tachi Token";
string public symbol = "TACHI";
uint8 public decimals = 18;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
event Transfer(address indexed from, address indexed to, uint256 value);

constructor(uint256 supply) {
totalSupply = supply;
balanceOf[msg.sender] = supply;
emit Transfer(address(0), msg.sender, supply);
}

function transfer(address to, uint256 value) external returns (bool) {
require(balanceOf[msg.sender] >= value, "insufficient balance");
balanceOf[msg.sender] -= value;
balanceOf[to] += value;
emit Transfer(msg.sender, to, value);
return true;
}
}`;

const tokenOut = JSON.parse(solc.compile(JSON.stringify({
language: "Solidity",
sources: { "Token.sol": { content: tokenSource } },
settings: { evmVersion: "cancun", outputSelection: { "*": { "*": ["abi", "evm.bytecode.object"] } } },
})));
const tokenAbi = tokenOut.contracts["Token.sol"].Token.abi;
const tokenInit = encodeDeployData({
abi: tokenAbi,
bytecode: `0x${tokenOut.contracts["Token.sol"].Token.evm.bytecode.object}`,
args: [1_000_000n * 10n ** 18n], // supply, minted to you
});

Steps 3 and 4 as one helper: size the gas, pay with your VTXOs, sign, send, wait.

async function sendEvm(build: { initCode: Hex } | { to: string; data: Hex }) {
const simulated = await client.evmCall(
"initCode" in build ? { from: me, data: build.initCode } : { from: me, to: build.to, data: build.data },
);
if (simulated.status !== 1) throw new Error(`simulation failed: ${simulated.error}`);
const gasLimit = BigInt(Math.ceil(simulated.gas_used * 1.2));
const { ids, total } = await spendable();
const common = {
inputs: ids,
outputs: [{ owner: pubKey, amount: total - maxGasFeeSats({ gasLimit, gasPrice: 1n }) }],
pubKey,
nonce: await client.getEvmNonce(pubKey),
gasLimit,
};
const tx = await signEVMTx(
"initCode" in build ? buildDeployTx({ ...common, initCode: build.initCode }) : buildCallTx({ ...common, ...build }),
signer,
);
const res = (await client.sendEvmTx(tx)) as CheckTx;
if (res.result.code !== 0) throw new Error(`rejected: ${res.result.log}`);
const hash = await evmTxHash(tx);
const receipt = await waitForReceipt(hash);
if (receipt.status !== 1) throw new Error(receipt.error);
return { receipt, link: `https://signet.tachibtcscan.com/tx/${hash}` };
}

Deploy, send 250 TACHI to another key, and read both balances:

const deployed = await sendEvm({ initCode: tokenInit });
const token = deployed.receipt.contract_address!;
console.log("token at", token, deployed.link);

// Any Tachi key has an EVM address. Here, a friend's x-only public key.
const friend = evmAddressFromPubKey("05ceb8b141848124b7916f56265cfba6d5b1809f63033d08a8a255ed3aa97bc1");

const sentTokens = await sendEvm({
to: token,
data: encodeFunctionData({ abi: tokenAbi, functionName: "transfer", args: [friend, 250n * 10n ** 18n] }),
});
console.log("transfer", sentTokens.link);

for (const who of [me, friend]) {
const res = await client.evmCall({
to: token,
data: encodeFunctionData({ abi: tokenAbi, functionName: "balanceOf", args: [who] }),
});
console.log(who, decodeFunctionResult({ abi: tokenAbi, functionName: "balanceOf", data: res.return_data as Hex }));
}

Token balances live in the contract's storage, not in VTXOs. Your sats only pay for gas.

What it costs​

These are real numbers from running this guide on signet at gasPrice 1:

TransactionGas usedFeeRefunded
Deploy Counter140,459141 sats28 sats
increment()45,18746 sats9 sats
Deploy the ERC-20614,571615 sats123 sats
ERC-20 transfer()52,40853 sats10 sats

The receipt reports these as gas_used, fee_sats and refund_sats. The refund VTXO's ID is refund_vtxo_id, or refundVtxoId(signedTx) before you send.

Things to know​

  • A failed transaction still costs you. A reverted or out-of-gas transaction is still applied: its inputs are spent, its gas is charged and its nonce is used. Always check the receipt's status, not just the broadcast code. A failed deploy still reports a contract_address.
  • The refund VTXO isn't in outputs. It comes at the output index right after yours, and only exists if some gas went unused.
  • Sign with getEvmNonce. It returns the nonce your next transaction must use. The raw /tachi_nonce response also has a next_nonce field, which is one higher, so don't sign with that.
  • There's no native BTC inside contracts yet. Every balance reads zero, and any call or deploy that sends value fails. Tokens like ERC-20 keep balances in contract storage, so they work.
  • Use EVM_CHAIN_ID (0x54434849) in EIP-712 domains.
  • evmCall is rate limited per client IP (about 60 a minute), since each call can run up to a million gas.

Reference​