Skip to content

feat(b20): add preauthorized spender allowances - #223

Open
stephancill wants to merge 5 commits into
mainfrom
stephancilliers/b20-operator-role
Open

feat(b20): add preauthorized spender allowances#223
stephancill wants to merge 5 commits into
mainfrom
stephancilliers/b20-operator-role

Conversation

@stephancill

@stephancill stephancill commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Token issuers need to preauthorize trusted integrations to spend from every holder without per-holder approvals while keeping that authority separate from Asset operations. This adds a dedicated preauthorized spender role while preserving existing controls.

  • Add shared PREAUTHORIZED_SPENDER_ROLE support to Asset and Stablecoin.
  • Return infinite effective allowance without changing stored holder allowances.
  • Keep OPERATOR_ROLE Asset-only for announcements and multiplier administration.
  • Preserve pause, transfer policies, delegable administration, and the no-opt-out model.
  • Document Permit2 usage, gas considerations, and rejected design alternatives.

type=nonroutine
risk=medium
impact=sev4
backwards_compatible=false
automerge=false

Co-authored-by: OpenCode <opencode-noreply@coinbase.com>
@github-actions

Copy link
Copy Markdown

Interface Coverage

✅ All interface functions have test coverage.

@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

📊 Forge Coverage (src/lib/)

🟡 ≥95% across all metrics — some metrics below 99%.

File Lines Stmts Branches Funcs
🟡 B20FactoryLib.sol 97.70% 98.00% 100.00% 95.00%
🔴 test/lib/ForceFeeder.sol 0.00% 0.00% 100.00% 0.00%
🔴 test/lib/PrecompileProbe.sol 0.00% 0.00% 0.00% 0.00%
🟢 MockActivationRegistry.sol 100.00% 100.00% 100.00% 100.00%
🟢 MockActivationRegistryStorage.sol 100.00% 100.00% 100.00% 100.00%
🟢 MockB20.sol 100.00% 100.00% 100.00% 100.00%
🟢 MockB20Asset.sol 100.00% 100.00% 100.00% 100.00%
🟡 MockB20Factory.sol 98.96% 99.10% 100.00% 100.00%
🟢 MockB20Stablecoin.sol 100.00% 100.00% 100.00% 100.00%
🟢 MockB20Storage.sol 100.00% 100.00% 100.00% 100.00%
🟡 MockPolicyRegistry.sol 100.00% 99.54% 97.67% 100.00%
🟢 MockPolicyRegistryStorage.sol 100.00% 100.00% 100.00% 100.00%
Total 97.08% 97.53% 98.18% 97.00%

Full report: download artifact. To browse locally: make coverage (runs forge coverage + genhtml + opens the HTML report).

@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

⚠️ Fork tests: 32 failed, 737 passed

These failures indicate divergences where base/base needs to catch up to the base-std spec. This check is advisory and does not block merging.

