Guide
NEAR Chain Signatures in practice: an NFT that signs on three chains
NEAR Chain Signatures let a NEAR account sign transactions for other blockchains, such as Bitcoin, Ethereum and Solana. Satchel is a worked example. In Satchel, each NFT owns its own derivation path, so one NEAR contract signs on three chains.
What NEAR Chain Signatures are
NEAR Chain Signatures are a service of NEAR's signer network. A group of nodes jointly holds a root key, using multi-party computation. No single node has the whole key. The nodes answer requests from NEAR accounts. The NEAR docs cover the full design.
A contract calls the network's sign method with three things: a derivation path, a payload and a domain id. The network adds a tweak to the root key. The tweak comes from the caller's account name and the path. The result is a key pair that belongs to that account and path, and only that account can ask for its signatures.
That key pair has a public key, so it has addresses on other chains. A NEAR contract can own a Bitcoin address, an Ethereum address and a Solana address, and no one holds a private key for them. You do trust the signer network, because it makes every signature.
One contract, one path per token and chain
Satchel always calls the signer network as the contract itself. The path says which token and which chain the signature is for.
| Chain | Path for token N | Domain | Curve | What the contract hashes |
|---|---|---|---|---|
| Bitcoin | satchel/N/btc | 0 | secp256k1 | Each input's BIP143 preimage, twice with SHA-256 |
| Ethereum | satchel/N/evm | 0 | secp256k1 | The type-2 transaction, with keccak256 |
| Solana | satchel/N/sol | 1 | ed25519 | Nothing: the signer signs the message bytes |
Token 7's Solana key lives at satchel/7/sol. Because the contract is the caller, no one else can ask for a signature on that path. The contract asks only for the token's current owner, or for an account the owner has let spend within daily caps.
Addresses don't need a network call. Satchel's TypeScript package reproduces the tweak, so a page can show a Satchel's addresses before anything is signed. A buyer's check also compares them with the signer network's own derived_public_key view.
This is the request the contract sends. Hex is shortened for the Bitcoin and Solana lines.
{"request":{"path":"satchel/1/evm","payload_v2":{"Ecdsa":"bc657845d9165bf3555d8b8a9a5b830272d83b762c60ceab048355a87deac377"},"domain_id":0}}
{"request":{"path":"satchel/1/btc","payload_v2":{"Ecdsa":"<sha256d of one input's preimage>"},"domain_id":0}}
{"request":{"path":"satchel/1/sol","payload_v2":{"Eddsa":"<hex of the whole message>"},"domain_id":1}}In the contract the path is one line, and the request is a small struct.
pub fn derivation_path(token_id: &str, chain: Chain) -> String {
["satchel/", token_id, "/", chain.slug()].concat() // "satchel/7/sol"
}
pub struct MpcSignRequest {
pub path: String,
pub payload_v2: MpcPayload, // Ecdsa(hex hash) or Eddsa(hex message)
pub domain_id: u64, // 0 for secp256k1, 1 for ed25519
}A structured payload, never a raw hash
The simple way to build a signer contract is to accept a 32-byte hash and pass it on. Satchel doesn't do that. The caller sends the unsigned transaction itself, and the contract parses the exact bytes and hashes them.
pub enum SignRequest {
Evm { unsigned_tx: String }, // 0x02 type-2 transaction
Btc { preimages: Vec<String>, outputs: String }, // BIP143 preimages + outputs
Sol { message: String }, // legacy message bytes
// plus plain-text message variants
}
// sign_for args: {"token_id":"1","request":{"evm":{"unsigned_tx":"02ee83aa36a7…80c0"}}}The contract hashes with the host functions: keccak256 for Ethereum, SHA-256 twice per Bitcoin input, and the raw message for Solana. It accepts only plain ETH and USDC transfers, standard Bitcoin spends, plain SOL transfers and plain-text messages. It refuses raw hashes, EIP-712 permits, EIP-7702 authorizations, any other contract call and Solana durable nonces.
Why does it matter? A contract that signs raw hashes is a blind signer. It can't say what a signature does, and neither can anyone reading its log. Because Satchel parses the bytes first, the record says exactly what was signed. That record is what makes clean history possible.
The record is written before the signer network is called
A request that times out can still produce a valid signature later. If the contract wrote its record afterwards, that signature would exist with no trace. So sign_for writes one record per signature first, in the same receipt that makes the call.
pub fn sign_for(&mut self, token_id: TokenId, request: SignRequest, mpc_deposit: Option<U128>) {
// 1. only the owner (or an allowed spender) may call; refuse while listed
// 2. parse the exact bytes, refuse anything unrecognised, hash them here
let c = parse::classify(&request).unwrap_or_else(|code| fail(code));
// 3. write one record per signature BEFORE the signer network is called
for (i, it) in c.items.iter().enumerate() {
self.sig_log.insert(
(token_id.clone(), first_seq + i as u64),
SigRecord { payload: it.record_payload, detail: it.detail.clone(),
status: SigStatus::Pending, signature: [0u8; 65], /* … */ },
);
}
st.sig_count += n64;
// 4. only now ask the signer network: one call per payload, joined
let joint = env::promise_and(&calls);
let cb = promise::then_call(joint, &env::current_account_id(), "on_signed", /* … */);
env::promise_return(cb);
}A signature request is also an event on NEAR. Timed-out requests stay on the record. A buyer can read every record with the sig_records view, and the chain a record names shows whether the signature can still move coins.
Fan-out and the callback
Several signatures in one call
A Bitcoin spend with two inputs needs two signatures. Putting two signer calls as separate actions in one transaction makes both time out. So the contract makes one single-action call per payload and joins them with promise_and. One callback, on_signed, receives all the results.
The callback only fills in the result
on_signed is for the user experience, not for safety. It reads the raw bytes of each result, with a size cap, and never panics. It marks each record Signed, Unparsed or Failed. A Failed record still counts as issued, because a signature may still exist.
#[private]
pub fn on_signed(&mut self, token_id: TokenId, first_seq: u64, n: u32) -> Vec<Option<Value>> {
for i in 0..count {
// raw bytes, size-capped: a parse failure can never revert anything
let (status, sig) = match env::promise_result_checked(i, MAX_MPC_RESPONSE) {
Ok(bytes) => /* parse r, s and recovery_id: Signed or Unparsed */,
Err(_) => (SigStatus::Failed, None), // rejected or timed out
};
if let Some(rec) = self.sig_log.get_mut(&key)
&& rec.status == SigStatus::Pending
{
rec.status = status;
rec.signature = sig.unwrap_or([0u8; 65]);
}
}
}Two details trip people up. The Ethereum parity bit is the signer's recovery_id, never the prefix byte of big_r. And the signer's s value is already in low-S form. A secp256k1 record stores r, s and the recovery id in 65 bytes.
Signing usually takes about 10 seconds. The network allows up to about 2 minutes before a request times out, and under load timeouts happen. A retry of an Ethereum send reuses the same nonce.
Native Bitcoin or wrapped Bitcoin
Wrapped bitcoin is a token on another chain that stands for bitcoin held somewhere else. It depends on whoever holds the original coins. The Chainflip overview of wrapped and native bitcoin explains the difference and the risks.
A Satchel's Bitcoin address is on Bitcoin itself. The contract builds a standard P2WPKH spend, the signer network signs each input, and the transaction goes out on the Bitcoin network like any other. There is no bridge and no wrapped token for the chain addresses. The coins are native on their own chains.
The trade is trust. A wrapped token trusts a custodian or a bridge. A Satchel trusts NEAR's signer network, and enough colluding nodes could sign anything.
What to keep in mind
This is a working example, not a security claim.
- NEAR's signer network makes every signature. Satchel is new and hasn't been audited.
- Each sign request needs a small attached deposit and a minimum amount of gas. The contract checks both before it writes anything.
- If the signer network changed its interface, a locked contract could no longer sign. The contract forwards leftover gas and lets the caller raise the deposit to soften this.
See it on a Satchel
Open Satchel #1 to see its three addresses, derived from the paths above. Its Activity tab lists each signature record. The contract page shows the account, its access keys and the code it runs.
For what buyers do with the record, read the guide to clean history. For a gift that uses the same wallet, read how to gift crypto without a wallet. For a different way to give an NFT an account, read ERC-6551 compared with Satchel.
Questions and answers
What are NEAR Chain Signatures?
NEAR Chain Signatures let a NEAR account get signatures for transactions on other chains, such as Bitcoin and Ethereum. NEAR's signer network makes each signature when a contract asks.
Does a Satchel hold a private key?
No one holds a seed phrase for a Satchel's addresses. The key exists as shares inside NEAR's signer network, which signs only when the Satchel contract asks.
Is a Satchel's bitcoin native or wrapped?
Native. A Satchel's Bitcoin address is on the Bitcoin network. There is no bridge and no wrapped token for its chain addresses.
Why does the contract not sign raw hashes?
A raw hash hides what it signs. The contract takes the unsigned transaction, parses it, refuses what it doesn't recognise and hashes the bytes itself. The record then says what was signed.
What happens if a signature request times out?
The record is already on NEAR, marked Failed. A timed-out request can still produce a valid signature, so the record counts as issued and clean history treats it that way.
Is a Satchel a multi-party computation wallet?
Partly. NEAR's signer network uses multi-party computation to make signatures. But the owner is a NEAR account, and the NFT decides who may ask for a signature.
Try it yourself
Open a Satchel and read its record, or browse the Satchels for sale.
Related guides
- Clean history: check a Satchel's signatures before you buyClean history is the buyer's browser check that no signature a Satchel made before a sale can still move its coins. What it checks, and what it doesn't.
- Can you sell a crypto wallet?Handing over a seed phrase doesn't sell a wallet, because the seller can keep a copy. An NFT that is a wallet can change hands without that risk.
- Crypto wallet with a balance for sale: what to check firstWallets with a balance for sale are a known scam pattern. What a buyer can check first, and how a Satchel's record makes that check possible.
- Can I give my crypto wallet to someone else?The usual answer is a new wallet and a transfer. With a Satchel you give the NFT to another NEAR account and its Bitcoin, Ethereum and Solana coins go too.
- How to gift crypto to someone without a walletHow to gift crypto to someone without a wallet: the usual options compared, then a Satchel by link. How the link works and what to know before you send.
- ERC-6551 and token bound accounts, compared with SatchelERC-6551 gives an NFT an account on one EVM chain. How that compares with a Satchel's native Bitcoin, Ethereum and Solana addresses and its clean history.
- QuestionsEvery answer about keys, chains, selling, giving and fees, grouped in one place.
- Contract factsThe contract account, its access keys, the code it runs and commands to check them.