Skip to main content

Overview

The RecallablePayment contract distributes ERC-20 payments to recipients using explicit, on-chain per-recipient allocations. Unlike the dividend functionality in InterestPayment, amounts are arbitrary (not pro-rata to token holdings), and recipients claim directly from the contract without supplying any proof data.

A distribution moves through a two-phase lifecyclecreateDistributionfundDividend — before recipients can claim. Allocations must be recorded first; funding is then deposited in one or more calls. Chunked createDistribution calls can build a large recipient set across many transactions before claiming ever opens; fundDividend is the only path that auto-starts a distribution once it is fully funded.

It intentionally mirrors the dividend ABI of InterestPayment (claimDividend, batchClaimDividend, unclaimedBalanceAt, claimedBalanceAt, tokensAt, fundsAt) for familiar integration.

Version compatibility

RecallablePayment is a standalone contract — it is not tied to a security token version. It needs only snapshots (balanceOfAt / totalSupplyAt) and grantRole / hasRole / revokeRole, so it operates against EVM Security Token v2 and up, and the Upside platform can deploy it for v4 and up (v2 and v3 tokens support the contract, but it has to be deployed elsewhere). Role checks route to its own AccessControl instance, which in most production setups is deliberately separate from the security token's, so admin authority over the payment token (USDC and similar) stays isolated — see Deployment.

Key Features

  • Arbitrary amounts, not pro-rata: each recipient is assigned an explicit amount.
  • Two-phase lifecycle: allocations first (createDistribution), then funding with auto-start (fundDividend) keyed by (token, timestamp). Funding before any allocation reverts.
  • Chunked allocation: createDistribution can be called repeatedly for the same (token, timestamp) to add recipients in batches, so the total recipient count is not bounded by a single block's gas limit.
  • Correctable until claiming actually opens: allocations are overwrite-on-change. Re-submitting a recipient replaces its amount (not additive), an amount of 0 removes the recipient, and re-submitting an unchanged amount reverts. Admins fix mistakes by re-uploading only the changed rows (or a small per-wallet batch) while claiming is not yet live — that is, while the distribution is not started, before its unlock time, or still under-funded (totalFunds < totalAllocated). Allocations freeze only once claiming actually opens, i.e. started and block.timestamp >= unlockedAtTs and fully funded (the same condition as isClaimable). This keeps the correction window open when a post-start allocation raise pushes the distribution under-funded past its unlock time — an admin can lower the allocation back to coverage instead of being forced to deposit the shortfall.
  • Proof-free claims: allocations are stored on-chain keyed by (timestamp, token, recipient). Recipients call claimDividend(token, timestamp, amount) with no extra calldata.
  • Scheduled unlock: the 4-arg fundDividend(token, amount, timestamp, unlockedAtTs) pins the claim-open time on the first deposit that supplies it, and that value is honored at auto-start even if a later deposit completes the funding. The 3-arg overload carries no unlock — it funds freely and either honors a pinned time or, if none was pinned, opens claiming at block.timestamp (immediately claimable). A later pre-start 4-arg deposit with a different unlock reverts (RecallablePayment_UnlockTimeConflict). The time is adjustable after start via setUnlockTime; passing 0 means "open immediately" and is stored as block.timestamp.
  • Reclaimable: an admin can reclaim allocations recipients have not claimed, and recover any over-funded surplus.
  • Familiar interface: same claim/view signatures as the current payment contract for easy integration.
  • ERC2771 meta-transactions: requires a non-zero trusted forwarder at deploy for gasless claims/funding (the forwarder is immutable thereafter).
  • TA lifecycle / record keeping: emits AllocationUpdated, DividendFunded, DistributionStarted, DistributionUnlockTimeUpdated, DividendClaimed, and DividendReclaimed events consumed by the off-chain Transfer Agent indexer.

Why not a Merkle tree?

A Merkle-root approach would require claimers to provide the Merkle proof inputs (the leaf data and path) when claiming. By storing allocations directly in on-chain mappings, claimers do not need to supply any additional data — they call the same claimDividend(token, timestamp, amount) interface used elsewhere. The trade-off is one storage write per recipient at allocation time, which is the standard and accepted cost for proof-free claims on EVM.

The timestamp argument is reused purely as an arbitrary distribution identifier (it is not a historical snapshot id), so the existing claim signatures are preserved exactly.

