ERC-TBA: uRWA Policy Interface

,

ERC-7943 (uRWA) standardised what a permissioned token exposes: canSend, canReceive, canTransfer, frozen balances and forcedTransfer. It deliberately left open how the token reaches those decisions. This proposal standardises that other half: the interface between an ERC-7943 token and an external policy contract.

Why

  • Compliance logic changes faster than the token. Identity, jurisdiction, lock-ups, holder caps and settlement hours change more often than token accounting. Different teams audit them, and one rule set often serves many instruments. So tokens move these rules into a separate contract, and every implementation invents its own token↔policy interface.
  • ERC-3643’s boundary is thin. Its compliance check is canTransfer(from, to, amount) returns (bool), plus callbacks. It never learns who initiated a movement or under what authority, and it can’t say why it rejects.
  • Integrators can’t trust the pre-flight. They can’t know whether a token’s pre-flight is computed the same way as the transfer it predicts.

Design in brief

  • Policy interface. checkSend and checkReceive back canSend and canReceive. checkTransfer(TransferContext) decides each movement. All three are views returning bytes32. wantsAfterTransfer() and afterTransfer() serve stateful policies, such as holder counts. On the token side: policy() and PolicyChanged.
  • A non-zero success value. TRANSFER_OK = keccak256("TRANSFER_OK"). Zero and every other value reject, so a forgotten or unassigned return fails closed.
  • A closed set of kinds, chosen by authority. Every movement carries one of TRANSFER, EXECUTED, FORCED, MINT or BURN. An ordered procedure picks the kind from on-chain facts, meaning who authorised the movement, not function or business-process names. This comes from a real bug in the implementation the draft was extracted from: returning escrowed shares labelled as a forced transfer skipped the holder cap and the pause.
  • The pre-flight matches the movement. A token’s canTransfer must return the decision that the transfer, transferFrom, mint or burn it predicts would receive, whoever submits it. To make that hold, TRANSFER, MINT and BURN decisions may not depend on the operator, and policy answers may not depend on msg.sender or other transaction context.
  • ERC-7943’s own limits apply. The policy answers canSend and canReceive under ERC-7943’s restrictions (account-level only), and checkTransfer must stay consistent with them.

Open questions

  1. For the ERC-7943 authors. The draft reads canTransfer as the pre-flight for ordinary transfers, mints and burns only. Executor-settled and forced movements arrive through restricted functions and are not predicted. It also reads a zero from or to as a mint or burn pre-flight. Does that match your intent?
  2. wantsAfterTransfer() versus ERC-165 detection of the callback. Is it worth one STATICCALL per movement?
  3. The mint pre-flight sets operator to the caller. Decisions can’t depend on it either way. Should it use from (zero) instead, for a context that is the same for every caller?
  4. EXECUTED (holder-signed instructions carried out by an authorised executor): should it be in the base set, or an extension?

Links

Feedback is very welcome, especially from teams running ERC-7943 or ERC-3643 tokens.

1 Like

Really careful draft. Picking the kind from who authorised the movement, with the escrow bug as the worked example, is the part I’d expect most implementations to get wrong. On Q2 I’d keep wantsAfterTransfer(): the policy is already warm from checkTransfer, so the extra STATICCALL costs a few hundred gas, which is a fair price for an answer that can follow configuration.

  1. Q3: I’d use from (zero). The transfer and burn pre-flights already set operator to from, so the mint one would match, and the pre-flight context becomes a pure function of the arguments: the same whoever runs the simulation.

  2. Q4: I’d keep EXECUTED in the base set. The set is closed and an out-of-range kind can’t permit, so an extension couldn’t add it without a new interface, and policies written for the base set would reject every settlement-channel movement. Tokens without a settlement channel never emit it, so it costs them nothing.

  3. One gap from the reference: policy state is only valid for one unbroken installation. seedHolderCount is accepted whenever the policy isn’t installed, so after A→B→A the reinstalled policy keeps the count it had when B took over, and nothing asks for a reseed. Too high and it turns away new holders below the real cap. Too low and it admits holders past the cap, and holderCount -= 1 can underflow and revert a full exit, as your unseeded-replacement test shows. A seeded policy can’t be corrected once installed, so I’d add a line to Security Considerations that reinstalling counts as a new installation (same for one token of a multi-token policy that swaps away and back), and do the seed and setPolicy in one batch so no movement lands between reading the count and installing.

