Near Learn

Predecessor vs signer

NEAR access control: authorize with env::predecessor_account_id(), not signer_account_id() — the NEAR equivalent of the Solidity tx.origin phishing bug.

Intermediate5 min read3-question check

Every NEAR function call knows two accounts. env::signer_account_id() is the account that signed the original transaction. env::predecessor_account_id() is the account that directly called this method — a user for a direct call, or a contract when the call came through a cross-contract promise.

They are the same for a plain user → contract call, which is why the wrong one slips through testing. They differ the moment another contract sits in the middle.

The bug#

Authorizing by the transaction origin
Solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Wallet {
    address public owner;
    constructor() { owner = msg.sender; }

    function withdrawAll(address payable to) external {
        // BUG: tx.origin is whoever signed the tx,
        // even if a malicious contract made this call
        require(tx.origin == owner, "not owner");
        to.transfer(address(this).balance);
    }
}
Rust
#[near]
impl Treasury {
    pub fn withdraw_all(&mut self, to: AccountId) -> Promise {
        // BUG: signer is whoever signed the original
        // transaction, even if evil.near made this call
        require!(env::signer_account_id() == self.owner, "not owner");
        let amount = env::account_balance()
            .saturating_sub(NearToken::from_near(1)); // keep a reserve
        Promise::new(to).transfer(amount)
    }
}
Coming from EVM

tx.origin ↔ env::signer_account_id(); msg.sender ↔ env::predecessor_account_id(). The bug and the fix are identical on both chains.

Why it happens on NEAR#

The fix#

Fixed — authorize the direct caller
Rust
use near_sdk::{env, near, require, AccountId, NearToken, PanicOnDefault, Promise};

#[near(contract_state)]
#[derive(PanicOnDefault)]
pub struct Treasury {
    owner: AccountId,
}

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

    pub fn withdraw_all(&mut self, to: AccountId) -> Promise {
        self.assert_owner();
        let amount = env::account_balance().saturating_sub(NearToken::from_near(1));
        Promise::new(to).transfer(amount)
    }
}

impl Treasury {
    fn assert_owner(&self) {
        // the account that called THIS method, not the tx signer
        require!(env::predecessor_account_id() == self.owner, "not owner");
    }
}

Check yourself

3 questions · progress saved in this browser

  1. 1.alice.near calls game.near, which makes a cross-contract call to vault.near.withdraw(). Inside withdraw, what are signer and predecessor?
  2. 2.Which Solidity value is the closest analog of env::signer_account_id()?
  3. 3.An admin method checks env::signer_account_id() == self.owner. The owner calls an unknown dApp contract that internally calls this admin method. What happens?