Lifecycle: create → fund

A distribution is built and activated by the transfer admin in two explicit phases. Each phase is keyed by (token, timestamp).

PhaseFunctionEffectClaimingAllocations
1createDistribution(token, timestamp, recipients, amounts)Sets per-recipient allocations (overwrite-on-change; amount 0 removes). totalAllocated tracks the net. No funds pulled. Never auto-starts.lockedopen
2fundDividend(token, amount, timestamp) or fundDividend(..., unlockedAtTs)Pulls amount of token into the contract (totalFunds += amount). Requires totalAllocated > 0. Auto-starts when totalFunds >= totalAllocated.OPEN at unlock timeeditable until claiming opens
  • Phase 1 is repeatable for the same (token, timestamp) — call it several times to add recipients in chunks, each call its own transaction under the block gas limit. Allocations are overwrite-on-change: re-submitting a recipient replaces its amount (totalAllocated is adjusted by the signed delta), an amount of 0 removes the recipient, and re-submitting an unchanged amount reverts with RecallablePayment_AllocationUnchanged. Each changed recipient emits one AllocationUpdated event (amount 0 = removed). The zero address and the distribution contract itself are rejected as recipients (RecallablePayment_InvalidRecipientAddress) — a self-allocation would be unclaimable dead weight. It reverts with RecallablePayment_DistributionClaimingOpen only once claiming has actually opened (started && block.timestamp >= unlockedAtTs && totalFunds >= totalAllocated — the same condition as isClaimable). After auto-start allocations remain editable before the unlock time, and also after it while the distribution is still under-funded — so an over-raised allocation can be lowered back to coverage past the unlock time.
  • Phase 2 requires at least one allocation from Phase 1 (RecallablePayment_NoAllocationsToFund otherwise). It may run incrementally until fully funded, and as a top-up after start. The claim-unlock time is pinned across pre-start deposits: the first 4-arg deposit pins unlockedAtTs for the distribution (accepted as-is, no validation), and every later pre-start 4-arg deposit must present the same value or revert with RecallablePayment_UnlockTimeConflict. The 3-arg overload carries no unlock — it funds freely and honors whatever was pinned, so a schedule set on a partial deposit is never overwritten by a later top-up. When the deposit completes funding (totalFunds >= totalAllocated with totalAllocated > 0), the distribution auto-starts and commits the pinned unlock; if no 4-arg deposit pinned a value, it commits block.timestamp (immediately claimable). After start, a 4-arg top-up is ignored unless its unlockedAtTs matches the current value — a mismatch reverts with RecallablePayment_UnlockTimeNotApplicableAfterStart (use setUnlockTime to reschedule). If an allocation decrease later brings totalAllocated down to the already-funded amount, the distribution stays unstarted until a follow-up fundDividend call (any amount > 0) triggers auto-start. Funds deposited beyond totalAllocated form a surplus recoverable via reclaimTotalDividend. A fee-on-transfer guard reverts (RecallablePayment_InvalidFeeApplied) if the received amount differs from amount. An admin can move the unlock time earlier (open sooner) or later (postpone) after start via setUnlockTime(token, timestamp, unlockedAtTs), but only while claiming has not yet opened; once block.timestamp >= unlockedAtTs the unlock time is frozen and setUnlockTime reverts with RecallablePayment_DistributionClaimingOpen. Passing 0 means "open immediately": it is stored and emitted as block.timestamp (same normalization as auto-start), so a started distribution never reads back an ambiguous 0 from unlockTimeAt. Reverts with RecallablePayment_DistributionNotStarted before start. setUnlockTime is not blocked by global pause, so admins can reschedule while the contract is paused as long as claiming has not yet opened; recipients still cannot claim until global pause is lifted.

For the full state machine and recipient-ceiling guidance, see the lifecycle deep dive (docs/recallable-payment/lifecycle.md in the comakery-security-token repo).

Claim flow

Claiming requires both gates to pass: the contract must not be globally paused and the distribution must be started with block.timestamp >= unlockedAtTs. Recipients claim proof-free once both are satisfied:

  • claimDividend(token, timestamp, amount) — transfers amount of token to the caller. amount = 0 claims the full remaining unclaimed balance. The call is nonReentrant and respects global pause.
  • batchClaimDividend(token, timestamps, amounts) — loops claimDividend across multiple distributions of the same token (timestamps and amounts must be equal length).

