Transferability
The three approval levels (collection, outgoing, incoming), the fields of an approval, auto-approval flags, overrides, and how a transfer is validated.
Transferability is a set of approvals on three levels. A transfer executes only when the sender has the balance and the approvals on every non-overridden level match.
Shape
// Stored on TokenCollection.collectionApprovals[]
interface CollectionApproval<T extends bigint> {
toListId: string; // Who can receive
fromListId: string; // Who can send
initiatedByListId: string; // Who can initiate
transferTimes: UintRange<T>[]; // When the transfer can happen (UNIX ms)
tokenIds: UintRange<T>[]; // Which token IDs
ownershipTimes: UintRange<T>[]; // Which ownership times are transferred
approvalId: string; // Unique on this level
version: T; // Incremented by the chain on every update
uri?: string;
customData?: string;
approvalCriteria?: ApprovalCriteria<T>;
}| Field | Type | Required | Description |
|---|---|---|---|
toListId | address list ID | yes | Recipients that match. Examples: "All", a single bb1 address |
fromListId | address list ID | yes | Senders that match. Examples: "Mint", "!Mint" |
initiatedByListId | address list ID | yes | Initiators (the transaction signer) that match |
transferTimes | UintRange[] | yes | Block times when the transfer may occur |
tokenIds | UintRange[] | yes | Token IDs covered |
ownershipTimes | UintRange[] | yes | Ownership times that can be moved |
approvalId | string | yes | Unique per level. Cannot be default-outgoing, default-incoming, self-initiated-outgoing, self-initiated-incoming, or all-incoming-transfers. |
version | Uint | set by chain | Starts at 0 and increments when the approval changes |
uri | string | no | Metadata link |
customData | string | no | Free-form string, or inline JSON metadata (name + description) |
approvalCriteria | ApprovalCriteria | no | Extra conditions. See Approval Criteria. |
The first six fields answer who, when, and what. An approval matches a transfer when the sender is in fromListId, the recipient in toListId, the initiator in initiatedByListId, the block time in transferTimes, and the balance being moved falls inside tokenIds and ownershipTimes.
Ask your agent:
Make collection 1 freely transferable between all non-Mint addresses, and keep minting limited to alice.The MCP builder tools (add_approval, add_preset_approval) produce the objects on this page.
How It Works
Three Levels
| Level | Set by | approvalLevel | approverAddress | Stored on | Message | Typical use |
|---|---|---|---|---|---|---|
| Collection | manager | collection | "" | TokenCollection.collectionApprovals | MsgSetCollectionApprovals | global rules, freezing, compliance, minting |
| Outgoing | sender | outgoing | the sender's address | UserBalanceStore.outgoingApprovals | MsgSetOutgoingApproval | listings, delegation |
| Incoming | recipient | incoming | the recipient's address | UserBalanceStore.incomingApprovals | MsgSetIncomingApproval | bids, opt-in receiving |
Every transfer must satisfy a collection approval. It must also satisfy the sender's outgoing approvals and the recipient's incoming approvals unless the matched collection approval overrides them.
Validation Flow
For each transfer the chain checks, in order:
- The sender's balance covers the amounts, IDs, and ownership times.
- A collection approval matches, including all of its approval criteria.
- Unless
overridesFromOutgoingApprovalsis set on the matched collection approval: the sender's outgoing approvals match, or the transfer is self-initiated andautoApproveSelfInitiatedOutgoingTransfersis on. - Unless
overridesToIncomingApprovalsis set: the recipient's incoming approvals match, or the transfer is self-initiated andautoApproveSelfInitiatedIncomingTransfersis on, orautoApproveAllIncomingTransfersis on.
Approvals define what is allowed. Transfers execute when an allowed path exists and balances suffice. Permissions (canUpdateCollectionApprovals and the user equivalents) define whether approvals can change. See Permissions.
Collection Approvals
Collection approvals apply to minting and to post-mint transfers alike. They are where the manager enforces global rules: freezing, revocation, whitelists, payments.
{
"fromListId": "Mint",
"toListId": "All",
"initiatedByListId": "All",
"transferTimes": [
{ "start": "1691931600000", "end": "1723554000000" }
],
"tokenIds": [
{ "start": "1", "end": "100" }
],
"ownershipTimes": [
{ "start": "1", "end": "18446744073709551615" }
],
"uri": "",
"approvalId": "mint-to-all",
"approvalCriteria": {
"approvalAmounts": {
"resetTimeIntervals": { "startTime": "0", "intervalLength": "0" }
},
"maxNumTransfers": {
"overallMaxNumTransfers": "1000",
"perInitiatedByAddressMaxNumTransfers": "1",
"amountTrackerId": "mint-to-all",
"resetTimeIntervals": { "startTime": "0", "intervalLength": "0" }
},
"overridesFromOutgoingApprovals": true,
"userApprovalSettings": {
"userRoyalties": { "percentage": "0", "payoutAddress": "" }
}
}
}{
"fromListId": "Mint",
"toListId": "All",
"initiatedByListId": "All",
"transferTimes": [
{ "start": "1691931600000", "end": "1723554000000" }
],
"tokenIds": [
{ "start": "1", "end": "100" }
],
"ownershipTimes": [
{ "start": "1", "end": "18446744073709551615" }
],
"uri": "",
"customData": "",
"approvalId": "mint-to-all",
"approvalCriteria": {
"merkleChallenges": [],
"predeterminedBalances": {
"manualBalances": [],
"incrementedBalances": {
"startBalances": [],
"incrementTokenIdsBy": "0",
"incrementOwnershipTimesBy": "0",
"durationFromTimestamp": "0",
"allowOverrideTimestamp": false,
"recurringOwnershipTimes": {
"startTime": "0",
"intervalLength": "0",
"chargePeriodLength": "0"
},
"allowOverrideWithAnyValidToken": false,
"allowAmountScaling": false,
"maxScalingMultiplier": "0"
},
"orderCalculationMethod": {
"useOverallNumTransfers": false,
"usePerToAddressNumTransfers": false,
"usePerFromAddressNumTransfers": false,
"usePerInitiatedByAddressNumTransfers": false,
"useMerkleChallengeLeafIndex": false,
"challengeTrackerId": ""
}
},
"approvalAmounts": {
"overallApprovalAmount": "0",
"perToAddressApprovalAmount": "0",
"perFromAddressApprovalAmount": "0",
"perInitiatedByAddressApprovalAmount": "0",
"amountTrackerId": "",
"resetTimeIntervals": { "startTime": "0", "intervalLength": "0" }
},
"maxNumTransfers": {
"overallMaxNumTransfers": "1000",
"perToAddressMaxNumTransfers": "0",
"perFromAddressMaxNumTransfers": "0",
"perInitiatedByAddressMaxNumTransfers": "1",
"amountTrackerId": "mint-to-all",
"resetTimeIntervals": { "startTime": "0", "intervalLength": "0" }
},
"coinTransfers": [],
"requireToEqualsInitiatedBy": false,
"requireFromEqualsInitiatedBy": false,
"requireToDoesNotEqualInitiatedBy": false,
"requireFromDoesNotEqualInitiatedBy": false,
"overridesFromOutgoingApprovals": true,
"overridesToIncomingApprovals": false,
"autoDeletionOptions": {
"afterOneUse": false,
"afterOverallMaxNumTransfers": false,
"allowCounterpartyPurge": false,
"allowPurgeIfExpired": false
},
"mustOwnTokens": [],
"dynamicStoreChallenges": [],
"ethSignatureChallenges": [],
"senderChecks": {
"mustBeEvmContract": false,
"mustNotBeEvmContract": false,
"mustBeLiquidityPool": false,
"mustNotBeLiquidityPool": false
},
"recipientChecks": {
"mustBeEvmContract": false,
"mustNotBeEvmContract": false,
"mustBeLiquidityPool": false,
"mustNotBeLiquidityPool": false
},
"initiatorChecks": {
"mustBeEvmContract": false,
"mustNotBeEvmContract": false,
"mustBeLiquidityPool": false,
"mustNotBeLiquidityPool": false
},
"altTimeChecks": {
"offlineHours": [],
"offlineDays": [],
"offlineMonths": [],
"offlineDaysOfMonth": [],
"offlineWeeksOfYear": [],
"timezoneOffsetMinutes": "0",
"timezoneOffsetNegative": false
},
"mustPrioritize": false,
"votingChallenges": [],
"allowBackedMinting": false,
"allowSpecialWrapping": false,
"evmQueryChallenges": [],
"userApprovalSettings": {
"allowedDenoms": [],
"disableUserCoinTransfers": false,
"userRoyalties": { "percentage": "0", "payoutAddress": "" }
}
},
"version": "0"
}Reads as: anyone can initiate one mint transfer covering IDs 1-100 between Aug 13, 2023 and Aug 13, 2024, up to 1000 transfers in total. This example limits transfer counts, not amounts: each transfer can mint arbitrary amounts of the covered IDs, subject to collection invariants and recipient approvals.
User-Level Approvals
Outgoing and incoming approvals have the same shape minus the field that is fixed to the owner. An outgoing approval has no fromListId (it is the owner). An incoming approval has no toListId. User-level criteria cannot use overrides or the other collection-only fields listed in Approval Criteria.
interface UserBalanceStore<T extends bigint> {
balances: Balance<T>[];
outgoingApprovals: OutgoingApproval<T>[];
incomingApprovals: IncomingApproval<T>[];
autoApproveSelfInitiatedOutgoingTransfers: boolean;
autoApproveSelfInitiatedIncomingTransfers: boolean;
autoApproveAllIncomingTransfers: boolean;
userPermissions: UserPermissions<T>;
}An outgoing approval owned by carol that lets bob take tokens across the full token ID range (a listing). fromListId is absent because it is carol:
{
"toListId": "bb1py4mfpg6uf59qkyzg0nmau322c5873eeysp5ue",
"initiatedByListId": "bb1py4mfpg6uf59qkyzg0nmau322c5873eeysp5ue",
"transferTimes": [
{ "start": "1", "end": "18446744073709551615" }
],
"tokenIds": [
{ "start": "1", "end": "18446744073709551615" }
],
"ownershipTimes": [
{ "start": "1", "end": "18446744073709551615" }
],
"uri": "",
"approvalId": "my-listing",
"approvalCriteria": {
"approvalAmounts": {
"resetTimeIntervals": { "startTime": "0", "intervalLength": "0" }
},
"maxNumTransfers": {
"resetTimeIntervals": { "startTime": "0", "intervalLength": "0" }
}
}
}{
"toListId": "bb1py4mfpg6uf59qkyzg0nmau322c5873eeysp5ue",
"initiatedByListId": "bb1py4mfpg6uf59qkyzg0nmau322c5873eeysp5ue",
"transferTimes": [
{ "start": "1", "end": "18446744073709551615" }
],
"tokenIds": [
{ "start": "1", "end": "18446744073709551615" }
],
"ownershipTimes": [
{ "start": "1", "end": "18446744073709551615" }
],
"uri": "",
"customData": "",
"approvalId": "my-listing",
"approvalCriteria": {
"merkleChallenges": [],
"predeterminedBalances": {
"manualBalances": [],
"incrementedBalances": {
"startBalances": [],
"incrementTokenIdsBy": "0",
"incrementOwnershipTimesBy": "0",
"durationFromTimestamp": "0",
"allowOverrideTimestamp": false,
"recurringOwnershipTimes": {
"startTime": "0",
"intervalLength": "0",
"chargePeriodLength": "0"
},
"allowOverrideWithAnyValidToken": false,
"allowAmountScaling": false,
"maxScalingMultiplier": "0"
},
"orderCalculationMethod": {
"useOverallNumTransfers": false,
"usePerToAddressNumTransfers": false,
"usePerFromAddressNumTransfers": false,
"usePerInitiatedByAddressNumTransfers": false,
"useMerkleChallengeLeafIndex": false,
"challengeTrackerId": ""
}
},
"approvalAmounts": {
"overallApprovalAmount": "0",
"perToAddressApprovalAmount": "0",
"perFromAddressApprovalAmount": "0",
"perInitiatedByAddressApprovalAmount": "0",
"amountTrackerId": "",
"resetTimeIntervals": { "startTime": "0", "intervalLength": "0" }
},
"maxNumTransfers": {
"overallMaxNumTransfers": "0",
"perToAddressMaxNumTransfers": "0",
"perFromAddressMaxNumTransfers": "0",
"perInitiatedByAddressMaxNumTransfers": "0",
"amountTrackerId": "",
"resetTimeIntervals": { "startTime": "0", "intervalLength": "0" }
},
"coinTransfers": [],
"requireToEqualsInitiatedBy": false,
"requireToDoesNotEqualInitiatedBy": false,
"autoDeletionOptions": {
"afterOneUse": false,
"afterOverallMaxNumTransfers": false,
"allowCounterpartyPurge": false,
"allowPurgeIfExpired": false
},
"mustOwnTokens": [],
"dynamicStoreChallenges": [],
"ethSignatureChallenges": [],
"recipientChecks": {
"mustBeEvmContract": false,
"mustNotBeEvmContract": false,
"mustBeLiquidityPool": false,
"mustNotBeLiquidityPool": false
},
"initiatorChecks": {
"mustBeEvmContract": false,
"mustNotBeEvmContract": false,
"mustBeLiquidityPool": false,
"mustNotBeLiquidityPool": false
},
"altTimeChecks": {
"offlineHours": [],
"offlineDays": [],
"offlineMonths": [],
"offlineDaysOfMonth": [],
"offlineWeeksOfYear": [],
"timezoneOffsetMinutes": "0",
"timezoneOffsetNegative": false
},
"mustPrioritize": false,
"votingChallenges": [],
"evmQueryChallenges": []
},
"version": "0"
}An incoming approval owned by carol that accepts tokens across the full token ID range from alice (a bid). toListId is absent because it is carol:
{
"fromListId": "bb1p0rrel3365scadq5k9pv0x0zp9j22js6dnw70d",
"initiatedByListId": "bb1p0rrel3365scadq5k9pv0x0zp9j22js6dnw70d",
"transferTimes": [
{ "start": "1", "end": "18446744073709551615" }
],
"tokenIds": [
{ "start": "1", "end": "18446744073709551615" }
],
"ownershipTimes": [
{ "start": "1", "end": "18446744073709551615" }
],
"uri": "",
"approvalId": "my-bids",
"approvalCriteria": {
"approvalAmounts": {
"resetTimeIntervals": { "startTime": "0", "intervalLength": "0" }
},
"maxNumTransfers": {
"resetTimeIntervals": { "startTime": "0", "intervalLength": "0" }
}
}
}{
"fromListId": "bb1p0rrel3365scadq5k9pv0x0zp9j22js6dnw70d",
"initiatedByListId": "bb1p0rrel3365scadq5k9pv0x0zp9j22js6dnw70d",
"transferTimes": [
{ "start": "1", "end": "18446744073709551615" }
],
"tokenIds": [
{ "start": "1", "end": "18446744073709551615" }
],
"ownershipTimes": [
{ "start": "1", "end": "18446744073709551615" }
],
"uri": "",
"customData": "",
"approvalId": "my-bids",
"approvalCriteria": {
"merkleChallenges": [],
"predeterminedBalances": {
"manualBalances": [],
"incrementedBalances": {
"startBalances": [],
"incrementTokenIdsBy": "0",
"incrementOwnershipTimesBy": "0",
"durationFromTimestamp": "0",
"allowOverrideTimestamp": false,
"recurringOwnershipTimes": {
"startTime": "0",
"intervalLength": "0",
"chargePeriodLength": "0"
},
"allowOverrideWithAnyValidToken": false,
"allowAmountScaling": false,
"maxScalingMultiplier": "0"
},
"orderCalculationMethod": {
"useOverallNumTransfers": false,
"usePerToAddressNumTransfers": false,
"usePerFromAddressNumTransfers": false,
"usePerInitiatedByAddressNumTransfers": false,
"useMerkleChallengeLeafIndex": false,
"challengeTrackerId": ""
}
},
"approvalAmounts": {
"overallApprovalAmount": "0",
"perToAddressApprovalAmount": "0",
"perFromAddressApprovalAmount": "0",
"perInitiatedByAddressApprovalAmount": "0",
"amountTrackerId": "",
"resetTimeIntervals": { "startTime": "0", "intervalLength": "0" }
},
"maxNumTransfers": {
"overallMaxNumTransfers": "0",
"perToAddressMaxNumTransfers": "0",
"perFromAddressMaxNumTransfers": "0",
"perInitiatedByAddressMaxNumTransfers": "0",
"amountTrackerId": "",
"resetTimeIntervals": { "startTime": "0", "intervalLength": "0" }
},
"coinTransfers": [],
"requireFromEqualsInitiatedBy": false,
"requireFromDoesNotEqualInitiatedBy": false,
"autoDeletionOptions": {
"afterOneUse": false,
"afterOverallMaxNumTransfers": false,
"allowCounterpartyPurge": false,
"allowPurgeIfExpired": false
},
"mustOwnTokens": [],
"dynamicStoreChallenges": [],
"ethSignatureChallenges": [],
"senderChecks": {
"mustBeEvmContract": false,
"mustNotBeEvmContract": false,
"mustBeLiquidityPool": false,
"mustNotBeLiquidityPool": false
},
"initiatorChecks": {
"mustBeEvmContract": false,
"mustNotBeEvmContract": false,
"mustBeLiquidityPool": false,
"mustNotBeLiquidityPool": false
},
"altTimeChecks": {
"offlineHours": [],
"offlineDays": [],
"offlineMonths": [],
"offlineDaysOfMonth": [],
"offlineWeeksOfYear": [],
"timezoneOffsetMinutes": "0",
"timezoneOffsetNegative": false
},
"mustPrioritize": false,
"votingChallenges": [],
"evmQueryChallenges": []
},
"version": "0"
}Auto-Approval Flags
Three flags on the balance store approve transfers without an explicit approval. Leaving all three true is the usual choice.
| Flag | Effect when true |
|---|---|
autoApproveSelfInitiatedOutgoingTransfers | Outgoing transfers that the owner initiates skip the outgoing approval check |
autoApproveSelfInitiatedIncomingTransfers | Incoming transfers that the owner initiates (claims, requested airdrops) skip the incoming approval check |
autoApproveAllIncomingTransfers | Every incoming transfer skips the incoming approval check, whoever initiates it |
Turning autoApproveAllIncomingTransfers off makes an account opt-in only. The flags are auto-scannable and never need prioritization.
Overrides
A collection approval can skip the user-level check for the sender, the recipient, or both. This is how freezing, revocation, and forced distribution work. Only collection approvals have these fields.
A collection approval that lets alice, the manager, move any post-mint token without the sender's or the recipient's consent:
{
"fromListId": "!Mint",
"toListId": "All",
"initiatedByListId": "bb1p0rrel3365scadq5k9pv0x0zp9j22js6dnw70d",
"transferTimes": [
{ "start": "1", "end": "18446744073709551615" }
],
"tokenIds": [
{ "start": "1", "end": "18446744073709551615" }
],
"ownershipTimes": [
{ "start": "1", "end": "18446744073709551615" }
],
"uri": "",
"approvalId": "manager-revoke",
"approvalCriteria": {
"approvalAmounts": {
"resetTimeIntervals": { "startTime": "0", "intervalLength": "0" }
},
"maxNumTransfers": {
"resetTimeIntervals": { "startTime": "0", "intervalLength": "0" }
},
"overridesFromOutgoingApprovals": true,
"overridesToIncomingApprovals": true,
"userApprovalSettings": {
"userRoyalties": { "percentage": "0", "payoutAddress": "" }
}
}
}{
"fromListId": "!Mint",
"toListId": "All",
"initiatedByListId": "bb1p0rrel3365scadq5k9pv0x0zp9j22js6dnw70d",
"transferTimes": [
{ "start": "1", "end": "18446744073709551615" }
],
"tokenIds": [
{ "start": "1", "end": "18446744073709551615" }
],
"ownershipTimes": [
{ "start": "1", "end": "18446744073709551615" }
],
"uri": "",
"customData": "",
"approvalId": "manager-revoke",
"approvalCriteria": {
"merkleChallenges": [],
"predeterminedBalances": {
"manualBalances": [],
"incrementedBalances": {
"startBalances": [],
"incrementTokenIdsBy": "0",
"incrementOwnershipTimesBy": "0",
"durationFromTimestamp": "0",
"allowOverrideTimestamp": false,
"recurringOwnershipTimes": {
"startTime": "0",
"intervalLength": "0",
"chargePeriodLength": "0"
},
"allowOverrideWithAnyValidToken": false,
"allowAmountScaling": false,
"maxScalingMultiplier": "0"
},
"orderCalculationMethod": {
"useOverallNumTransfers": false,
"usePerToAddressNumTransfers": false,
"usePerFromAddressNumTransfers": false,
"usePerInitiatedByAddressNumTransfers": false,
"useMerkleChallengeLeafIndex": false,
"challengeTrackerId": ""
}
},
"approvalAmounts": {
"overallApprovalAmount": "0",
"perToAddressApprovalAmount": "0",
"perFromAddressApprovalAmount": "0",
"perInitiatedByAddressApprovalAmount": "0",
"amountTrackerId": "",
"resetTimeIntervals": { "startTime": "0", "intervalLength": "0" }
},
"maxNumTransfers": {
"overallMaxNumTransfers": "0",
"perToAddressMaxNumTransfers": "0",
"perFromAddressMaxNumTransfers": "0",
"perInitiatedByAddressMaxNumTransfers": "0",
"amountTrackerId": "",
"resetTimeIntervals": { "startTime": "0", "intervalLength": "0" }
},
"coinTransfers": [],
"requireToEqualsInitiatedBy": false,
"requireFromEqualsInitiatedBy": false,
"requireToDoesNotEqualInitiatedBy": false,
"requireFromDoesNotEqualInitiatedBy": false,
"overridesFromOutgoingApprovals": true,
"overridesToIncomingApprovals": true,
"autoDeletionOptions": {
"afterOneUse": false,
"afterOverallMaxNumTransfers": false,
"allowCounterpartyPurge": false,
"allowPurgeIfExpired": false
},
"mustOwnTokens": [],
"dynamicStoreChallenges": [],
"ethSignatureChallenges": [],
"senderChecks": {
"mustBeEvmContract": false,
"mustNotBeEvmContract": false,
"mustBeLiquidityPool": false,
"mustNotBeLiquidityPool": false
},
"recipientChecks": {
"mustBeEvmContract": false,
"mustNotBeEvmContract": false,
"mustBeLiquidityPool": false,
"mustNotBeLiquidityPool": false
},
"initiatorChecks": {
"mustBeEvmContract": false,
"mustNotBeEvmContract": false,
"mustBeLiquidityPool": false,
"mustNotBeLiquidityPool": false
},
"altTimeChecks": {
"offlineHours": [],
"offlineDays": [],
"offlineMonths": [],
"offlineDaysOfMonth": [],
"offlineWeeksOfYear": [],
"timezoneOffsetMinutes": "0",
"timezoneOffsetNegative": false
},
"mustPrioritize": false,
"votingChallenges": [],
"allowBackedMinting": false,
"allowSpecialWrapping": false,
"evmQueryChallenges": [],
"userApprovalSettings": {
"allowedDenoms": [],
"disableUserCoinTransfers": false,
"userRoyalties": { "percentage": "0", "payoutAddress": "" }
}
},
"version": "0"
}Mint approvals must set overridesFromOutgoingApprovals: true because the Mint address has no approvals of its own. Set the noForcefulPostMintTransfers invariant to forbid overrides on every non-Mint approval forever. Full rules and the reserved-address protection are on Overrides.
Break-Down Matching
The chain can split one transfer across several approvals. It walks the approvals in order, deducts as much as each one allows, and continues with the remainder. If anything is left over, the transfer fails and the error lists what each candidate approval rejected.
Design approvals so a transfer matches one of them. Rely on splitting only when you must. Prioritized Approvals explains which approvals the scan considers and how to pin a specific one.