1 Like

Is batching seedHolderCount and setPolicy sufficient if the count was calculated off-chain?

Suppose governance prepares the batch with a count of 100. Another transfer creates a new holder before that batch executes. Both calls then succeed atomically but the new policy starts with a stale count.

Should installation require a check that no relevant movements occurred since the snapshot? A movement nonce could provide that guard. Otherwise the migration would need an authoritative count read during execution or a pause covering snapshot collection through installation.

A test with a holder-changing transfer immediately before the installation batch would make this distinction explicit.

1 Like

Good catch, the batch only closes the gap inside one transaction. If the count was read off chain, a transfer that lands before the batch still makes it stale.

A pause doesn’t fix it in the reference, since forced transfers go through while paused and still count towards the cap. What does freeze every kind is your fail-closed rule: every movement, FORCED included, goes through checkTransfer, and setPolicy only checks ERC-165, so installing a stub policy that never returns TRANSFER_OK stops all of them. Count from that quiet state, then seed and install the real policy in one batch. Enforcement is blocked for that window too, so keep it short.

A movement nonce checked by the seed (seedHolderCount(count, expectedNonce)) is the precise version, but it puts a counter on the token, so I’d keep it in the reference rather than the ERC. And +1 on the test with a holder-changing transfer right before the batch.

2 Likes

Thanks Dmitry, you’re right. Doing it in one batch only closes the gap between the seed and setPolicy. The count goes into the batch as fixed calldata, so a holder that shows up between reading it and the batch running makes it stale, and the batch still goes through.

I added the test you suggested. Governance reads 2 holders, a transfer creates a third before the batch runs, then the batch seeds and installs a policy with a cap of 3. Seeded with the off-chain 2, the next mint gets in and the token ends up with 4 holders. If the same call reads the count from the outgoing policy instead, it installs 3 and that mint is rejected with MAX_HOLDERS.

The Security Considerations now split this into the two windows and list what closes the second one: state kept somewhere that survives a policy swap, reading or checking the state on-chain in the installing transaction (copy it from the outgoing policy, or check a sorted holder list whose balances add up to totalSupply()), a pause that also holds off FORCED, or a movement counter on the token. The counter stays out of the ERC, it’s only mentioned as an optional extension, same as Carlos said above.

Commit: Phase 0.4: the snapshot window · fepvenancio/urwa-policy@b1d6b68 · GitHub

The bigger thing this shows is that a policy never finds out it was installed. It’s also why Carlos’s A→B→A case happens, nothing tells the policy it needs a reseed. So I’d like to propose an install hook: setPolicy calls onInstall(token, previousPolicy, data) on the new policy. It doesn’t close the snapshot window by itself, but it gives the policy one place to read or check its state on-chain at the moment it goes live (copy the count from previousPolicy, or check a holder list against totalSupply()), and a policy that needs seeding can refuse to be installed without it. Stateless policies implement it as a no-op.

I’d add it with four rules:

  1. No uninstall hook that can block a replacement. If the outgoing policy could revert the swap, a broken or hostile policy could keep itself installed. ERC-7579 warns about the same thing with modules that revert in onUninstall.
  2. Fail closed. If onInstall reverts, setPolicy reverts.
  3. Only the token calls it. Like afterTransfer, it reverts unless msg.sender is the token, and anything it writes is keyed by that token, so one token can’t initialise a shared policy for another.
  4. The token’s reentrancy guard covers setPolicy, so onInstall can’t move tokens or swap the policy again halfway through.

The cost is one more external call on setPolicy, which is rare, and one more function every policy has to implement. Worth having in the ERC, or better left to implementations?

1 Like