Use isClaimable(token, timestamp) to check whether claiming is open right now (started and totalAllocated > 0 and block.timestamp >= unlockedAtTs and totalFunds >= totalAllocated); unlockTimeAt(token, timestamp) returns the configured unlock time (0 only before start / unlock not yet pinned; a started distribution always reports the committed claim-open time).

Reverts:

  • RecallablePayment_DistributionNotStarted — the distribution has not been started yet.
  • RecallablePayment_DistributionNotUnlocked — started, but the unlock time (unlockedAtTs) has not been reached.
  • RecallablePayment_NoRemainingUnclaimedBalance — the caller has nothing left to claim.
  • RecallablePayment_NotEnoughFundsToClaim — requested amount exceeds the caller's unclaimed balance.

The unlock-time gate applies to recipient claims (claimDividend / batchClaimDividend) and to reclaimDividend — admin reclaim is symmetric with recipient claiming. reclaimSurplus (surplus recovery) and reclaimTotalDividend (full-pool recall) are not gated on unlockedAtTs and remain available throughout the lifecycle.

Accounting model

Each (timestamp, token) distribution tracks:

FieldMeaning
totalFundsERC-20 deposited for the distribution (minus any reclaimed surplus)
totalAllocatedtotal assigned to recipients
totalClaimedtotal claimed by recipients
totalReclaimedtotal reclaimed from recipients by admins
unlockedAtTsunix time at/after which recipients may claim. 0 only before start (unlock not yet pinned); start paths and setUnlockTime normalize a 0 input to block.timestamp, so a started distribution always holds a real claim-open time
startedwhether the distribution has been auto-started via fundDividend (allocations stay editable until claiming opens)

Per recipient, the contract tracks allocated, claimed, and reclaimed. Once claiming is open (isClaimable — started, unlock time reached, fully funded), a recipient's claimable balance is:

unclaimedBalanceAt = allocated - claimed - reclaimed

unclaimedBalanceAt is gated on isClaimable: it reports 0 while the distribution is pending, still time-locked, or under-funded, rather than the raw formula above. This keeps it aligned with the IDividends doc ("amount of tokens that can be claimed") so a plain-IDividends consumer never sees a non-zero balance for a claim that would actually revert. Use totalAwardedBalanceAt / allocatedBalanceAt to read the raw entitlement for a pending or locked distribution.

Funding follows allocation (fundDividend reverts with RecallablePayment_NoAllocationsToFund when totalAllocated == 0), so over-funding produces a real surplus (totalFunds - totalAllocated). The full-funding gate enforced at auto-start (totalFunds >= totalAllocated) guarantees that every owed allocation is fully backed once claiming opens, preserving the coverage invariant:

tokensAt = totalFunds - totalClaimed - totalReclaimed  >=  remaining owed

Reclaiming

  • reclaimDividend(token, recipient, timestamp, amount) — claws back a specific recipient's unclaimed allocation and sends it to reclaimerAddress. amount = 0 reclaims all of that recipient's remaining unclaimed balance. After a reclaim the recipient can no longer claim the reclaimed portion. Requires the same gates as claimDividend: started, unlock time reached, and fully funded. To recover funds while claiming is not open — before start, before the unlock time, or while under-funded (allocations stay editable in all three cases) — reduce or zero the allocation via createDistribution, then call reclaimSurplus to drain the resulting surplus.
  • reclaimSurplus(token, amount, timestamp) — reclaims deposited surplus (totalFunds - totalAllocated) to reclaimerAddress. With funding decoupled from allocation this is a reachable path; it can never touch amounts still owed to recipients. Not gated on started or unlockedAtTs.
  • reclaimTotalDividend(token, amount, timestamp) — the IDividends-mandated full-pool recall: reclaims the distribution's entire remaining totalFunds to reclaimerAddress, but only while nothing has been claimed or reclaimed for it (totalClaimed == 0 && totalReclaimed == 0); reverts with RecallablePayment_DividendsAlreadyClaimed otherwise. Unlike reclaimSurplus, this can pull funds still owed to recipients — safe only because none has been claimed yet. Draining totalFunds below totalAllocated makes the distribution under-funded (claims blocked until it is topped up again). Not gated on started or unlockedAtTs.

