Near Learn

Calling the signer from your own contract

Request Chain Signatures from a NEAR smart contract in Rust: the sign cross-contract call, 1 yoctoNEAR and gas, the callback, and who may sign for which path.

Advanced16 min read3-question check

When a contract calls sign, the predecessor is the contract, so the keys are derived from the contract’s account ID. The contract owns Bitcoin, EVM and Solana addresses, and its code decides what gets signed. This is the piece Solidity cannot do: a smart contract that holds native assets on other chains.

It is an ordinary cross-contract call with two requirements from the signer (1 yoctoNEAR, at least 15 Tgas) and a long wait while the signer yields. Interface source: crates/contract/src/api/sign.rs in near/mpc.

The signer interface in Rust#

Wire types for `sign`, mirrored with near-sdk 5.x serializers
Rust
use near_sdk::{env, ext_contract, near, require, AccountId, Gas, NearToken, PanicOnDefault, Promise, PromiseError};

/// v1.signer requires >= 15 Tgas on the sign receipt (`config` view, Oct 2026).
const SIGN_GAS: Gas = Gas::from_tgas(15);
const CALLBACK_GAS: Gas = Gas::from_tgas(10);
const ONE_YOCTO: NearToken = NearToken::from_yoctonear(1);

#[near(serializers = [json])]
pub enum Payload {
    Ecdsa(String), // 32-byte hash, hex
    Eddsa(String), // message bytes, hex
}

#[near(serializers = [json])]
pub struct SignRequest {
    pub path: String,
    pub payload_v2: Payload,
    pub domain_id: u64, // 0 = secp256k1, 1 = ed25519
}

#[near(serializers = [json])]
pub struct AffinePoint {
    pub affine_point: String, // 33-byte compressed point, hex
}

#[near(serializers = [json])]
pub struct Scalar {
    pub scalar: String, // 32 bytes, hex
}

#[near(serializers = [json])]
#[serde(tag = "scheme")]
pub enum SignatureResponse {
    Secp256k1 { big_r: AffinePoint, s: Scalar, recovery_id: u8 },
    Ed25519 { signature: Vec<u8> },
}

#[ext_contract(ext_mpc)]
pub trait MpcSigner {
    fn sign(&mut self, request: SignRequest);
}

Pattern 1: per-caller keys#

The simplest useful contract gives every caller their own namespace of keys under the contract. Alice gets addresses derived from signer.near + alice.near/<key>, Bob from signer.near + bob.near/<key>. Each can sign anything, but only for their own addresses. This is multichain account abstraction: you can add rules (spending limits, 2FA, recovery) in the contract that no wallet on the target chain could enforce.

A contract that signs only inside the caller’s namespace
Rust
#[near(contract_state)]
#[derive(PanicOnDefault)]
pub struct Signer {
    mpc: AccountId, // v1.signer or v1.signer-prod.testnet
}

#[near]
impl Signer {
    #[init]
    pub fn new(mpc: AccountId) -> Self {
        Self { mpc }
    }

    /// Signs a 32-byte hash with a key only the caller controls.
    #[payable]
    pub fn sign_for_caller(&mut self, key: String, hash_hex: String) -> Promise {
        require!(env::attached_deposit() == ONE_YOCTO, "attach exactly 1 yoctoNEAR");
        require!(
            hash_hex.len() == 64 && hash_hex.bytes().all(|b| b.is_ascii_hexdigit()),
            "hash_hex must be 32 bytes of hex"
        );
        require!(key.len() <= 64, "key too long");

        // Account IDs cannot contain '/', so "<caller>/<key>" can never
        // collide with another caller's namespace.
        let path = format!("{}/{}", env::predecessor_account_id(), key);

        ext_mpc::ext(self.mpc.clone())
            .with_attached_deposit(ONE_YOCTO) // forwarded, so the contract pays nothing
            .with_static_gas(SIGN_GAS)
            .sign(SignRequest { path, payload_v2: Payload::Ecdsa(hash_hex), domain_id: 0 })
            .then(
                Self::ext(env::current_account_id())
                    .with_static_gas(CALLBACK_GAS)
                    .on_signature(),
            )
    }