On whether it belongs in the ERC: I’d put it there, on the token side. A policy can only refuse to go live unseeded if every compliant token has to call the hook. As an optional extension, a policy on a token that skips it still can’t tell a reinstall from an unbroken installation, so A→B→A stays open there.

Two things I’d add to your four rules:

  1. onInstall should set up state on every call, not once per token. The usual initializer guard (if (initialized[token]) return;) brings A→B→A straight back, because the second install skips the reseed and keeps the old count. I’d make it a MUST that every installation reinitialises.

  2. Copying from previousPolicy trusts the contract being replaced, which is often the broken one, or a stateless policy with no count to copy. I’d keep the copy as the convenience path between versions of the same policy, and point to the holder list checked against totalSupply() as the path that trusts nothing.

2 Likes

Thanks Carlos.

Q2: agree, keeping wantsAfterTransfer(). With the policy already warm from checkTransfer the extra call is cheap, and I’d rather the answer follow the policy’s config than be fixed at deploy time.

Q3: changed it. Every pre-flight now uses from as the operator, so for a mint it’s zero and the context only depends on the arguments. Check rule 3 and the rationale are updated to match.

Q4: agree, EXECUTED stays in the base set. The set is closed, so adding it later would need a new interface anyway.

On the reinstall, you’re right. The Security Considerations now say a reinstall is a new installation, and the same goes for one token of a shared policy that switches away and back. The seedHolderCount docs say to seed before every installation, and there are two A→B→A tests, one where the stale count lets a holder in past the cap and one where reseeding enforces it. Commit: Phase 0.3: first forum feedback · fepvenancio/urwa-policy@a99a946 · GitHub

As Dmitry pointed out, one batch isn’t enough when the count is read off-chain. That’s in b1d6b68, see my reply to him above.

The stub policy works in the reference: setPolicy only checks ERC-165 and every kind goes through checkTransfer, FORCED included, so it stops what the pause doesn’t. It has two limits though. While the stub is in nothing moves, so if the outgoing policy tracks the count, that count stays correct and the second batch can read it on-chain. The stub only helps when nothing on-chain has the count, like the first stateful policy on a token that already has holders. And behind a timelock the freeze can’t be short. The second batch can only be scheduled after the count is taken, so transfers and enforcement stay blocked for at least one delay.

For that case I’d rather check a sorted list of holders against totalSupply() at install time. If the listed balances add up to the supply, nobody outside the list holds anything, so the count is exact when the batch runs, and a new holder makes it revert instead of installing a stale count. No freeze and nothing added to the token, as long as the list fits in one transaction. I’ve planned it for the reference but held it until we see where the install hook goes, since that’s where it would run.

Agree on the nonce, it stays out of the ERC. And the test is in, it’s the pair in b1d6b68.

1 Like

Fair on the timelock, I was picturing a direct admin call. With a delay in front the freeze lasts at least one delay, so the holder list is the better path there.

Two checks I’d make MUSTs for that list: addresses strictly ascending, and a non-zero balance for each. Without the first, a list that repeats a large holder can still add up to totalSupply() while leaving smaller holders out, so the count comes out low. Without the second, padding with empty addresses inflates the count, and the cap starts rejecting real holders early.

1 Like

Agree on the first one. If duplicates are allowed, a list that repeats a big holder can add up to totalSupply() and still leave holders out, so strictly ascending has to be a MUST.

On the second, I’d put the MUST on the count rather than on the list: only entries with a non-zero balance are counted. Then empty addresses don’t inflate anything, they only cost gas. Requiring every entry to be non-zero would hurt behind a timelock. A listed holder who exits during the delay would make the whole batch revert, and you couldn’t pad the list with verified accounts that might become holders before it runs, which is what keeps it valid across the delay.

For scale, every entry is a cold balance read, so one transaction fits a few thousand holders (about 3,700 for a token behind a proxy, called through a timelock, in my measurement).

Both checks would sit in the reference policy’s seed, or in the reference check for the install hook if we add one. The ERC itself doesn’t standardise how a policy is initialised.

1 Like