All three reclaim functions require a non-zero reclaimerAddress (set via setReclaimerAddress) and Transfer Admin privileges. setReclaimerAddress rejects the zero address and the distribution contract itself (RecallablePayment_InvalidReclaimerAddress) — a self-reclaimer would strand tokens with no sweep path — and rejects a no-op that leaves the current value unchanged (RecallablePayment_ReclaimerAddressUnchanged).

Wallet recovery

forceTransferDividend(token, timestamp, oldWallet, newWallet) migrates a recipient's full position for one (token, timestamp) distribution — allocated, claimed, and reclaimed — from a lost wallet to a new wallet. If newWallet already has a position in the same distribution, the values are merged. newWallet may not be the zero address or the distribution contract itself (the contract cannot claim from itself, so a self-position would be dead weight). Callable only by Contract Admin. Not gated on global pause, started, or unlockedAtTs, so recovery can proceed even while the contract is paused or before claiming opens. Must be called once per distribution when the lost wallet has multiple distributions.

Operational considerations

The Contract Admin that gates forceTransferDividend should be sufficiently decentralized in production, since RecallablePayment holds and distributes payment tokens such as USDC. There may be regulatory reasons to rely on recovery wallets being non-custodial and isolated from other wallet controls. In practice the Contract Admin is likely to be a Gnosis Safe (or similar multisig) rather than a single EOA. Gating wallet recovery on Contract Admin — alongside setReclaimerAddress and pause — is intended to accommodate that operational setup; flag this for deployment runbooks and operator onboarding.

Supported payment tokens

RecallablePayment supports standard, fixed-balance ERC-20 tokens only. Fee-on-transfer (deflationary), rebasing / elastic-supply, and ERC777-callback tokens are not supported as a distribution token.

This is enforced at funding: fundDividend compares the contract's balance before and after the inbound transferFrom and reverts with RecallablePayment_InvalidFeeApplied if the received amount differs from the requested amount. The check is strict equality, so it rejects both a transfer fee and an unexpected balance increase (positive rebase, or a third-party push during an ERC777 hook).

The payout path is deliberately not guarded. claimDividend, reclaimDividend, reclaimSurplus and reclaimTotalDividend settle the nominal amount held in the accounting model described under Accounting model and never reconcile against the live token balance. If a non-standard token is used in spite of the constraint, behaviour on that path is undefined: an outbound transfer fee would silently underpay recipients while claimed/reclaimed record the full amount, and a negative rebase would drop the real balance below Σ owed, so the last claimants revert while unclaimedBalanceAt still reports a balance. There is no sweep path to recover from either state.

Because accounting is counter-based, operators should compare IERC20(token).balanceOf(<RecallablePayment>) against the outstanding obligation across live distributions to detect drift before claimants hit a revert. USDC, DAI and similar fixed-balance stablecoins satisfy the constraint.

Roles

Role checks are routed to the external AccessControl contract (the same model used by InterestPayment):

ActionRequired role
createDistribution, fundDividend, reclaimDividend, reclaimSurplus, reclaimTotalDividendTransfer Admin
forceClaimDividend / batchForceClaimDividendTransfer Admin
setUnlockTimeTransfer Admin (not blocked by global pause)
setReclaimerAddressContract Admin
forceTransferDividendContract Admin (not blocked by global pause or claimable gates)
pause(bool)Contract Admin or Transfer Admin
claimDividend / batchClaimDividendany recipient (self)

Events

Consumed by the off-chain Transfer Agent indexer (see contracts/interfaces/IRecallablePayment.sol):

EventEmitted when
AllocationUpdated(token, recipient, timestamp, amount, admin)a recipient's allocation is set, updated, or removed in createDistribution. amount is the recipient's new allocation (not a delta): amount == 0 means removed, amount > 0 means created/updated. Index consumers can key on (timestamp, token, recipient) and upsert when amount > 0 / delete when amount == 0. The resulting distribution total is readable via totalAllocatedAt.
DividendFunded(payer, token, amount, timestamp)funds deposited via fundDividend
DistributionStarted(admin, token, timestamp, totalAllocated, totalFunds, unlockedAtTs)distribution auto-started via fundDividend when fully funded. Carries activation-time totals and the scheduled unlock; does not freeze the recipient set — keep processing AllocationUpdated until claiming opens
DistributionUnlockTimeUpdated(admin, token, timestamp, unlockedAtTs)the unlock time is changed after start via setUnlockTime. Carries the normalized value: setUnlockTime(..., 0) emits block.timestamp, not 0
DividendClaimed(payee, token, amount, timestamp)a recipient claims
DividendReclaimed(payee, target, token, amount, timestamp)an admin reclaims. payee is the transfer admin that initiated the reclaim — not the fund destination; funds go to the current reclaimerAddress (recoverable from the ReclaimerAddressChanged history, which always has at least one entry before any reclaim). target is the recipient clawed back from (reclaimDividend), or the contract address for pool-level reclaims (reclaimSurplus / reclaimTotalDividend)
ReclaimerAddressChanged(admin, previousReclaimer, newReclaimer)setReclaimerAddress changes the reclaimer (previousReclaimer is zero on first assignment)
DividendForceTransferred(token, timestamp, oldWallet, newWallet, allocated, claimed, reclaimed, admin)a recipient's position is migrated via forceTransferDividend