Failing tests
  • test_allowance_success_preauthorizedSpenderReadsAsInfinite(address,uint256): preauthorized spender allowance must be infinite: 173783353463 != 115792089237316195423570985008687907853269984665640564039457584007913129639935; counterexample: calldata=0x9a57557b0000000000000000000000001226a600138d53dc4e9ffd486dfc7641560646a000000000000000000000000000000000000000000000000000000028764b8877 args=[0x1226A600138d53Dc4e9ffD486dFC7641560646a0, 173783353463 [1.737e11]]
  • test_allowance_success_preauthorizedSpenderReadsAsInfinite(address,uint256): preauthorized spender allowance must read as infinite: 3 != 115792089237316195423570985008687907853269984665640564039457584007913129639935; counterexample: calldata=0x9a57557b00000000000000000000000000000000000000000000000000000000000013030000000000000000000000000000000000000000000000000000000000000003 args=[0x0000000000000000000000000000000000001303, 3]
  • test_transferFromWithMemo_revert_preauthorizedSpenderExecutorPolicyForbids(address,address,uint256,bytes32): Error != expected error: InsufficientAllowance(0x6DeFFdf410729Ff39a70Ab41ea085560fdC4db62, 0, 2042989818 [2.042e9]) != PolicyForbids(0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8, 72057594037927937 [7.205e16]); counterexample: calldata=0x001d835400000000000000000000000098c872e17f26cb302cab51cb3aa47b1dc8cba4b90000000000000000000000000d2e080259f5e8b289710ff0f7fa160fd35510450000000000000000000000000000000000000000000000000000000079c58cfa3257b1ebdd47b54f4faa666ff97e82831ee512c420833e7d5f9001d261b44864 args=[0x98c872e17F26Cb302cAb51cb3AA47B1Dc8cBA4b9, 0x0d2E080259F5e8b289710fF0f7FA160FD3551045, 2042989818 [2.042e9], 0x3257b1ebdd47b54f4faa666ff97e82831ee512c420833e7d5f9001d261b44864]
  • test_transferFromWithMemo_revert_preauthorizedSpenderExecutorPolicyForbids(address,address,uint256,bytes32): Error != expected error: InsufficientAllowance(0x6DeFFdf410729Ff39a70Ab41ea085560fdC4db62, 0, 339346576870 [3.393e11]) != PolicyForbids(0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8, 72057594037927937 [7.205e16]); counterexample: calldata=0x001d83540000000000000000000000008418afcaa8477bd278b349bccc10570fe2eda7bb0000000000000000000000008590c27698d8d03706060d7e36f3611418a675000000000000000000000000000000000000000000000000000000004f02a1d5e66ad3a6b9f303a475f9ebb1ed1b27e7ef16539223ffd0e21cf7ac01baf3fa9b57 args=[0x8418AFcaA8477Bd278B349Bccc10570FE2eDa7Bb, 0x8590C27698D8d03706060d7E36F3611418a67500, 339346576870 [3.393e11], 0x6ad3a6b9f303a475f9ebb1ed1b27e7ef16539223ffd0e21cf7ac01baf3fa9b57]
  • test_transferFromWithMemo_success_preauthorizedSpenderSpendsAfterHolderApprovesZero(address,address,uint256,bytes32): InsufficientAllowance(0x6DeFFdf410729Ff39a70Ab41ea085560fdC4db62, 0, 131408504100400439262709900097354961216 [1.314e38]); counterexample: calldata=0x9d4d4ab90000000000000000000000005749f55c1949a16176e5fe2430b7d7ffc25aece2000000000000000000000000775486810a80854e41623702597d54c301de4bfe00008eeecb8f3a255cf64479f422b91f62dc5b77f95e48c6455039f2dd9391403af9b898e97a3de8eda66873cce2404f552f6be3acc7b7a8808fe94f6b0527ea args=[0x5749f55C1949A16176e5fE2430b7d7fFC25AEce2, 0x775486810a80854e41623702597d54C301dE4bfE, 986485885866475490181287660973826042261611811807174978426140117393707328 [9.864e71], 0x3af9b898e97a3de8eda66873cce2404f552f6be3acc7b7a8808fe94f6b0527ea]
  • test_transferFromWithMemo_success_preauthorizedSpenderSpendsWithoutAllowance(address,address,uint256,bytes32): InsufficientAllowance(0x6DeFFdf410729Ff39a70Ab41ea085560fdC4db62, 0, 7907020162 [7.907e9]); counterexample: calldata=0xff8fb2760000000000000000000000007abc2551d507bbbb67b889d752bd5737229c513500000000000000000000000099b37dae595002457fce785fe94dbc2ce5dfa3c000000000000000000000000000000000000000000000000000000001d74b8d82a1c6c046d368b76ec3232080502d6f6f22010bfa1d309bf0961be421ae696292 args=[0x7abc2551d507BbBb67B889D752bd5737229C5135, 0x99b37DAE595002457fCE785FE94DBC2ce5Dfa3C0, 7907020162 [7.907e9], 0xa1c6c046d368b76ec3232080502d6f6f22010bfa1d309bf0961be421ae696292]
  • test_transferFrom_revert_preauthorizedSpenderExecutorPolicyForbids(address,address,uint256): Error != expected error: InsufficientAllowance(0x6DeFFdf410729Ff39a70Ab41ea085560fdC4db62, 0, 2) != PolicyForbids(0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8, 72057594037927937 [7.205e16]); counterexample: calldata=0xa116a27d0000000000000000000000000c7c4b5729d8f3a9dc82d907ec869664290e7dd2000000000000000000000000415d62f4079920cf7b597c61f5903414afc5a26f0000000000000000000000000000000000000000000000000000000000000002 args=[0x0C7C4B5729D8f3a9DC82D907EC869664290e7dD2, 0x415d62f4079920CF7b597c61F5903414Afc5A26F, 2]
  • test_transferFrom_revert_preauthorizedSpenderExecutorPolicyForbids(address,address,uint256): Error != expected error: InsufficientAllowance(0x6DeFFdf410729Ff39a70Ab41ea085560fdC4db62, 0, 270480478485791422 [2.704e17]) != PolicyForbids(0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8, 72057594037927937 [7.205e16]); counterexample: calldata=0xa116a27d000000000000000000000000aae1cc5f49ebe8d48c462ec0be92faa05e65a6a70000000000000000000000004478c986c7033e2e8103ef6f3c2776e00e98c5fa00000000000000000000000000000000000000000000000003c0f08fe6d2c2be args=[0xAaE1Cc5f49eBe8d48C462EC0BE92fAA05e65a6a7, 0x4478C986c7033E2e8103EF6F3C2776e00e98C5fA, 270480478485791422 [2.704e17]]
  • test_transferFrom_revert_preauthorizedSpenderReceiverPolicyForbids(address,address,uint256): Error != expected error: InsufficientAllowance(0x6DeFFdf410729Ff39a70Ab41ea085560fdC4db62, 0, 2) != PolicyForbids(0x8a4b3fa2d8b921852bc0089c6ef0958aa6961897be36fd731330fe2cd23f8363, 72057594037927937 [7.205e16]); counterexample: calldata=0xf5c29dc4000000000000000000000000988e043ee79bcc30cd4f0a5949f0edf1b2c82094000000000000000000000000dc2a8f5fa1a1f1b227d49590a48441ffa4efc28a0000000000000000000000000000000000000000000000000000000000000002 args=[0x988E043ee79bcC30CD4F0A5949F0EDf1B2C82094, 0xdC2a8f5Fa1A1f1B227D49590A48441fFA4efC28a, 2]
  • test_transferFrom_revert_preauthorizedSpenderSenderPolicyForbids(address,address,uint256): Error != expected error: InsufficientAllowance(0x6DeFFdf410729Ff39a70Ab41ea085560fdC4db62, 0, 314450492242270948951582458278884128636 [3.144e38]) != PolicyForbids(0xb81736c875ab819dd97f59f2a6542cfb731ad52b4ae15a6f24df2fb02b0327f5, 72057594037927937 [7.205e16]); counterexample: calldata=0x5ab0150d000000000000000000000000724f4a11e91950618a8a57074232d0885323635900000000000000000000000099a898cad3a5d3cf32f21e76a5f0344c79829ab800000000000000000000000b99c47422ec90f63f51a2c2aa43454071545bb77c args=[0x724F4a11E91950618a8A57074232d08853236359, 0x99A898cAD3a5d3cF32F21e76a5F0344C79829Ab8, 16954374661544110601326734889042304723284481783676 [1.695e49]]
  • test_transferFrom_success_preauthorizedSpenderPreservesStoredAllowance(address,address,uint256,uint256): preauthorized spender allowance must remain infinite: 20838976572780088766102093924385842252446482815110357 != 115792089237316195423570985008687907853269984665640564039457584007913129639935; counterexample: calldata=0x006f50200000000000000000000000006e435ad9161b56b0605ed7e42d52bc58fcfd95dd000000000000000000000000df2f1406333cf4b39859c11624d2518fbeb38e760000000000000000000037b29b3bd340787b350aa0d253d456144a8ba0e459990000000000000000000000000000000000000c6309e4475458590bc34a5e8cc4 args=[0x6E435ad9161b56B0605eD7e42d52bc58FCfd95dd, 0xDF2F1406333cf4B39859c11624D2518FBeb38e76, 20838976572780088766353329489055593644115099109317017 [2.083e52], 251235564669751391668616294206660 [2.512e32]]
  • test_transferFrom_success_preauthorizedSpenderPreservesStoredAllowance(address,address,uint256,uint256): stored allowance must not be consumed: 10791 != 10822; counterexample: calldata=0x006f502000000000000000000000000000000000000000000000000000000000003137b000000000000000000000000000000000000000000000000000000000000009230000000000000000000000000000000000000000000000000000000000002a46000000000000000000000000000000000000000000000000000000000000001f args=[0x00000000000000000000000000000000003137b0, 0x0000000000000000000000000000000000000923, 10822 [1.082e4], 31]
  • test_transferFrom_success_preauthorizedSpenderSpendsAfterHolderApprovesZero(address,address,uint256): InsufficientAllowance(0x6DeFFdf410729Ff39a70Ab41ea085560fdC4db62, 0, 20958 [2.095e4]); counterexample: calldata=0x79bb7d86000000000000000000000000cb0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002fb00000000000000000000000000000000000000000000000000000000000051de args=[0xCB00000000000000000000000000000000000000, 0x00000000000000000000000000000000000002FB, 20958 [2.095e4]]
  • test_transferFrom_success_preauthorizedSpenderSpendsWithoutAllowance(address,address,uint256): InsufficientAllowance(0x6DeFFdf410729Ff39a70Ab41ea085560fdC4db62, 0, 294512195024512505041287546723861338276 [2.945e38]); counterexample: calldata=0xde1a88fa000000000000000000000000c8a56578198fc3872ab5008f540e003a0347db72000000000000000000000000f060748efeec2acae48d83f93efa6b98e769f6210001c450e44dafa5b553850833352191dd90fc4c402900dc07987b1944b918a4 args=[0xC8A56578198Fc3872ab5008F540e003a0347dB72, 0xF060748eFeec2acae48d83f93EfA6b98e769F621, 3121770187643424368214208706255788624182236808795664159780741270935771300 [3.121e72]]
    [FAIL: custom error 0x6c0f8b76] test_PREAUTHORIZED_SPENDER_ROLE_success_matchesExpected() (gas: 5142)
    [FAIL: custom error 0x6c0f8b76] test_PREAUTHORIZED_SPENDER_ROLE_success_matchesExpected() (gas: 5209)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1