    #[private]
    pub fn on_signature(
        &self,
        #[callback_result] result: Result<SignatureResponse, PromiseError>,
    ) -> SignatureResponse {
        match result {
            Ok(signature) => signature,
            // "Request has timed out." after 200 blocks, or a rejected request
            Err(_) => env::panic_str("signature request failed"),
        }
    }
}

Callers attach 1 yoctoNEAR and around 50–100 Tgas (the contract method, 15 Tgas for sign, 10 for the callback, plus headroom; unused gas is refunded). The transaction’s final result is the signature. Users find their address off-chain with deriveAddressAndPublicKey('signer.near', 'alice.near/eth'): the contract is the predecessor.

Pattern 2: a treasury that signs only what it built#

For funds the contract itself owns, never accept a hash from the caller: a hash is opaque, and a compromised front end could get any transaction signed. Build the foreign transaction inside the contract from typed parameters, check them, then hash it. omni-transaction (crate omni-transaction, 0.5 in October 2026) builds EVM, Bitcoin, Solana and other transactions in Rust and compiles to Wasm.

Cargo.toml
TOML
[dependencies]
near-sdk = "5.29"
omni-transaction = "0.5" # per-chain feature flags (evm, bitcoin, solana…) keep Wasm small
hex = "0.4"
Owner-only ETH payout, same signer types as above
Rust
use near_sdk::json_types::U128;
use omni_transaction::evm::utils::parse_eth_address;
use omni_transaction::{TransactionBuilder, TxBuilder, EVM};

#[near]
impl Treasury {
    #[payable]
    pub fn pay_eth(
        &mut self,
        to: String, // "0x…"
        value_wei: U128,
        nonce: u64,
        max_fee_per_gas: U128,
        max_priority_fee_per_gas: U128,
    ) -> Promise {
        require!(env::predecessor_account_id() == self.owner, "owner only");
        require!(env::attached_deposit() == ONE_YOCTO, "attach exactly 1 yoctoNEAR");
        require!(value_wei.0 <= self.max_payout_wei, "over the per-payout limit");

        let tx = TransactionBuilder::new::<EVM>()
            .nonce(nonce)
            .to(parse_eth_address(to.trim_start_matches("0x")))
            .value(value_wei.0)
            .input(vec![])
            .gas_limit(21_000)
            .max_fee_per_gas(max_fee_per_gas.0)
            .max_priority_fee_per_gas(max_priority_fee_per_gas.0)
            .chain_id(self.chain_id)
            .build();

        // EIP-1559 signing hash = keccak256 of the unsigned typed transaction
        let hash = env::keccak256_array(&tx.build_for_signing());

        ext_mpc::ext(self.mpc.clone())
            .with_attached_deposit(ONE_YOCTO)
            .with_static_gas(SIGN_GAS)
            .sign(SignRequest {
                path: format!("treasury-evm-{}", self.chain_id), // one path per chain
                payload_v2: Payload::Ecdsa(hex::encode(hash)),
                domain_id: 0,
            })
            .then(
                Self::ext(env::current_account_id())
                    .with_static_gas(CALLBACK_GAS)
                    .on_signature(),
            )
    }
}

A relayer (any account) then rebuilds the same transaction off-chain, attaches r, s, yParity from the result and broadcasts it. The contract never sees the EVM chain, so it cannot know the current nonce: either pass it in as here, or track it in state and accept that a signed-but-never-broadcast payout blocks later ones until it is sent.

Security: who may sign what#

  • Never let callers choose the full path. If path is a parameter, anyone can pass treasury-evm-1 and sign with your treasury key. Prefix paths with env::predecessor_account_id() or hard-code them.
  • Never sign caller-supplied hashes with keys the contract owns. Build the payload in the contract, as in Pattern 2.
  • Gate privileged paths with the predecessor (not the signer) and 1 yoctoNEAR so access keys cannot trigger them silently.
  • Handle `Err` in the callback. Timeouts happen. Restore any state you changed before the call; do not mark a payout complete until you have a signature.
  • Upgrades are custody. Whoever can deploy new code to the contract can sign for all of its addresses. Lock or govern the contract’s full-access keys accordingly.

Check yourself

3 questions · progress saved in this browser

  1. 1.Your contract exposes sign_any(path: String, hash_hex: String) that forwards both to v1.signer. What is the risk?
  2. 2.What must the cross-contract sign call carry?
  3. 3.The signature does not arrive within 200 blocks. What does your callback receive?