Indexer allocation snapshot rules

Do not snapshot the recipient set on DistributionStarted. Allocations stay mutable until claiming actually opens — started && block.timestamp >= unlockedAtTs && totalFunds >= totalAllocated (the same condition as isClaimable, and the condition that makes createDistribution revert with RecallablePayment_DistributionClaimingOpen):

  • Continue upserting/deleting from AllocationUpdated while the distribution is not claimable — including when the unlock time has passed but totalFunds < totalAllocated (a post-start raise can still be lowered back to coverage).
  • Treat allocations as frozen only once started && block.timestamp >= unlockedAtTs && totalFunds >= totalAllocated (further createDistribution calls revert). Do not freeze purely on block.timestamp >= unlockedAtTs — an under-funded distribution past its unlock time is still editable.
  • Prefer on-chain totalAllocatedAt / allocatedBalanceAt as source of truth over event snapshots.

Errors

ErrorReverts when
RecallablePayment_InvalidTimestamp()timestamp == 0
RecallablePayment_InvalidTokenAddress()token == address(0)
RecallablePayment_InvalidTrustedForwarder()constructor called with a zero trustedForwarder
RecallablePayment_InvalidRecipientAddress(index)a recipient at index is the zero address or the distribution contract itself
RecallablePayment_InvalidAmount(index)the funded amount (in fundDividend) is zero
RecallablePayment_NoAllocationsToFund()fundDividend called before any allocation exists for (token, timestamp)
RecallablePayment_AllocationUnchanged(index)the amount at index equals the recipient's current allocation (incl. 0 for a recipient that has none) — a stale/duplicate no-op
RecallablePayment_InvalidArrayLengths()recipients/amounts (or timestamps/amounts) lengths differ
RecallablePayment_EmptyRecipients()createDistribution called with no recipients
RecallablePayment_InvalidFeeApplied()fee-on-transfer detected during fundDividend
RecallablePayment_UnlockTimeConflict(provided, scheduled)a pre-start 4-arg fundDividend presents an unlockedAtTs that differs from the value already pinned by an earlier deposit
RecallablePayment_UnlockTimeNotApplicableAfterStart(requested, current)4-arg fundDividend after start with an unlockedAtTs that differs from the stored value
RecallablePayment_DistributionClaimingOpen()createDistribution once claiming has actually opened (started && block.timestamp >= unlockedAtTs && totalFunds >= totalAllocated), or setUnlockTime once the unlock time has been reached (started && block.timestamp >= unlockedAtTs). Note the difference: allocation edits stay open while under-funded past the unlock time, but rescheduling the unlock time does not
RecallablePayment_DistributionNotStarted()claiming before auto-start, or setUnlockTime / reclaimDividend before start
RecallablePayment_DistributionNotUnlocked()claiming or reclaimDividend after start but before unlockedAtTs is reached
RecallablePayment_NoRemainingUnclaimedBalance()claim/reclaim when the recipient has nothing left
RecallablePayment_NotEnoughFundsToClaim()requested amount exceeds the unclaimed, surplus, or total-funds balance
RecallablePayment_NoFundsToClaim()reclaimSurplus with zero surplus, or reclaimTotalDividend with zero totalFunds
RecallablePayment_DividendsAlreadyClaimed()reclaimTotalDividend after any claim or reclaim has occurred for the distribution
RecallablePayment_InvalidReclaimerAddress()reclaim attempted while reclaimerAddress == address(0), or setReclaimerAddress called with the zero address or the distribution contract itself
RecallablePayment_ReclaimerAddressUnchanged()setReclaimerAddress called with the current reclaimer address
RecallablePayment_InvalidWalletAddress()forceTransferDividend called with a zero oldWallet, or a newWallet that is zero or the distribution contract itself, or forceClaimDividend called with a zero wallet or the distribution contract itself
RecallablePayment_SameWallet()forceTransferDividend called with oldWallet == newWallet
RecallablePayment_NoPositionToTransfer()forceTransferDividend called when oldWallet has no position in the distribution

