@bananapus/omnichain-deployers-v6 0.0.28 → 0.0.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RISKS.md +62 -46
- package/package.json +8 -8
- package/script/Deploy.s.sol +8 -1
- package/src/JBOmnichainDeployer.sol +64 -19
- package/test/JBOmnichainDeployer.t.sol +2 -12
- package/test/JBOmnichainDeployerGuard.t.sol +45 -8
- package/test/OmnichainDeployerAttacks.t.sol +3 -1
- package/test/OmnichainDeployerEdgeCases.t.sol +12 -17
- package/test/OmnichainDeployerReentrancy.t.sol +8 -8
- package/test/TestAuditGaps.sol +4 -18
- package/test/Tiered721HookComposition.t.sol +13 -10
- package/test/audit/AuditFixesC2H6M14.t.sol +480 -0
- package/test/audit/CarryForwardRejectedHook.t.sol +6 -5
- package/test/audit/CashOutSpecMerge.t.sol +372 -0
- package/test/audit/DeterministicDrift.t.sol +78 -0
- package/test/audit/DeterministicPeerDrift.t.sol +79 -0
- package/test/audit/ExtraCashOutHookZeroReclaim.t.sol +340 -0
- package/test/audit/ForwardedPermissions.t.sol +297 -0
- package/test/audit/JBOmnichainDeployer.t.sol +16 -12
- package/test/audit/NftCashoutSupplyMismatch.t.sol +224 -0
- package/test/audit/{CodexNemesisAudit.t.sol → OmnichainAudit.t.sol} +9 -7
- package/test/audit/SplitCreditWeight.t.sol +437 -0
- package/test/audit/WeightScalingComparison.t.sol +14 -13
- package/test/fork/OmnichainForkTestBase.sol +5 -3
- package/test/fork/TestOmnichain721QueueAndAdjust.t.sol +5 -5
- package/test/fork/TestOmnichainCashOutFork.t.sol +42 -40
- package/test/fork/TestOmnichainStressFork.t.sol +87 -95
- package/test/fork/TestOmnichainWeightFork.t.sol +9 -27
- package/test/invariants/CrossChainDeployerInvariant.t.sol +208 -0
- package/test/invariants/OmnichainDeployerInvariant.t.sol +3 -5
- package/test/invariants/handlers/CrossChainDeployerHandler.sol +394 -0
- package/test/regression/EmptyRulesetConfigurations.t.sol +3 -5
- package/test/regression/HookOwnershipTransfer.t.sol +2 -5
- package/test/regression/ValidateController.t.sol +3 -11
package/RISKS.md
CHANGED
|
@@ -1,81 +1,97 @@
|
|
|
1
1
|
# Omnichain Deployers Risk Register
|
|
2
2
|
|
|
3
|
-
This file
|
|
3
|
+
This file covers the risks in the deployer layer that launches Juicebox projects across chains while composing 721 hooks, suckers, and optional custom data hooks.
|
|
4
4
|
|
|
5
|
-
## How
|
|
5
|
+
## How To Use This File
|
|
6
6
|
|
|
7
|
-
- Read `Priority risks` first
|
|
8
|
-
-
|
|
9
|
-
- Treat `Invariants to Verify` as required checks for any new omnichain deployment flow.
|
|
7
|
+
- Read `Priority risks` first. They capture the highest-blast-radius deployment and cash-out assumptions.
|
|
8
|
+
- Treat `Invariants to verify` as required checks for any new omnichain deployment flow.
|
|
10
9
|
|
|
11
|
-
## Priority
|
|
10
|
+
## Priority Risks
|
|
12
11
|
|
|
13
12
|
| Priority | Risk | Why it matters | Primary controls |
|
|
14
13
|
|----------|------|----------------|------------------|
|
|
15
14
|
| P0 | Registry-trusted sucker bypass | This deployer gives suckers privileged cash-out behavior based on registry answers. A bad registry entry can affect many projects. | Registry allowlists, deployment verification, and explicit registry scrutiny. |
|
|
16
15
|
| P1 | Cross-chain deployment drift | Omnichain assumptions fail if chain-specific wiring, peers, or composed hooks do not match. | Deterministic deploy ordering, parity checks, and post-deploy peer verification. |
|
|
17
|
-
| P1 | Data-hook composition mistakes | The deployer wraps or forwards custom data hooks
|
|
18
|
-
|
|
16
|
+
| P1 | Data-hook composition mistakes | The deployer wraps or forwards custom data hooks. A bad composition can alter pay or cash-out semantics unexpectedly. | Integration tests and careful forwarding review. |
|
|
19
17
|
|
|
20
18
|
## 1. Trust Assumptions
|
|
21
19
|
|
|
22
|
-
- **Trusted forwarder
|
|
23
|
-
- **Sucker registry
|
|
24
|
-
- **Controller trust.**
|
|
25
|
-
- **Extra data hooks
|
|
20
|
+
- **Trusted forwarder is trusted.**
|
|
21
|
+
- **Sucker registry answers are trusted.**
|
|
22
|
+
- **Controller trust matters.**
|
|
23
|
+
- **Extra data hooks are trusted code.**
|
|
26
24
|
|
|
27
|
-
## 2. Economic
|
|
25
|
+
## 2. Economic Risks
|
|
28
26
|
|
|
29
|
-
- **Sucker cashout bypass
|
|
30
|
-
- **
|
|
31
|
-
- **721 hook amount splitting
|
|
32
|
-
- **Cross-chain sender dependence
|
|
27
|
+
- **Sucker cashout bypass exists for registered suckers.**
|
|
28
|
+
- **Extra data hooks can manipulate weight or cash-out behavior.**
|
|
29
|
+
- **721 hook amount splitting can zero out project amount in edge cases.**
|
|
30
|
+
- **Cross-chain sender dependence affects deterministic sucker salts.**
|
|
33
31
|
|
|
34
32
|
## 3. Access Control
|
|
35
33
|
|
|
36
|
-
- **Wildcard
|
|
37
|
-
-
|
|
38
|
-
-
|
|
34
|
+
- **Wildcard `MAP_SUCKER_TOKEN` permission is broad.**
|
|
35
|
+
- **`launchRulesetsFor` requires combined permissions.**
|
|
36
|
+
- **`launchProjectFor` is intentionally permissionless for new projects.**
|
|
39
37
|
|
|
40
38
|
## 4. DoS Vectors
|
|
41
39
|
|
|
42
|
-
- **Ruleset ID collision
|
|
43
|
-
- **External hook
|
|
44
|
-
- **721 hook deployment revert
|
|
45
|
-
- **
|
|
40
|
+
- **Ruleset ID collision can block queueing.**
|
|
41
|
+
- **External hook reverts can block pay or cash-out flows.**
|
|
42
|
+
- **721 hook deployment revert blocks launch.**
|
|
43
|
+
- **Non-safe NFT transfers can still strand assets.**
|
|
46
44
|
|
|
47
45
|
## 5. Reentrancy Surface
|
|
48
46
|
|
|
49
|
-
- **`launchProjectFor`
|
|
50
|
-
- **`beforePayRecordedWith` delegates to external hooks.**
|
|
51
|
-
- **`beforeCashOutRecordedWith` delegates to external hooks.**
|
|
52
|
-
- **
|
|
47
|
+
- **`launchProjectFor` makes several external calls in sequence.**
|
|
48
|
+
- **`beforePayRecordedWith` delegates to external hooks.**
|
|
49
|
+
- **`beforeCashOutRecordedWith` delegates to external hooks.**
|
|
50
|
+
- **There is no `ReentrancyGuard`.** The deployer relies on being effectively stateless during pay and cash-out operations.
|
|
53
51
|
|
|
54
52
|
## 6. Integration Risks
|
|
55
53
|
|
|
56
|
-
- **Hook config keyed by predicted
|
|
57
|
-
- **Carried-forward 721 hook on queue
|
|
58
|
-
- **ERC721Receiver
|
|
59
|
-
- **Empty simplified launch config reverts.**
|
|
60
|
-
- **Cross-reference: sucker registration.** The deployer grants `MAP_SUCKER_TOKEN` to `SUCKER_REGISTRY` with `projectId=0` (wildcard). This means the registry can map tokens for ALL projects deployed through this deployer. See [nana-suckers-v6 RISKS.md](../nana-suckers-v6/RISKS.md) for the full sucker lifecycle risks.
|
|
61
|
-
- **Cross-reference: core reentrancy.** The deployer delegates to `JBController` and `JBMultiTerminal` for all fund operations. See [nana-core-v6 RISKS.md](../nana-core-v6/RISKS.md) section 3 for the reentrancy surface of these contracts.
|
|
54
|
+
- **Hook config is keyed by predicted ruleset ID.**
|
|
55
|
+
- **Carried-forward 721 hook behavior on queue depends on prior ruleset state.**
|
|
56
|
+
- **ERC721Receiver restrictions are narrow but non-safe transfers can still strand assets.**
|
|
57
|
+
- **Empty simplified launch config reverts.**
|
|
62
58
|
|
|
63
|
-
## 7. Invariants
|
|
59
|
+
## 7. Invariants To Verify
|
|
64
60
|
|
|
65
|
-
-
|
|
66
|
-
-
|
|
67
|
-
-
|
|
68
|
-
-
|
|
69
|
-
-
|
|
70
|
-
- Project NFT ownership: after `_launchProjectFor`, the project NFT is owned by `owner`, not the deployer.
|
|
71
|
-
- `deploySuckersFor` uses the same `_msgSender()` on every chain when deterministic cross-chain peer symmetry is expected.
|
|
61
|
+
- launched projects point at the intended controller
|
|
62
|
+
- stored 721 hook config exists for every ruleset created through this deployer
|
|
63
|
+
- sucker cashouts always get the intended zero-tax path
|
|
64
|
+
- self-reference prevention holds after setup
|
|
65
|
+
- the project NFT ends owned by the intended owner
|
|
72
66
|
|
|
73
67
|
## 8. Accepted Behaviors
|
|
74
68
|
|
|
75
|
-
### 8.1 Controller validation skipped during
|
|
69
|
+
### 8.1 Controller validation is skipped during initial launch
|
|
70
|
+
|
|
71
|
+
Pre-launch controller validation is impossible because the project does not yet exist. The accepted safeguard is the post-launch project ID match check.
|
|
72
|
+
|
|
73
|
+
### 8.2 Registered suckers receive 0% cashout tax
|
|
74
|
+
|
|
75
|
+
This is intentional and shares the same trust boundary as the sucker registry.
|
|
76
|
+
|
|
77
|
+
## 9. Accepted Security Risks
|
|
78
|
+
|
|
79
|
+
Documented risks that were reviewed and accepted.
|
|
80
|
+
|
|
81
|
+
### Configuration Risks
|
|
82
|
+
|
|
83
|
+
**Unvalidated extra data hooks can brick live flows.** *(Minor)*
|
|
84
|
+
Extra data hooks provided by the project owner in `_setup721` configuration can fail and brick live pay/cashout flows. Accepted because this is self-inflicted misconfiguration — only the project owner can set these hooks.
|
|
85
|
+
|
|
86
|
+
**Missing hook721 alias check enables double invocation.** *(Minor)*
|
|
87
|
+
If the project owner configures the 721 hook as both the primary hook and as an extra data hook, it could be invoked twice. Accepted because this is self-inflicted misconfiguration — the deployer correctly processes each hook independently.
|
|
88
|
+
|
|
89
|
+
### Hook Selection
|
|
76
90
|
|
|
77
|
-
|
|
91
|
+
**ApprovalExpected rulesets excluded from hook carry-forward.**
|
|
92
|
+
When no new tiers are provided, the deployer carries forward the 721 hook from the most recent approved ruleset. Rulesets with `ApprovalExpected` status are intentionally excluded even though they may become active. Hook selection is irreversible — if the pending ruleset is later rejected by the approval hook, we'd have locked in a hook from a ruleset that never became active. The deployer falls back to the current (already-approved) ruleset in this case.
|
|
78
93
|
|
|
79
|
-
###
|
|
94
|
+
### Cross-Chain Deployment
|
|
80
95
|
|
|
81
|
-
`
|
|
96
|
+
**`_msgSender()` in deployment salt breaks cross-chain determinism.** *(Minor)*
|
|
97
|
+
`deploySuckersFor` includes `_msgSender()` in the CREATE2 salt, which means the same deployment from different callers produces different addresses across chains. Accepted because this is intentional replay protection — prevents frontrunning of cross-chain deployments.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bananapus/omnichain-deployers-v6",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.30",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -17,17 +17,17 @@
|
|
|
17
17
|
"artifacts": "source ./.env && npx sphinx artifacts --org-id 'ea165b21-7cdc-4d7b-be59-ecdd4c26bee4' --project-name 'nana-omnichain-deployers-v6'"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@bananapus/721-hook-v6": "^0.0.
|
|
21
|
-
"@bananapus/buyback-hook-v6": "^0.0.
|
|
22
|
-
"@bananapus/core-v6": "^0.0.
|
|
23
|
-
"@bananapus/ownable-v6": "^0.0.
|
|
24
|
-
"@bananapus/permission-ids-v6": "^0.0.
|
|
25
|
-
"@bananapus/suckers-v6": "^0.0.
|
|
20
|
+
"@bananapus/721-hook-v6": "^0.0.41",
|
|
21
|
+
"@bananapus/buyback-hook-v6": "^0.0.30",
|
|
22
|
+
"@bananapus/core-v6": "^0.0.36",
|
|
23
|
+
"@bananapus/ownable-v6": "^0.0.20",
|
|
24
|
+
"@bananapus/permission-ids-v6": "^0.0.19",
|
|
25
|
+
"@bananapus/suckers-v6": "^0.0.28",
|
|
26
26
|
"@openzeppelin/contracts": "^5.6.1",
|
|
27
27
|
"@uniswap/v4-core": "^1.0.2"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@bananapus/address-registry-v6": "^0.0.
|
|
30
|
+
"@bananapus/address-registry-v6": "^0.0.20",
|
|
31
31
|
"@sphinx-labs/plugins": "^0.33.2"
|
|
32
32
|
}
|
|
33
33
|
}
|
package/script/Deploy.s.sol
CHANGED
|
@@ -8,6 +8,7 @@ import {CoreDeployment, CoreDeploymentLib} from "@bananapus/core-v6/script/helpe
|
|
|
8
8
|
import {Hook721Deployment, Hook721DeploymentLib} from "@bananapus/721-hook-v6/script/helpers/Hook721DeploymentLib.sol";
|
|
9
9
|
import {SuckerDeployment, SuckerDeploymentLib} from "@bananapus/suckers-v6/script/helpers/SuckerDeploymentLib.sol";
|
|
10
10
|
|
|
11
|
+
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
11
12
|
import {JBOmnichainDeployer} from "src/JBOmnichainDeployer.sol";
|
|
12
13
|
|
|
13
14
|
contract Deploy is Script, Sphinx {
|
|
@@ -61,7 +62,12 @@ contract Deploy is Script, Sphinx {
|
|
|
61
62
|
salt: NANA_OMNICHAIN_DEPLOYER_SALT,
|
|
62
63
|
creationCode: type(JBOmnichainDeployer).creationCode,
|
|
63
64
|
arguments: abi.encode(
|
|
64
|
-
suckers.registry,
|
|
65
|
+
suckers.registry,
|
|
66
|
+
hook.hook_deployer,
|
|
67
|
+
core.permissions,
|
|
68
|
+
core.projects,
|
|
69
|
+
core.directory,
|
|
70
|
+
core.trustedForwarder
|
|
65
71
|
),
|
|
66
72
|
deployer: safeAddress()
|
|
67
73
|
})) {
|
|
@@ -70,6 +76,7 @@ contract Deploy is Script, Sphinx {
|
|
|
70
76
|
hookDeployer: hook.hook_deployer,
|
|
71
77
|
permissions: core.permissions,
|
|
72
78
|
projects: core.projects,
|
|
79
|
+
directory: IJBDirectory(address(core.directory)),
|
|
73
80
|
trustedForwarder: core.trustedForwarder
|
|
74
81
|
});
|
|
75
82
|
}
|
|
@@ -6,6 +6,7 @@ import {IJB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/interfaces/IJB
|
|
|
6
6
|
import {JBApprovalStatus} from "@bananapus/core-v6/src/enums/JBApprovalStatus.sol";
|
|
7
7
|
import {JBPermissioned} from "@bananapus/core-v6/src/abstract/JBPermissioned.sol";
|
|
8
8
|
import {IJBController} from "@bananapus/core-v6/src/interfaces/IJBController.sol";
|
|
9
|
+
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
9
10
|
import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
|
|
10
11
|
import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol";
|
|
11
12
|
import {IJBRulesetDataHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetDataHook.sol";
|
|
@@ -29,6 +30,7 @@ import {JBDeployerHookConfig} from "./structs/JBDeployerHookConfig.sol";
|
|
|
29
30
|
import {JBOmnichain721Config} from "./structs/JBOmnichain721Config.sol";
|
|
30
31
|
import {JBSuckerDeploymentConfig} from "./structs/JBSuckerDeploymentConfig.sol";
|
|
31
32
|
import {JBTiered721HookConfig} from "./structs/JBTiered721HookConfig.sol";
|
|
33
|
+
import {mulDiv} from "@prb/math/src/Common.sol";
|
|
32
34
|
|
|
33
35
|
/// @notice Deploys, manages, and operates Juicebox projects with suckers.
|
|
34
36
|
// Project NFTs sent to this contract are not recoverable. The deployer does not
|
|
@@ -79,6 +81,10 @@ contract JBOmnichainDeployer is
|
|
|
79
81
|
/// @notice Deploys and tracks suckers for projects.
|
|
80
82
|
IJBSuckerRegistry public immutable SUCKER_REGISTRY;
|
|
81
83
|
|
|
84
|
+
/// @notice The directory used to validate controllers. Stored as immutable to prevent a user-provided
|
|
85
|
+
/// controller from returning a fake directory that confirms itself.
|
|
86
|
+
IJBDirectory public immutable DIRECTORY;
|
|
87
|
+
|
|
82
88
|
//*********************************************************************//
|
|
83
89
|
// -------------------- internal stored properties ------------------- //
|
|
84
90
|
//*********************************************************************//
|
|
@@ -101,12 +107,14 @@ contract JBOmnichainDeployer is
|
|
|
101
107
|
/// @param hookDeployer The deployer to use for project's tiered ERC-721 hooks.
|
|
102
108
|
/// @param permissions The permissions to use for the contract.
|
|
103
109
|
/// @param projects The projects to use for the contract.
|
|
110
|
+
/// @param directory The directory used to validate controllers against a trusted source.
|
|
104
111
|
/// @param trustedForwarder The trusted forwarder for the ERC2771Context.
|
|
105
112
|
constructor(
|
|
106
113
|
IJBSuckerRegistry suckerRegistry,
|
|
107
114
|
IJB721TiersHookDeployer hookDeployer,
|
|
108
115
|
IJBPermissions permissions,
|
|
109
116
|
IJBProjects projects,
|
|
117
|
+
IJBDirectory directory,
|
|
110
118
|
address trustedForwarder
|
|
111
119
|
)
|
|
112
120
|
JBPermissioned(permissions)
|
|
@@ -115,6 +123,7 @@ contract JBOmnichainDeployer is
|
|
|
115
123
|
PROJECTS = projects;
|
|
116
124
|
SUCKER_REGISTRY = suckerRegistry;
|
|
117
125
|
HOOK_DEPLOYER = hookDeployer;
|
|
126
|
+
DIRECTORY = directory;
|
|
118
127
|
|
|
119
128
|
// Give the sucker registry permission to map tokens for all revnets.
|
|
120
129
|
uint8[] memory permissionIds = new uint8[](1);
|
|
@@ -426,10 +435,10 @@ contract JBOmnichainDeployer is
|
|
|
426
435
|
// If a 721 hook is set and opted into cash out handling, let it adjust the cash out parameters.
|
|
427
436
|
if (address(tiered721Config.hook) != address(0) && tiered721Config.useDataHookForCashOut) {
|
|
428
437
|
// Forward to the 721 hook. It may change the tax rate, count, and return hook specs.
|
|
429
|
-
//
|
|
430
|
-
//
|
|
438
|
+
// Capture the 721 hook's totalSupply and effectiveSurplusValue — NFT cash-outs should use
|
|
439
|
+
// local-only denominators so holders reclaim against local surplus, not omnichain surplus.
|
|
431
440
|
// slither-disable-next-line unused-return
|
|
432
|
-
(cashOutTaxRate, cashOutCount
|
|
441
|
+
(cashOutTaxRate, cashOutCount, totalSupply, effectiveSurplusValue, tiered721HookSpecifications) =
|
|
433
442
|
IJBRulesetDataHook(address(tiered721Config.hook)).beforeCashOutRecordedWith(context);
|
|
434
443
|
}
|
|
435
444
|
|
|
@@ -444,16 +453,23 @@ contract JBOmnichainDeployer is
|
|
|
444
453
|
// Build a mutable copy of the context with the latest values (possibly updated by the 721 hook).
|
|
445
454
|
JBBeforeCashOutRecordedContext memory hookContext = context;
|
|
446
455
|
hookContext.cashOutTaxRate = cashOutTaxRate;
|
|
447
|
-
hookContext.cashOutCount = cashOutCount;
|
|
448
456
|
hookContext.totalSupply = totalSupply;
|
|
449
457
|
hookContext.surplus.value = effectiveSurplusValue;
|
|
450
458
|
|
|
451
|
-
// Forward to the extra hook. It may further change the tax rate
|
|
452
|
-
// We discard
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
(
|
|
456
|
-
|
|
459
|
+
// Forward to the extra hook. It may further change the tax rate and return hook specs.
|
|
460
|
+
// We always discard totalSupply and effectiveSurplusValue — this contract computes
|
|
461
|
+
// cross-chain values for both. When the 721 hook is active, we also discard cashOutCount
|
|
462
|
+
// because the 721 hook redefines both cashOutCount and totalSupply as NFT cash-out weights
|
|
463
|
+
// (sum of tier prices), not fungible token counts. Letting the extra hook override
|
|
464
|
+
// cashOutCount would corrupt NFT pricing in the bonding curve.
|
|
465
|
+
if (address(tiered721Config.hook) != address(0) && tiered721Config.useDataHookForCashOut) {
|
|
466
|
+
// slither-disable-next-line unused-return
|
|
467
|
+
(cashOutTaxRate,,,, extraHookSpecifications) = extraHook.dataHook.beforeCashOutRecordedWith(hookContext);
|
|
468
|
+
} else {
|
|
469
|
+
// slither-disable-next-line unused-return
|
|
470
|
+
(cashOutTaxRate, cashOutCount,,, extraHookSpecifications) =
|
|
471
|
+
extraHook.dataHook.beforeCashOutRecordedWith(hookContext);
|
|
472
|
+
}
|
|
457
473
|
}
|
|
458
474
|
|
|
459
475
|
// If neither hook returned any specifications, return the adjusted values with no hook specs.
|
|
@@ -502,6 +518,8 @@ contract JBOmnichainDeployer is
|
|
|
502
518
|
bool hasTiered721Spec;
|
|
503
519
|
// The weight returned by the 721 hook (already scaled for splits).
|
|
504
520
|
uint256 tiered721Weight;
|
|
521
|
+
// The weight attributable to tier splits when issueTokensForSplits is true.
|
|
522
|
+
uint256 splitCreditWeight;
|
|
505
523
|
// Whether a 721 hook is configured for this project's ruleset.
|
|
506
524
|
bool has721Hook;
|
|
507
525
|
if (address(tiered721Config.hook) != address(0)) {
|
|
@@ -518,6 +536,14 @@ contract JBOmnichainDeployer is
|
|
|
518
536
|
hasTiered721Spec = true;
|
|
519
537
|
tiered721HookSpec = tiered721HookSpecs[0];
|
|
520
538
|
totalSplitAmount = tiered721HookSpec.amount;
|
|
539
|
+
|
|
540
|
+
// Decode splitCreditWeight from the 721 hook's metadata (4th field).
|
|
541
|
+
// When issueTokensForSplits is true and splits exist, this holds the weight portion
|
|
542
|
+
// attributable to tier splits — used to prevent split credit erasure if the extra
|
|
543
|
+
// hook (e.g. buyback) returns weight=0.
|
|
544
|
+
if (tiered721HookSpec.metadata.length >= 128) {
|
|
545
|
+
(,,, splitCreditWeight) = abi.decode(tiered721HookSpec.metadata, (address, address, bytes, uint256));
|
|
546
|
+
}
|
|
521
547
|
}
|
|
522
548
|
}
|
|
523
549
|
|
|
@@ -533,11 +559,27 @@ contract JBOmnichainDeployer is
|
|
|
533
559
|
if (address(extraHook.dataHook) != address(0) && extraHook.useDataHookForPay) {
|
|
534
560
|
JBBeforePayRecordedContext memory hookContext = context;
|
|
535
561
|
hookContext.amount.value = projectAmount;
|
|
536
|
-
// Pass the
|
|
537
|
-
//
|
|
538
|
-
|
|
562
|
+
// Pass the original context.weight — NOT the 721 hook's split-adjusted weight.
|
|
563
|
+
// The extra hook (e.g. buyback) applies its own weight logic; using the 721 hook's
|
|
564
|
+
// already-split-adjusted weight would double-discount the split ratio.
|
|
539
565
|
(weight, dataHookSpecs) = extraHook.dataHook.beforePayRecordedWith(hookContext);
|
|
540
566
|
customHookCalled = true;
|
|
567
|
+
|
|
568
|
+
// The custom hook (e.g. buyback) returned a weight based on the original context.weight.
|
|
569
|
+
// If the 721 hook scaled weight down for tier splits, apply the same ratio so the terminal
|
|
570
|
+
// doesn't over-mint tokens relative to the funds actually entering the project.
|
|
571
|
+
// When issueTokensForSplits is true, tiered721Weight == context.weight and the ratio is 1x.
|
|
572
|
+
if (has721Hook && context.weight > 0 && tiered721Weight != context.weight) {
|
|
573
|
+
weight = mulDiv(weight, tiered721Weight, context.weight);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// When the extra hook returns weight=0 (e.g. buyback found no profitable swap) but tier
|
|
577
|
+
// splits exist with issueTokensForSplits=true, the split credit must still mint fungible tokens.
|
|
578
|
+
// The split credit weight is independent of buyback routing — it represents the token issuance
|
|
579
|
+
// for funds forwarded to tier split beneficiaries.
|
|
580
|
+
if (weight == 0 && splitCreditWeight > 0) {
|
|
581
|
+
weight = splitCreditWeight;
|
|
582
|
+
}
|
|
541
583
|
}
|
|
542
584
|
}
|
|
543
585
|
|
|
@@ -715,8 +757,9 @@ contract JBOmnichainDeployer is
|
|
|
715
757
|
});
|
|
716
758
|
}
|
|
717
759
|
|
|
718
|
-
// Transfer ownership of the project to the owner.
|
|
719
|
-
|
|
760
|
+
// Transfer ownership of the project to the owner. Uses safeTransferFrom so contract receivers
|
|
761
|
+
// get an onERC721Received callback.
|
|
762
|
+
PROJECTS.safeTransferFrom({from: address(this), to: owner, tokenId: projectId});
|
|
720
763
|
}
|
|
721
764
|
|
|
722
765
|
/// @notice Internal implementation of `launchRulesetsFor`.
|
|
@@ -804,6 +847,9 @@ contract JBOmnichainDeployer is
|
|
|
804
847
|
{
|
|
805
848
|
// First try the latest queued ruleset — if it's been explicitly approved
|
|
806
849
|
// (or has no approval hook), its hook config should take precedence.
|
|
850
|
+
// Conservative: only use Approved or Empty status. ApprovalExpected is intentionally
|
|
851
|
+
// excluded because hook selection is irreversible — if the pending ruleset is later rejected
|
|
852
|
+
// by the approval hook, we'd have locked in a hook from a ruleset that never became active.
|
|
807
853
|
(JBRuleset memory latestQueued, JBApprovalStatus approvalStatus) =
|
|
808
854
|
controller.RULESETS().latestQueuedOf(projectId);
|
|
809
855
|
if (
|
|
@@ -920,13 +966,12 @@ contract JBOmnichainDeployer is
|
|
|
920
966
|
}
|
|
921
967
|
|
|
922
968
|
/// @notice Validates that the provided controller matches the project's controller in the directory.
|
|
923
|
-
/// @dev
|
|
924
|
-
///
|
|
925
|
-
/// malicious controller from being passed in.
|
|
969
|
+
/// @dev Uses the immutable DIRECTORY instead of querying the controller, preventing a malicious
|
|
970
|
+
/// controller from returning a fake directory that confirms itself.
|
|
926
971
|
/// @param projectId The ID of the project to validate the controller for.
|
|
927
972
|
/// @param controller The controller to validate.
|
|
928
973
|
function _validateController(uint256 projectId, IJBController controller) internal view {
|
|
929
|
-
address current = address(
|
|
974
|
+
address current = address(DIRECTORY.controllerOf(projectId));
|
|
930
975
|
// Allow address(0) for fresh projects that haven't launched rulesets yet.
|
|
931
976
|
if (current != address(0) && current != address(controller)) {
|
|
932
977
|
revert JBOmnichainDeployer_ControllerMismatch();
|
|
@@ -42,6 +42,7 @@ contract TestJBOmnichainDeployer is Test {
|
|
|
42
42
|
IJBProjects projects = IJBProjects(makeAddr("projects"));
|
|
43
43
|
IJBSuckerRegistry suckerRegistry = IJBSuckerRegistry(makeAddr("suckerRegistry"));
|
|
44
44
|
IJB721TiersHookDeployer hookDeployer = IJB721TiersHookDeployer(makeAddr("hookDeployer"));
|
|
45
|
+
IJBDirectory directory = IJBDirectory(makeAddr("directory"));
|
|
45
46
|
|
|
46
47
|
address projectOwner = makeAddr("projectOwner");
|
|
47
48
|
address sucker = makeAddr("sucker");
|
|
@@ -63,6 +64,7 @@ contract TestJBOmnichainDeployer is Test {
|
|
|
63
64
|
hookDeployer,
|
|
64
65
|
permissions,
|
|
65
66
|
projects,
|
|
67
|
+
directory,
|
|
66
68
|
address(0) // trustedForwarder
|
|
67
69
|
);
|
|
68
70
|
|
|
@@ -331,11 +333,7 @@ contract TestJBOmnichainDeployer is Test {
|
|
|
331
333
|
|
|
332
334
|
function test_launchRulesetsFor_simplified_usesDefaultCurrency() public {
|
|
333
335
|
IJBController controller = IJBController(makeAddr("controller"));
|
|
334
|
-
IJBDirectory directory = IJBDirectory(makeAddr("directory"));
|
|
335
336
|
|
|
336
|
-
vm.mockCall(
|
|
337
|
-
address(controller), abi.encodeWithSelector(IJBController.DIRECTORY.selector), abi.encode(directory)
|
|
338
|
-
);
|
|
339
337
|
vm.mockCall(
|
|
340
338
|
address(directory),
|
|
341
339
|
abi.encodeWithSelector(IJBDirectory.controllerOf.selector, projectId),
|
|
@@ -369,7 +367,6 @@ contract TestJBOmnichainDeployer is Test {
|
|
|
369
367
|
function test_queueRulesetsOf_simplified_carriesForwardHook() public {
|
|
370
368
|
// First launch a project so there's a hook to carry forward.
|
|
371
369
|
IJBController controller = IJBController(makeAddr("controller"));
|
|
372
|
-
IJBDirectory directory = IJBDirectory(makeAddr("directory"));
|
|
373
370
|
IJBRulesets rulesets = IJBRulesets(makeAddr("rulesets"));
|
|
374
371
|
|
|
375
372
|
vm.mockCall(address(projects), abi.encodeWithSelector(IJBProjects.count.selector), abi.encode(uint256(41)));
|
|
@@ -391,9 +388,6 @@ contract TestJBOmnichainDeployer is Test {
|
|
|
391
388
|
deployer.launchProjectFor(projectOwner, "test", configs, terminals, "", _emptySuckerConfig(), controller);
|
|
392
389
|
|
|
393
390
|
// Now set up mocks for queueRulesetsOf.
|
|
394
|
-
vm.mockCall(
|
|
395
|
-
address(controller), abi.encodeWithSelector(IJBController.DIRECTORY.selector), abi.encode(directory)
|
|
396
|
-
);
|
|
397
391
|
vm.mockCall(
|
|
398
392
|
address(directory),
|
|
399
393
|
abi.encodeWithSelector(IJBDirectory.controllerOf.selector, projectId),
|
|
@@ -462,7 +456,6 @@ contract TestJBOmnichainDeployer is Test {
|
|
|
462
456
|
function test_queueRulesetsOf_carryForward_preservesCashOutFlag() public {
|
|
463
457
|
// --- Step 1: Launch a project with useDataHookForCashOut = true ---
|
|
464
458
|
IJBController controller = IJBController(makeAddr("controller"));
|
|
465
|
-
IJBDirectory directory = IJBDirectory(makeAddr("directory"));
|
|
466
459
|
IJBRulesets rulesets = IJBRulesets(makeAddr("rulesets"));
|
|
467
460
|
|
|
468
461
|
vm.mockCall(address(projects), abi.encodeWithSelector(IJBProjects.count.selector), abi.encode(uint256(41)));
|
|
@@ -495,9 +488,6 @@ contract TestJBOmnichainDeployer is Test {
|
|
|
495
488
|
assertTrue(initialCashOutFlag, "initial launch should store useDataHookForCashOut = true");
|
|
496
489
|
|
|
497
490
|
// --- Step 2: Queue a new ruleset with no tiers (carry-forward) ---
|
|
498
|
-
vm.mockCall(
|
|
499
|
-
address(controller), abi.encodeWithSelector(IJBController.DIRECTORY.selector), abi.encode(directory)
|
|
500
|
-
);
|
|
501
491
|
vm.mockCall(
|
|
502
492
|
address(directory),
|
|
503
493
|
abi.encodeWithSelector(IJBDirectory.controllerOf.selector, projectId),
|
|
@@ -126,6 +126,7 @@ contract JBOmnichainDeployerGuardTest is TestBaseWorkflow {
|
|
|
126
126
|
IJB721TiersHookDeployer(mockHookDeployerAddr),
|
|
127
127
|
IJBPermissions(address(jbPermissions())),
|
|
128
128
|
IJBProjects(address(jbProjects())),
|
|
129
|
+
IJBDirectory(address(jbDirectory())),
|
|
129
130
|
trustedForwarder()
|
|
130
131
|
);
|
|
131
132
|
|
|
@@ -262,11 +263,11 @@ contract JBOmnichainDeployerGuardTest is TestBaseWorkflow {
|
|
|
262
263
|
.setPermissionsFor(
|
|
263
264
|
owner,
|
|
264
265
|
JBPermissionsData({
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
266
|
+
operator: address(deployer),
|
|
267
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
268
|
+
projectId: uint64(projectId),
|
|
269
|
+
permissionIds: permissionIds
|
|
270
|
+
})
|
|
270
271
|
);
|
|
271
272
|
}
|
|
272
273
|
|
|
@@ -302,9 +303,10 @@ contract JBOmnichainDeployerGuardTest is TestBaseWorkflow {
|
|
|
302
303
|
|
|
303
304
|
JBRulesetConfig[] memory rulesets = _makeRulesetConfigs(1);
|
|
304
305
|
|
|
305
|
-
// Should succeed without reverting.
|
|
306
306
|
JBOmnichain721Config memory empty721;
|
|
307
|
-
|
|
307
|
+
(uint256 rulesetId,) =
|
|
308
|
+
deployer.queueRulesetsOf(projectId, empty721, rulesets, "queue", IJBController(address(jbController())));
|
|
309
|
+
assertGt(rulesetId, 0, "Queued ruleset ID must be non-zero");
|
|
308
310
|
}
|
|
309
311
|
|
|
310
312
|
/// @notice Queue rulesets reverts when called in the same block as launch
|
|
@@ -362,6 +364,41 @@ contract JBOmnichainDeployerGuardTest is TestBaseWorkflow {
|
|
|
362
364
|
|
|
363
365
|
// Now should succeed.
|
|
364
366
|
JBOmnichain721Config memory empty721b;
|
|
365
|
-
|
|
367
|
+
(uint256 rulesetId,) =
|
|
368
|
+
deployer.queueRulesetsOf(projectId, empty721b, rulesets, "ok-now", IJBController(address(jbController())));
|
|
369
|
+
assertGt(rulesetId, 0, "Queued ruleset ID must be non-zero after warping past conflict");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/// @notice When no new tiers are provided and no latestQueued exists (single launch ruleset that
|
|
373
|
+
/// became the current ruleset), the hook is carried forward from the current ruleset (lines 862-864).
|
|
374
|
+
function test_queueRulesetsOf_carriesForwardFromCurrent_whenNoLatestQueued() public {
|
|
375
|
+
// Launch with 1 ruleset — this deploys a 721 hook stored for the launch ruleset.
|
|
376
|
+
uint256 projectId = _launchProject(1);
|
|
377
|
+
uint256 launchRulesetId = block.timestamp;
|
|
378
|
+
_grantDeployerQueuePermission(projectId);
|
|
379
|
+
|
|
380
|
+
// Verify the 721 hook was stored for the launch ruleset.
|
|
381
|
+
(IJB721TiersHook launchHook,) = deployer.tiered721HookOf(projectId, launchRulesetId);
|
|
382
|
+
assertEq(address(launchHook), mockHookAddr, "Launch ruleset should have 721 hook stored");
|
|
383
|
+
|
|
384
|
+
// Warp forward so the launched ruleset is the current active one and the guard passes.
|
|
385
|
+
vm.warp(block.timestamp + 1 days);
|
|
386
|
+
|
|
387
|
+
// Queue a new ruleset with NO new tiers → triggers carry-forward logic.
|
|
388
|
+
JBRulesetConfig[] memory rulesets = _makeRulesetConfigs(1);
|
|
389
|
+
JBOmnichain721Config memory empty721;
|
|
390
|
+
(uint256 queuedRulesetId,) = deployer.queueRulesetsOf(
|
|
391
|
+
projectId, empty721, rulesets, "carry-forward", IJBController(address(jbController()))
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
assertGt(queuedRulesetId, 0, "Queued ruleset ID must be non-zero");
|
|
395
|
+
|
|
396
|
+
// Verify the hook was carried forward to the new queued ruleset.
|
|
397
|
+
(IJB721TiersHook carriedHook,) = deployer.tiered721HookOf(projectId, queuedRulesetId);
|
|
398
|
+
assertEq(
|
|
399
|
+
address(carriedHook),
|
|
400
|
+
address(launchHook),
|
|
401
|
+
"Queued ruleset should carry forward the 721 hook from the current ruleset"
|
|
402
|
+
);
|
|
366
403
|
}
|
|
367
404
|
}
|
|
@@ -4,6 +4,7 @@ pragma solidity 0.8.28;
|
|
|
4
4
|
import {Test} from "forge-std/Test.sol";
|
|
5
5
|
|
|
6
6
|
import {IJBController} from "@bananapus/core-v6/src/interfaces/IJBController.sol";
|
|
7
|
+
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
7
8
|
import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
|
|
8
9
|
import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol";
|
|
9
10
|
import {IJBRulesetDataHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetDataHook.sol";
|
|
@@ -97,6 +98,7 @@ contract OmnichainDeployerAttacks is Test {
|
|
|
97
98
|
IJBProjects projects = IJBProjects(makeAddr("projects"));
|
|
98
99
|
IJBSuckerRegistry suckerRegistry = IJBSuckerRegistry(makeAddr("suckerRegistry"));
|
|
99
100
|
IJB721TiersHookDeployer hookDeployer = IJB721TiersHookDeployer(makeAddr("hookDeployer"));
|
|
101
|
+
IJBDirectory directory = IJBDirectory(makeAddr("directory"));
|
|
100
102
|
|
|
101
103
|
address projectOwner = makeAddr("projectOwner");
|
|
102
104
|
address sucker = makeAddr("sucker");
|
|
@@ -119,7 +121,7 @@ contract OmnichainDeployerAttacks is Test {
|
|
|
119
121
|
address(permissions), abi.encodeWithSelector(IJBPermissions.setPermissionsFor.selector), abi.encode()
|
|
120
122
|
);
|
|
121
123
|
|
|
122
|
-
deployer = new JBOmnichainDeployer(suckerRegistry, hookDeployer, permissions, projects, address(0));
|
|
124
|
+
deployer = new JBOmnichainDeployer(suckerRegistry, hookDeployer, permissions, projects, directory, address(0));
|
|
123
125
|
|
|
124
126
|
// Default mocks.
|
|
125
127
|
vm.mockCall(
|