NFT-holder policy executors

Make a data policy’s spend authority transferable: name an ERC-721 (collection, tokenId) as the executor and whoever holds the NFT may move funds within the policy’s bounds. Transfer the NFT = transfer the right to act — the property-management “agency credential” pattern, with mandatory amount bounds and live ownerOf authorisation.

Guides/Structure

A data policy lets a group account G authorise a bounded movement of funds — a confidential send (or deposit/withdraw) out of G — without ever handing over G's keys. An NFT-holder executor makes that authority transferable: the policy names an ERC-721 (collection, tokenId), and whoever currently holds that NFT may execute the policy. Transfer the NFT and you transfer the right to act — atomically, with no policy edit and no re-issuance.

The motivating case is property management. A property is owned by a group account that collects rent and holds a float. The owner wants a managing agent to pay bills out of that float — but only within a mandate (a per-payment cap, a balance floor) — and wants to be able to change agents without touching the account. Mint an "agency" credential NFT, attach a send policy whose executor is that NFT, and hand the NFT to the agent. Firing the agent is a transfer to the next one: the mandate moves with the token, and the previous holder is revoked the instant they no longer hold it.

Executors vs approvers

A policy has two distinct roles. Keep them separate:

Role
Approvers
Fields
required_signers + caller_ids
What they do
The M-of-N who sign off on the policy — a fixed, reusable signature set
Can be an NFT holder?
No — a reusable signature can't track live ownership
Role
Executors
Fields
executor_accounts + executor_ids
What they do
Who may submit executeUnderPolicy and move the funds
Can be an NFT holder?
Yes — execution is authorised per-call, so it can read live ownerOf

executor_ids[i] selects the executor kind, per slot:

  • "0" (or omitted) → address executor: executor_accounts[i] is a wallet/account address, matched by equality.
  • non-zero → NFT-holder executor: executor_accounts[i] is the ERC-721 collection address, and ownerOf(executor_ids[i]) — resolved live at every call — is the executor.

Omit executor_accounts entirely and execution defaults to the approvers (the common "the approvers also execute" case).

Amount bounds are mandatory for a bearer executor

An NFT-holder executor is a bearer credential over money movement — whoever holds the token can spend within the policy's scope. So if a policy allows a fund-moving op (send/deposit/withdraw) and names any NFT executor, it must declare at least one amount bound. Registration fails otherwise — with a clear pre-submit error, and as a hard on-chain revert. Address-only executors (a fixed, known party) keep the historic opt-in behaviour; only the transferable-bearer case is forced.

Blast radius. A bound caps each individual send, not the lifetime total. One M-of-N approval authorises up to max_use separate bounded sends, so the true exposure of a bearer credential is max_use × hi. Size hi and max_use to the mandate.

Lifecycle

  1. Mint the executor NFT. An obligation NFT in a transferable class works well (createObligation or createComposedContract). Hold it wherever the right should start — e.g. the property group mints to itself.
  2. Register the policy on G with addDataPolicy: set executorAccounts = [<collection address>], executorIds = [<token id>], at least one amountBound, the allowedOperations, the requiredSigners (your approvers), and expiry / maxUse.
  3. Collect approvals with approveDataPolicy until minSignatories is met. (If the registrant is the sole required signer, approval is auto-collected at registration.)
  4. Execute. The current NFT holder calls executeUnderPolicy acting as the group — the platform mints them a short-lived, narrowly-scoped group-delegation JWT. On-chain, the policy reads ownerOf live, in the same transaction as the send, and authorises only the current holder.
  5. Transfer = re-delegate. Move the NFT to a new holder and the execute right moves with it. The former holder is denied on their next call (live ownerOf no longer matches) — no policy edit, no re-issuance.
# 1) Register a send policy whose executor is an "agency" NFT.
mutation Register($input: AddDataPolicyInput!) {
  pipelineGate { addDataPolicy(input: $input) { success policyId messageId } }
}
# $input:
# {
#   account:        "<group account>",
#   walletId:       "<group wallet id>",
#   policyId:       "1",
#   expiry:         "<unix seconds or ISO>",
#   maxUse:         "12",
#   minSignatories: 1,
#   requiredSigners:   ["<approver wallet>"],   # the M-of-N approvers (NOT the executor)
#   allowedOperations: ["send"],
#   amountBounds:      [{ token: "<asset>", lo: "0", hi: "500000000000000000000" }],
#   requirements:      [{ source: 0, denomination: "<asset>", lo: "0", hi: "..." }],
#   executorAccounts:  ["<agency collection address>"], # the ERC-721 collection
#   executorIds:       ["<agency token id>"]            # non-zero ⇒ NFT-holder executor
# }

# 2) The current NFT holder spends — submitted with a group-delegation JWT (acting as G).
mutation Execute($input: ExecuteUnderPolicyInput!) {
  pipelineGate { executeUnderPolicy(input: $input) { success messageId } }
}
# $input: {
#   account: "<group account>", policyId: "1", operationType: "Send",
#   operationData: "{\"denomination\":\"<asset>\",\"destination\":\"<payee>\",\"amount\":\"100\"}"
# }

Authoring it in a deal (DMS)

The deal composer wires this end-to-end. A rental/agency deal plan mints the agency NFT, transfers it to the agent, and registers the send policy — threading the minted NFT's identifiers into the policy via $step references, so nothing is copied by hand:

  • create_composed_contract (the mint) exposes token_id and obligation_address (the class) as step outputs.
  • add_data_policy binds executor_accounts: ["$step.<mint>.obligation_address"] and executor_ids: ["$step.<mint>.token_id"].

The policy's executor is then exactly the NFT the deal just minted. See DMS for the deal-authoring model and Collateralisation for the obligation/contract primitives the mint builds on.

Security model (what a deployer must accept)

  • On-chain is authoritative. executeUnderPolicy re-reads ownerOf live in the spend transaction; the off-chain delegation gate is only a UX accelerator and fails closed on every error path (a DB/RPC/JWT failure denies). A former holder reusing a still-valid delegation JWT can submit but the on-chain gate rejects the send — it wastes gas and leaks nothing.
  • Bearer semantics. A stolen or lent NFT carries the spend right within scope. Custody of the NFT is the control surface — secure it like a signing key.
  • The owner picks the collection. Only G's owner can register a policy or choose the executor collection. A hostile ERC-721 can grief (deny) but cannot move funds — the policy re-checks the same collection on-chain.

The full threat model, the three authorization gates, and the test→guarantee mapping are in the security review nft-executor-security.md (smart-contracts docs). End-to-end behaviour is exercised by rental_nft_executor_transfer_suite.yaml (direct) and rental_nft_executor_dms_suite.yaml (through the deal flow): mint → register → holder executes → transfer → new holder executes → former holder denied.