Interface summary

State-changing:

  • createDistribution(address token, uint256 timestamp, address[] recipients, uint256[] amounts)
  • fundDividend(address token, uint256 amount, uint256 timestamp)
  • fundDividend(address token, uint256 amount, uint256 timestamp, uint256 unlockedAtTs)
  • setUnlockTime(address token, uint256 timestamp, uint256 unlockedAtTs)0 means open now (stored/emitted as block.timestamp)
  • claimDividend(address token, uint256 timestamp, uint256 amount)
  • batchClaimDividend(address token, uint256[] timestamps, uint256[] amounts)
  • forceClaimDividend(address token, address wallet, uint256 timestamp, uint256 amount)
  • batchForceClaimDividend(address token, address wallet, uint256[] timestamps, uint256[] amounts)
  • reclaimDividend(address token, address recipient, uint256 timestamp, uint256 amount)
  • reclaimSurplus(address token, uint256 amount, uint256 timestamp)
  • reclaimTotalDividend(address token, uint256 amount, uint256 timestamp)
  • setReclaimerAddress(address newReclaimer)
  • forceTransferDividend(address token, uint256 timestamp, address oldWallet, address newWallet)
  • pause(bool shouldPause)

Views:

  • fundsAt(token, timestamp) — total funded
  • isStarted(token, timestamp) — whether the distribution is started (raw started flag; ignores the unlock time)
  • unlockTimeAt(token, timestamp) — the configured unlock time (unlockedAtTs); 0 only before start, otherwise the committed claim-open time
  • isClaimable(token, timestamp) — whether claiming is open right now (started and block.timestamp >= unlockedAtTs and totalFunds >= totalAllocated)
  • tokensAt(token, timestamp) — remaining (unclaimed and not reclaimed) funds held
  • totalAllocatedAt(token, timestamp) — total currently allocated across all recipients
  • allocatedBalanceAt(token, recipient, timestamp)
  • totalAwardedBalanceAt(token, recipient, timestamp) — originally allocated amount
  • claimedBalanceAt(token, recipient, timestamp)
  • reclaimedBalanceAt(token, recipient, timestamp)
  • unclaimedBalanceAt(token, recipient, timestamp) — currently-claimable amount; 0 unless isClaimable holds (diverges from IDividends' raw-entitlement description; see above)

Deep dives

These live in the comakery-security-token repo rather than on this site:

  • Distribution lifecycle (docs/recallable-payment/lifecycle.md) — the full create → fund state machine, claiming, reclaiming, and how to raise the recipient ceiling via chunked allocation.
  • Gas measurements (docs/recallable-payment/gas-measurements.md) — per-function gas usage, scaling behaviour, and block-gas-limit batch-size guidance.

Deployment

See deploy/12_recallable_payment.js. The constructor takes:

constructor(address accessControl, address trustedForwarder, address restrictedLockupToken)
  • accessControl (required, non-zero) — external AccessControl contract used for role checks. The deployer chooses any implementation: the security token's shared AccessControl, a dedicated payment-token AccessControl, or another compatible contract. In most production setups, a separate AccessControl is preferred so Transfer Admin and Contract Admin authority over USDC (or other payment tokens) stays isolated from the security token's admin set. Grant roles on whichever AccessControl is wired here.
  • trustedForwarder (required, non-zero) — ERC2771 forwarder. The constructor reverts with RecallablePayment_InvalidTrustedForwarder if zero; the forwarder is immutable in ERC2771Context, so an accidental zero could never be corrected without redeploying. See docs/recallable-payment/deploy.md in the comakery-security-token repo for config resolution.
  • restrictedLockupToken — optional RestrictedLockupToken reference for discoverability and integration. Zero = standalone distributor. Independent of accessControl: you may set the related security token address while wiring a dedicated payment AccessControl.