AGENT_OKRW_TRANSFER_LIMIT_POLICY

component compliance

Per-transaction OKRW transfer cap for agent wallets — the limit is read from the agent's on-chain TransferLimit metadata, and applies both when the agent is the direct sender and (globally) when the agent is the initiating principal.

Caps a single OKRW transfer originating from an agent wallet using a limit stored as on-chain metadata on the ERC-8004 IdentityRegistry — specifically getMetadata(agentId, "TransferLimit"). The policy struct itself has no configurable fields; the cap is whatever the agent's owner has written into the metadata slot. The evaluator attributes the transfer to the agent both when the agent is the direct msg.sender and, under global-scope policies, when the agent is the initiating principal forwarded by the PCL proxy hook — so a contract that pulls funds from an agent principal still hits the same cap. If the resulting attributed value exceeds the configured TransferLimit, the transaction reverts with ExceededAgentTransferLimit(maxLimit, value).

Parameter struct

The template has no user-configurable parameters. Solidity disallows empty structs, so AgentOkrwTransferLimitPolicy carries a single reserved field that the chain ignores. Encode zero and move on:
import { AgentOkrwTransferLimitPolicy, PolicySet } from "@maroo-chain/contracts/precompiles/pcl/IPcl.sol";

AgentOkrwTransferLimitPolicy memory p = AgentOkrwTransferLimitPolicy({ reserved: 0 });

PolicySet memory ps = PolicySet({
    templateId: "AGENT_OKRW_TRANSFER_LIMIT_POLICY",
    policy: abi.encode(p),
    selector: ""
});

Where the limit comes from

The cap is not stored on the PolicySet; it is stored per-agent on the ERC-8004 IdentityRegistry preinstall at 0x8004000000000000000000000000000000000001. The value under the "TransferLimit" key must be abi.encode(uint256) — exactly 32 big-endian bytes — holding the cap in atokrw base units (18 decimals, so 10,000,000 OKRW is 10000000000000000000000000). The agent's owner writes it with the IdentityRegistry's setMetadata; in viem the value is pad(toHex(10_000_000n * 10n ** 18n), { size: 32 }). If the metadata slot is empty or not exactly 32 bytes, the call reverts with the typed error AgentTransferLimitMetadataInvalid(string reason) from the IPcl ABI (reason "empty metadata value" or "expected 32-byte uint256, got N bytes"); decode it with the IPcl ABI, like ExceededAgentTransferLimit.

Who is attributed the transfer

The evaluator first checks the call itself before execution (for a global policy, the transaction's own value; for a call through a PCL proxy, the value the proxy passes to preCall), then runs in the after-execution stage and inspects each OKRW transfer produced by the transaction. It attributes the transfer to the agent in two cases:

  • Direct sender: the agent wallet itself calls a function that moves OKRW out (typical MAWS flow).
  • Global-scope principal: a contract performs the outbound transfer, but the PCL proxy hook has forwarded the initiating principal (the wallet that entered the regulated path). When that principal is the agent, the global-scope evaluation still attributes the transfer to the agent.


Under contract-scope evaluation, only the direct sender attribution applies — contract-scoped policies do not re-attribute to the principal for this template.

Failure semantics

If the attributed value exceeds TransferLimit, the transaction reverts with the typed custom error ExceededAgentTransferLimit(uint256 maxLimit, uint256 value). Decode it client-side to display the offending amount and the configured cap:
import { BaseError, ContractFunctionRevertedError, decodeErrorResult } from "viem";
import { iPclAbi as pclAbi } from "@maroo-chain/contracts/abi/precompiles/pcl/IPcl";

try {
  await wallet.writeContract({ /* agent-initiated OKRW transfer of 10_000_000 OKRW */ });
} catch (err) {
  // viem keeps the revert data on the ContractFunctionRevertedError in the cause chain, not on err.
  const revert = err instanceof BaseError ? err.walk((e) => e instanceof ContractFunctionRevertedError) : null;
  if (revert instanceof ContractFunctionRevertedError && revert.raw) {
    const decoded = decodeErrorResult({ abi: pclAbi, data: revert.raw });
    if (decoded.errorName === "ExceededAgentTransferLimit") {
      const [maxLimit, value] = decoded.args as [bigint, bigint];
      console.error(`agent transfer limit exceeded: max=${maxLimit} value=${value}`);
    } else {
      throw err; // another policy's rejection: let it surface
    }
  } else {
    throw err; // not a revert (a network error, a wrong ABI): let it surface
  }
}

Composing with other caps

This template only reads agent metadata; it does not evaluate attestations, denylists, or periodic windows. To add those checks — an attestation requirement (EAS_POLICY), a denylist (DENYLIST_POLICY), a periodic window (PERIODIC_VOLUME_POLICY) — combine the templates in one PolicySet whose template is LOGICAL_POLICY with the And quantifier. Every child must pass, so a transfer is rejected if any child fails, whether that is the agent's metadata cap or another child such as a DENYLIST_POLICY. Each child carries an empty selector; the selector goes on the outer PolicySet (see PCL composite policies).

Do not list the templates as separate PolicySet entries with the same selector. changeContractPolicies and setGlobalPolicies reject a policies array with two entries for one selector, reverting with InvalidParameter(bytes input), where input is that selector as text: its lowercase hex without 0x (for example the bytes of "a9059cbb"), or __all__ for the empty selector. Separate entries work only when each has a distinct selector.
import {
    AgentOkrwTransferLimitPolicy,
    DenylistPolicy,
    LogicalPolicy,
    LogicalQuantifier,
    PolicySet
} from "@maroo-chain/contracts/precompiles/pcl/IPcl.sol";

// TODO: replace with the counterparties to block before production.
address[] memory blocked = new address[](1);
blocked[0] = 0x8F3ac2B1d9E74c05A6B18FE27Dc4913e5A0F7b62;

// Children of a LOGICAL_POLICY carry an empty selector.
PolicySet[] memory children = new PolicySet[](2);
children[0] = PolicySet({
    templateId: "AGENT_OKRW_TRANSFER_LIMIT_POLICY",
    policy: abi.encode(AgentOkrwTransferLimitPolicy({ reserved: 0 })),
    selector: ""
});
children[1] = PolicySet({
    templateId: "DENYLIST_POLICY",
    policy: abi.encode(DenylistPolicy({ addresses: blocked })),
    selector: ""
});

// One PolicySet for the selector: every child must pass.
PolicySet memory combined = PolicySet({
    templateId: "LOGICAL_POLICY",
    policy: abi.encode(LogicalPolicy({ quantifier: LogicalQuantifier.And, children: children })),
    selector: ""
});
ESC
Type to search