Could the holder-list approach make a policy replacement repeatedly fail behind a timelock?

If the eligible recipient set can grow during the delay then a small transfer to an eligible address outside the submitted list makes the installation revert. Padding helps only if governance can enumerate all potential recipients in advance.

What is the recovery path when the outgoing policy is broken and cannot supply a trusted count? Would that require temporarily blocking new holder creation or using state maintained independently of the policy?

I’d distinguish a migration that rejects stale state from one that can reliably complete. The list check establishes the former but may not establish the latter.

1 Like

Agreed on the count, that’s better. Padding with verified accounts that may become holders is exactly what keeps the list valid across the delay, and the seed in bd1c277 already counts only non-zero balances.

On Dmitry’s question, I think it splits in two. With an allowlist policy like the reference, the only addresses that can receive during the delay are the ones on the outgoing policy’s allowlist, which you can rebuild from AllowlistSet events and which only grows by an owner call. Pad with the whole allowlist and freeze new allowlisting for one delay, and a dust transfer has nowhere to go that isn’t already listed. The limit is the few thousand entries per transaction you measured.

When the outgoing policy is broken or its eligibility can’t be enumerated, I’d accept two delays to get a migration that completes: first install a stateless stub that only admits receivers already holding a balance, so existing holders keep trading but nobody new enters, then schedule the seed and install with the current holder list. With the set closed, the listed balances can’t drift from totalSupply(), so the batch can’t be griefed.

1 Like

Thanks Dmitry, that’s a fair distinction. The list check gives you the first part: it never installs a stale count. It doesn’t make the migration complete on its own. If anyone outside the list can become a holder during the delay, a dust transfer makes the batch revert, and whoever sent it can do it again on the next try.
It completes only if the set of possible holders is closed while it’s pending. Carlos covered the two cases I’d use:

  1. When eligibility is an allowlist, like the reference, the only accounts that can receive are on the outgoing policy’s allowlist. Rebuild it from AllowlistSet events, pad the list with all of it, and add nobody until the batch runs. The limit is what fits in one transaction, about 3,990 entries through a timelock batch on the reference.
  2. When the outgoing policy is broken or eligibility can’t be listed, close the set first: install a policy that only admits receivers who already hold a balance, then seed and install from the holder list. That costs two delays, and the interim policy still has to apply the token’s eligibility checks. It also has to decide about enforcement: letting a forced transfer reach a new account reopens the set.
    The other way is to keep the state outside the policy, in a contract that stays in place across replacements, so nothing needs seeding. Copying from the outgoing policy completes in one step, but only if you trust it, which is the case you’re asking about.
    I’ll add this to the Security Considerations: a check against totalSupply() rejects stale state, and the migration only completes reliably if the holder set is closed while it’s pending.

What is the recovery path if the existing holder set already exceeds the single-transaction gas limit?

The interim policy stops new holders from entering but does not make the existing list smaller. If the outgoing count is also untrusted then neither copying it nor validating the full list works.

Would recovery require a staged migration with balances frozen during verification? Or should the reference explicitly state a holder-count limit beyond which this recovery path is unavailable?

On the holder set outgrowing one transaction: I don’t think it needs balances frozen, as long as the interim policy does the counting itself instead of checking one list at install.

Anyone can register holders in chunks, over as many transactions as it takes: each address once, with a non-zero balance at registration. afterTransfer then keeps a running sum of registered balances (minus the amount when from is registered, plus when to is registered) and drops the count when a registered holder reaches zero. Once that sum equals totalSupply(), every holder is registered and the count is exact. Transfers between holders during registration can’t throw it off, and since the set is closed nobody new appears, so the remaining work only shrinks.

The install then copies the count from the interim policy and reverts unless the sum still equals totalSupply() in that transaction. Copying from the outgoing policy is safe here because it’s the one that just did the check. In the reference, _update already calls afterTransfer for every kind, so this holds as long as the interim policy also keeps FORCED from reaching an account with no balance.

I’d keep the one-transaction list for up to the ~3,990 holders you measured and describe this staged path for anything bigger.