Comment thread src/interfaces/IB20.sol

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3

Comment thread src/lib/B20FactoryLib.sol

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4

Co-authored-by: OpenCode <opencode-noreply@coinbase.com>
@stephancill stephancill changed the title feat(b20): add issuer-approved operator allowances feat(b20): add issuer-authorized spender allowances Sep 10, 2026
Co-authored-by: OpenCode <opencode-noreply@coinbase.com>
Comment thread src/lib/B20Constants.sol

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5


## Summary

Denim lets a B20 issuer grant an account permission to spend from every holder without holder approvals. The dedicated `AUTHORIZED_SPENDER_ROLE` keeps this authority separate from the Asset-only `OPERATOR_ROLE`.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need to mention OPERATOR_ROLE


## Motivation

Some token integrations need one contract, such as a router or settlement system, to spend from every holder. Requiring each holder to call `approve` adds a transaction and prevents the integration from working for holders that cannot make an approval call.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Example: auto approve Permit2 to unlock signature-based transfers. This provides a workaround for lack of permit functionality for smart contract accounts

Co-authored-by: OpenCode <opencode-noreply@coinbase.com>

## Motivation

Some token integrations need one contract, such as a router or settlement system, to spend from every holder. For example, an issuer can authorize Permit2 to unlock signature-based transfers. This provides a workaround when a smart contract account cannot use token-native permit functionality. Requiring each holder to call `approve` adds a transaction and prevents these integrations from working for holders that cannot make an approval call.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove part about not being able to make an approval call


