Policy client

TotpTransferPolicyClient

Solidity for the Newton PolicyClient Safe module that executes an attested USDC transfer after Newton allows it.

0xbE59C5d05FE46AA532c2e5c2c2E3958fAe01AE6f

Attack demo · Policy · Guard

policy-client/src/TotpTransferPolicyClient.sol

// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.27;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {INewtonProverTaskManager} from "newton-contracts/src/interfaces/INewtonProverTaskManager.sol";
import {NewtonMessage} from "newton-contracts/src/core/NewtonMessage.sol";
import {NewtonPolicyClient} from "newton-contracts/src/mixins/NewtonPolicyClient.sol";
import {ISafe} from "./ISafe.sol";

/// @title TotpTransferPolicyClient
/// @notice Narrow Safe module: attested USDC transfer from the honey-pot Safe.
/// @dev Intent describes `transfer(address recipient, uint256 amount, bytes32 tapNonce)`
///      with `intent.to` = the token and `intent.from` = the compromised spender EOA
///      (`msg.sender`). Execution is Safe `execTransactionFromModule` → `USDC.transfer`.
///      The compromised key is a Safe owner; OwnerExecGuard blocks owner exec.
///      Task replay is TaskManager `taskId` spend via `_validateAttestationDirect`.
///      Tap reuse is this contract's `usedTap` map, keyed by the attested tapNonce.
contract TotpTransferPolicyClient is NewtonPolicyClient {
    error InvalidIntentTarget(address expected, address actual);
    error InvalidIntentValue(uint256 value);
    error InvalidFunctionSignature();
    error InvalidCalldata();
    error InvalidSelector(bytes4 expected, bytes4 actual);
    error IntentArgumentsMismatch();
    error TapNonceAlreadyUsed(bytes32 tapNonce);
    error PolicyDenied();
    error ModuleExecFailed();

    bytes internal constant TRANSFER_SIGNATURE =
        bytes("function transfer(address recipient, uint256 amount, bytes32 tapNonce)");
    bytes4 internal constant TRANSFER_SELECTOR = bytes4(keccak256("transfer(address,uint256,bytes32)"));

    IERC20 public immutable token;
    ISafe public immutable safe;
    mapping(bytes32 => bool) public usedTap;

    event ProtectedTransfer(address indexed sender, address indexed recipient, uint256 amount, bytes32 tapNonce);

    constructor(
        address token_,
        address safe_,
        address policyTaskManager_,
        address policy_,
        address policyClientOwner_
    ) {
        require(token_ != address(0) && safe_ != address(0) && policyTaskManager_ != address(0) && policy_ != address(0));
        require(policyClientOwner_ != address(0));

        token = IERC20(token_);
        safe = ISafe(safe_);
        _initNewtonPolicyClient(policyTaskManager_, policyClientOwner_);
        _setPolicyAddress(policy_);
    }

    /// @notice Transfer USDC from the Safe after a directly verified Newton policy response.
    function transferWithAttestation(
        address recipient,
        uint256 amount,
        bytes32 tapNonce,
        INewtonProverTaskManager.Task calldata task,
        INewtonProverTaskManager.TaskResponse calldata taskResponse,
        bytes calldata signatureData
    ) external {
        NewtonMessage.Intent calldata intent = taskResponse.intent;

        require(intent.to == address(token), InvalidIntentTarget(address(token), intent.to));
        require(intent.value == 0, InvalidIntentValue(intent.value));
        require(keccak256(intent.functionSignature) == keccak256(TRANSFER_SIGNATURE), InvalidFunctionSignature());
        require(intent.data.length == 100, InvalidCalldata());

        bytes4 selector = bytes4(intent.data[:4]);
        require(selector == TRANSFER_SELECTOR, InvalidSelector(TRANSFER_SELECTOR, selector));

        (address attestedRecipient, uint256 attestedAmount, bytes32 attestedTapNonce) =
            abi.decode(intent.data[4:], (address, uint256, bytes32));
        require(
            attestedRecipient == recipient && attestedAmount == amount && attestedTapNonce == tapNonce,
            IntentArgumentsMismatch()
        );

        require(_validateAttestationDirect(task, taskResponse, signatureData), PolicyDenied());
        require(!usedTap[attestedTapNonce], TapNonceAlreadyUsed(attestedTapNonce));

        usedTap[attestedTapNonce] = true;
        bytes memory callData = abi.encodeWithSelector(IERC20.transfer.selector, recipient, amount);
        bool ok = safe.execTransactionFromModule(address(token), 0, callData, ISafe.Operation.Call);
        require(ok, ModuleExecFailed());
        emit ProtectedTransfer(msg.sender, recipient, amount, attestedTapNonce);
    }
}