For any other caller, allowance behavior remains unchanged. A finite allowance decrements by the transferred amount, and `type(uint256).max` remains the non-decrementing ERC-20 sentinel.

### Storage layout

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add gas section

Comment thread src/lib/B20FactoryLib.sol Outdated
/// `address(0)` fields are skipped at bootstrap.
///
/// @dev `DEFAULT_ADMIN_ROLE` is assigned via `B20StablecoinCreateParams.initialAdmin`, not this struct.
/// @dev Append `encodeGrantRole(B20Constants.AUTHORIZED_SPENDER_ROLE, spender)` when needed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove

Comment thread src/lib/B20FactoryLib.sol Outdated
/// with an `OPERATOR_ROLE` slot.
///
/// @dev `DEFAULT_ADMIN_ROLE` is assigned via `B20AssetCreateParams.initialAdmin`, not this struct.
/// @dev Append `encodeGrantRole(B20Constants.AUTHORIZED_SPENDER_ROLE, spender)` when needed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove


### Interface changes

`AUTHORIZED_SPENDER_ROLE()` is added to the shared [`IB20`](../src/interfaces/IB20.sol) interface.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's go with PREAUTHORIZED_SPENDER_ROLE instead

Co-authored-by: OpenCode <opencode-noreply@coinbase.com>
@stephancill stephancill changed the title feat(b20): add issuer-authorized spender allowances feat(b20): add preauthorized spender allowances Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant