diff --git a/broadcast/DeployBundlerOnly.s.sol/84532/run-1758642627919.json b/broadcast/DeployBundlerOnly.s.sol/84532/run-1758642627919.json new file mode 100644 index 000000000..132d83da5 --- /dev/null +++ b/broadcast/DeployBundlerOnly.s.sol/84532/run-1758642627919.json @@ -0,0 +1,57 @@ +{ + "transactions": [ + { + "hash": "0x82593378e15de863bcc3eeb0f5a6fcf05e70cefa12dbf000193277129af2ff73", + "transactionType": "CREATE", + "contractName": "Bundler", + "contractAddress": "0x69db7c20cdda49bed2bfb21e16fa218330c50661", + "function": null, + "arguments": [ + "0x3411306Ce66c9469BFF1535BA955503c4Bde1C6e", + "0x492E6456D9528771018DeB9E87ef7750EF184104", + "0xC5290058841028F1614F3A6F0F5816cAd0df5E27", + "0x4A6513c898fe1B2d0E78d3b0e0A4a151589B1cBa" + ], + "transaction": { + "from": "0x00d1c1c523d0058359850f8a1e49504ef78541ce", + "gas": "0x141fdd", + "value": "0x0", + "input": "0x6101003461015f57601f6112c838819003918201601f19168301916001600160401b038311848410176101635780849260809460405283398101031261015f5780516001600160a01b0381169081810361015f576020830151906001600160a01b03821680830361015f5760408501516001600160a01b0381169590949086860361015f5760600151956001600160a01b0387169182880361015f5715928315610156575b50821561014d575b508115610144575b506101355760805260a05260c05260e0526040516111509081610178823960805181818160da015281816103470152818161054d0152818161074b0152610f19015260a05181818160920152610580015260c051818181610298015281816103c201526107c6015260e0518181816101a1015281816102dd01526108de0152f35b63ca07b5fb60e01b5f5260045ffd5b9050155f6100b4565b1591505f6100ac565b1592505f6100a4565b5f80fd5b634e487b7160e01b5f52604160045260245ffdfe60806040526004361015610011575f80fd5b5f803560e01c806307087b061461085b5780632989cf501461072d5780632d816eba1461048e57806388a01efc1461030c578063900715e1146102c7578063c6bbd5a714610282578063e2e9faa114610109578063f78a8a3e146100c45763f887ea401461007d575f80fd5b346100c157806003193601126100c1576040517f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03168152602090f35b80fd5b50346100c157806003193601126100c1576040517f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03168152602090f35b50346100c15761011836610985565b9061012594929394610b0c565b5061012f85610ee5565b909691959093916001600160801b0382161561022e575060409261019c95949261017e925b85519461016086610a9f565b8a8652151560208601526001600160801b0316858501523691610b36565b6060820152815180948192635873307360e01b835260048301610b91565b0381847f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03165af1918215610221576101eb928291906101ef575b5060405194859485610a2e565b0390f35b9050610213915060403d60401161021a575b61020b8183610ae9565b810190610b7b565b905f6101de565b503d610201565b50604051903d90823e3d90fd5b6020915001358015610273576001600160801b0381116102645761019c9493604093909261017e926001600160801b0316610154565b6369cb37cb60e01b8552600485fd5b63504fc7a960e11b8552600485fd5b50346100c157806003193601126100c1576040517f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03168152602090f35b50346100c157806003193601126100c1576040517f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03168152602090f35b50346100c15760a061033c61032036610a5d565b92906040518093819263882db70760e01b835260048301610cb9565b038186600180861b037f0000000000000000000000000000000000000000000000000000000000000000165af1908115610483578391610450575b506001600160a01b0361038c60208401610e15565b166001600160a01b039091160361044257604051635e90b82560e11b8152906103b9906004830190610e6b565b60808160a481857f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03165af19081156104375760209291610405575b50604051908152f35b610427915060803d608011610430575b61041f8183610ae9565b810190610e29565b5050505f6103fc565b503d610415565b6040513d84823e3d90fd5b6231010160e51b8252600482fd5b610472915060a03d60a01161047c575b61046a8183610ae9565b810190610c09565b505050505f610377565b503d610460565b6040513d85823e3d90fd5b506060366003190112610704576004356001600160401b038111610704576101a06003198236030112610704576024356001600160401b038111610704576104da903690600401610958565b604435916001600160401b0383116107045736602384011215610704576004830135926001600160401b03841161070457602481018460051b9160248336920101116107045760405163882db70760e01b81529460a086806105426004808c01908301610cb9565b03815f600180861b037f0000000000000000000000000000000000000000000000000000000000000000165af19586156106b9575f96610708575b507f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031693479390853b15610704579593916105de604096949296519788966324856bc360e01b8852604060048901526044880191610c99565b60031986820301602487015281815260208082019782010196835f925b8484106106c4575050505050508290815f9503925af180156106b9576106a4575b50824780610684575b50509060449161063530826110cd565b80610672575b5050016106503061064b83610e15565b6110cd565b80610659578280f35b61066561066c92610e15565b33906110f2565b5f808280f35b61067d9133906110f2565b5f8061063b565b81803892335af11561069757825f610625565b63b12d13eb83526004601cfd5b6106b19193505f90610ae9565b5f915f61061c565b6040513d5f823e3d90fd5b9193959750919395976020806106ef600193601f198682030188526106e98b88610c67565b90610c99565b980194019401918997959394919896986105fb565b5f80fd5b61072291965060a03d60a01161047c5761046a8183610ae9565b50505050945f61057d565b346107045760a061074061032036610a5d565b03815f600180861b037f0000000000000000000000000000000000000000000000000000000000000000165af19081156106b9575f91610838575b506001600160a01b0361079060208401610e15565b166001600160a01b039091160361082a57604051636352813560e11b8152906107bd906004830190610e6b565b60808160a4815f7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03165af180156106b9576020915f916108085750604051908152f35b610821915060803d6080116104305761041f8183610ae9565b505050826103fc565b6231010160e51b5f5260045ffd5b610851915060a03d60a01161047c5761046a8183610ae9565b505050508261077b565b346107045761086936610985565b91610872610b0c565b506001600160801b0316928315610949576108d9916108bb610895604093610ee5565b93919690978551946108a686610a9f565b89865215156020860152858501523691610b36565b606082015281518093819263aa9d21cb60e01b835260048301610b91565b03815f7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03165af180156106b9576101eb915f905f92610927575060405194859485610a2e565b9050610942915060403d60401161021a5761020b8183610ae9565b90856101de565b63504fc7a960e11b5f5260045ffd5b9181601f84011215610704578235916001600160401b038311610704576020838186019501011161070457565b6060600319820112610704576004356001600160401b038111610704576101a0818303600319011261070457600401916024356001600160801b03811681036107045791604435906001600160401b038211610704576109e791600401610958565b9091565b80516001600160a01b03908116835260208083015182169084015260408083015162ffffff169084015260608083015160020b9084015260809182015116910152565b6001600160a01b039091168152610100810194939260e092610a549060208401906109eb565b60c08201520152565b9060c0600319830112610704576004356001600160401b038111610704576101a081840360031901126107045760a09060040192602319011261070457602490565b608081019081106001600160401b03821117610aba57604052565b634e487b7160e01b5f52604160045260245ffd5b60a081019081106001600160401b03821117610aba57604052565b601f909101601f19168101906001600160401b03821190821017610aba57604052565b60405190610b1982610ace565b5f6080838281528260208201528260408201528260608201520152565b9192916001600160401b038211610aba5760405191610b5f601f8201601f191660200184610ae9565b829481845281830111610704578281602093845f960137010152565b9190826040910312610704576020825192015190565b6020606061014093828452610ba983850182516109eb565b80830151151560c085015260408101516001600160801b031660e085015201516101008084015280516101208401819052918291018484015e5f828201840152601f01601f1916010190565b51906001600160a01b038216820361070457565b908160a091031261070457610c1d81610bf5565b91610c2a60208301610bf5565b91610c3760408201610bf5565b91610c506080610c4960608501610bf5565b9301610bf5565b90565b35906001600160a01b038216820361070457565b9035601e1982360301811215610704570160208101919035906001600160401b03821161070457813603831361070457565b908060209392818452848401375f828201840152601f01601f1916010190565b60208152813560208201526020820135604082015260018060a01b03610ce160408401610c53565b166060820152606082013560018060a01b038116809103610704576080820152610d24610d116080840184610c67565b6101a060a08501526101c0840191610c99565b60a08301356001600160a01b038116919082900361070457610d649160c0840152610d5260c0850185610c67565b848303601f190160e086015290610c99565b60e08301356001600160a01b038116919082900361070457610da791610100840152610d94610100850185610c67565b848303601f190161012086015290610c99565b6101208301356001600160a01b038116919082900361070457610df2610180916101a093610140860152610ddf610140870187610c67565b868303601f190161016088015290610c99565b936001600160a01b03610e086101608301610c53565b1682850152013591015290565b356001600160a01b03811681036107045790565b919082608091031261070457815160208301519092906001600160a01b03811681036107045791604082015163ffffffff811681036107045760609092015190565b6001600160a01b03610e7c82610c53565b1682526001600160a01b03610e9360208301610c53565b1660208301526040810135604083015260608101359062ffffff821680920361070457608091606084015201359060018060a01b0382168092036107045760800152565b51908160020b820361070457565b90610eee610b0c565b5060405163882db70760e01b81529060a08280610f0e8660048301610cb9565b03815f600180861b037f0000000000000000000000000000000000000000000000000000000000000000165af19182156106b9575f926110a8575b5060e0829301359160018060a01b03831680930361070457604051631bab58f560e01b81526001600160a01b03909116600482018190529261010090829060249082905afa9081156106b9575f91610fda575b508051602082015191936001600160a01b03928316929091168103610fc15750505f90565b03610fcb57600190565b630a55ad8b60e41b5f5260045ffd5b8091506101003d81116110a1575b610ff28183610ae9565b81010361010081126107045761100782610bf5565b506004602083015110156107045760a090603f190112610704576040519061102e82610ace565b61103a60408201610bf5565b825261104860608201610bf5565b6020830152608081015162ffffff8116810361070457604083015261106f60a08201610ed7565b606083015260c08101516001600160a01b038116810361070457608083015261109a9060e001610ed7565b505f610f9c565b503d610fe8565b6110c291925060a03d60a01161047c5761046a8183610ae9565b50505050905f610f49565b602460106020939284936014526370a0823160601b5f525afa601f3d11166020510290565b919060145260345263a9059cbb60601b5f5260205f6044601082855af1908160015f51141615611125575b50505f603452565b3b153d171015611136575f8061111d565b6390b8ec185f526004601cfdfea164736f6c634300081a000a0000000000000000000000003411306ce66c9469bff1535ba955503c4bde1c6e000000000000000000000000492e6456d9528771018deb9e87ef7750ef184104000000000000000000000000c5290058841028f1614f3a6f0f5816cad0df5e270000000000000000000000004a6513c898fe1b2d0e78d3b0e0a4a151589b1cba", + "nonce": "0x7", + "chainId": "0x14a34" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "status": "0x1", + "cumulativeGasUsed": "0x451cb9", + "logs": [], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "type": "0x2", + "transactionHash": "0x82593378e15de863bcc3eeb0f5a6fcf05e70cefa12dbf000193277129af2ff73", + "transactionIndex": "0xa", + "blockHash": "0xa59328087a52490886036aa22ac9b3213721bc6291192d16f2c25dd4967f85ff", + "blockNumber": "0x1dfb167", + "gasUsed": "0xf7af9", + "effectiveGasPrice": "0xf4ee1", + "from": "0x00d1c1c523d0058359850f8a1e49504ef78541ce", + "to": null, + "contractAddress": "0x69db7c20cdda49bed2bfb21e16fa218330c50661", + "l1BaseFeeScalar": "0x44d", + "l1BlobBaseFee": "0x3", + "l1BlobBaseFeeScalar": "0xa118b", + "l1Fee": "0x6b94", + "l1GasPrice": "0x1c7", + "l1GasUsed": "0xac38" + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1758642627919, + "chain": 84532, + "commit": "204d121" +} \ No newline at end of file diff --git a/broadcast/DeployBundlerOnly.s.sol/84532/run-latest.json b/broadcast/DeployBundlerOnly.s.sol/84532/run-latest.json new file mode 100644 index 000000000..132d83da5 --- /dev/null +++ b/broadcast/DeployBundlerOnly.s.sol/84532/run-latest.json @@ -0,0 +1,57 @@ +{ + "transactions": [ + { + "hash": "0x82593378e15de863bcc3eeb0f5a6fcf05e70cefa12dbf000193277129af2ff73", + "transactionType": "CREATE", + "contractName": "Bundler", + "contractAddress": "0x69db7c20cdda49bed2bfb21e16fa218330c50661", + "function": null, + "arguments": [ + "0x3411306Ce66c9469BFF1535BA955503c4Bde1C6e", + "0x492E6456D9528771018DeB9E87ef7750EF184104", + "0xC5290058841028F1614F3A6F0F5816cAd0df5E27", + "0x4A6513c898fe1B2d0E78d3b0e0A4a151589B1cBa" + ], + "transaction": { + "from": "0x00d1c1c523d0058359850f8a1e49504ef78541ce", + "gas": "0x141fdd", + "value": "0x0", + "input": "0x6101003461015f57601f6112c838819003918201601f19168301916001600160401b038311848410176101635780849260809460405283398101031261015f5780516001600160a01b0381169081810361015f576020830151906001600160a01b03821680830361015f5760408501516001600160a01b0381169590949086860361015f5760600151956001600160a01b0387169182880361015f5715928315610156575b50821561014d575b508115610144575b506101355760805260a05260c05260e0526040516111509081610178823960805181818160da015281816103470152818161054d0152818161074b0152610f19015260a05181818160920152610580015260c051818181610298015281816103c201526107c6015260e0518181816101a1015281816102dd01526108de0152f35b63ca07b5fb60e01b5f5260045ffd5b9050155f6100b4565b1591505f6100ac565b1592505f6100a4565b5f80fd5b634e487b7160e01b5f52604160045260245ffdfe60806040526004361015610011575f80fd5b5f803560e01c806307087b061461085b5780632989cf501461072d5780632d816eba1461048e57806388a01efc1461030c578063900715e1146102c7578063c6bbd5a714610282578063e2e9faa114610109578063f78a8a3e146100c45763f887ea401461007d575f80fd5b346100c157806003193601126100c1576040517f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03168152602090f35b80fd5b50346100c157806003193601126100c1576040517f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03168152602090f35b50346100c15761011836610985565b9061012594929394610b0c565b5061012f85610ee5565b909691959093916001600160801b0382161561022e575060409261019c95949261017e925b85519461016086610a9f565b8a8652151560208601526001600160801b0316858501523691610b36565b6060820152815180948192635873307360e01b835260048301610b91565b0381847f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03165af1918215610221576101eb928291906101ef575b5060405194859485610a2e565b0390f35b9050610213915060403d60401161021a575b61020b8183610ae9565b810190610b7b565b905f6101de565b503d610201565b50604051903d90823e3d90fd5b6020915001358015610273576001600160801b0381116102645761019c9493604093909261017e926001600160801b0316610154565b6369cb37cb60e01b8552600485fd5b63504fc7a960e11b8552600485fd5b50346100c157806003193601126100c1576040517f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03168152602090f35b50346100c157806003193601126100c1576040517f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03168152602090f35b50346100c15760a061033c61032036610a5d565b92906040518093819263882db70760e01b835260048301610cb9565b038186600180861b037f0000000000000000000000000000000000000000000000000000000000000000165af1908115610483578391610450575b506001600160a01b0361038c60208401610e15565b166001600160a01b039091160361044257604051635e90b82560e11b8152906103b9906004830190610e6b565b60808160a481857f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03165af19081156104375760209291610405575b50604051908152f35b610427915060803d608011610430575b61041f8183610ae9565b810190610e29565b5050505f6103fc565b503d610415565b6040513d84823e3d90fd5b6231010160e51b8252600482fd5b610472915060a03d60a01161047c575b61046a8183610ae9565b810190610c09565b505050505f610377565b503d610460565b6040513d85823e3d90fd5b506060366003190112610704576004356001600160401b038111610704576101a06003198236030112610704576024356001600160401b038111610704576104da903690600401610958565b604435916001600160401b0383116107045736602384011215610704576004830135926001600160401b03841161070457602481018460051b9160248336920101116107045760405163882db70760e01b81529460a086806105426004808c01908301610cb9565b03815f600180861b037f0000000000000000000000000000000000000000000000000000000000000000165af19586156106b9575f96610708575b507f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031693479390853b15610704579593916105de604096949296519788966324856bc360e01b8852604060048901526044880191610c99565b60031986820301602487015281815260208082019782010196835f925b8484106106c4575050505050508290815f9503925af180156106b9576106a4575b50824780610684575b50509060449161063530826110cd565b80610672575b5050016106503061064b83610e15565b6110cd565b80610659578280f35b61066561066c92610e15565b33906110f2565b5f808280f35b61067d9133906110f2565b5f8061063b565b81803892335af11561069757825f610625565b63b12d13eb83526004601cfd5b6106b19193505f90610ae9565b5f915f61061c565b6040513d5f823e3d90fd5b9193959750919395976020806106ef600193601f198682030188526106e98b88610c67565b90610c99565b980194019401918997959394919896986105fb565b5f80fd5b61072291965060a03d60a01161047c5761046a8183610ae9565b50505050945f61057d565b346107045760a061074061032036610a5d565b03815f600180861b037f0000000000000000000000000000000000000000000000000000000000000000165af19081156106b9575f91610838575b506001600160a01b0361079060208401610e15565b166001600160a01b039091160361082a57604051636352813560e11b8152906107bd906004830190610e6b565b60808160a4815f7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03165af180156106b9576020915f916108085750604051908152f35b610821915060803d6080116104305761041f8183610ae9565b505050826103fc565b6231010160e51b5f5260045ffd5b610851915060a03d60a01161047c5761046a8183610ae9565b505050508261077b565b346107045761086936610985565b91610872610b0c565b506001600160801b0316928315610949576108d9916108bb610895604093610ee5565b93919690978551946108a686610a9f565b89865215156020860152858501523691610b36565b606082015281518093819263aa9d21cb60e01b835260048301610b91565b03815f7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03165af180156106b9576101eb915f905f92610927575060405194859485610a2e565b9050610942915060403d60401161021a5761020b8183610ae9565b90856101de565b63504fc7a960e11b5f5260045ffd5b9181601f84011215610704578235916001600160401b038311610704576020838186019501011161070457565b6060600319820112610704576004356001600160401b038111610704576101a0818303600319011261070457600401916024356001600160801b03811681036107045791604435906001600160401b038211610704576109e791600401610958565b9091565b80516001600160a01b03908116835260208083015182169084015260408083015162ffffff169084015260608083015160020b9084015260809182015116910152565b6001600160a01b039091168152610100810194939260e092610a549060208401906109eb565b60c08201520152565b9060c0600319830112610704576004356001600160401b038111610704576101a081840360031901126107045760a09060040192602319011261070457602490565b608081019081106001600160401b03821117610aba57604052565b634e487b7160e01b5f52604160045260245ffd5b60a081019081106001600160401b03821117610aba57604052565b601f909101601f19168101906001600160401b03821190821017610aba57604052565b60405190610b1982610ace565b5f6080838281528260208201528260408201528260608201520152565b9192916001600160401b038211610aba5760405191610b5f601f8201601f191660200184610ae9565b829481845281830111610704578281602093845f960137010152565b9190826040910312610704576020825192015190565b6020606061014093828452610ba983850182516109eb565b80830151151560c085015260408101516001600160801b031660e085015201516101008084015280516101208401819052918291018484015e5f828201840152601f01601f1916010190565b51906001600160a01b038216820361070457565b908160a091031261070457610c1d81610bf5565b91610c2a60208301610bf5565b91610c3760408201610bf5565b91610c506080610c4960608501610bf5565b9301610bf5565b90565b35906001600160a01b038216820361070457565b9035601e1982360301811215610704570160208101919035906001600160401b03821161070457813603831361070457565b908060209392818452848401375f828201840152601f01601f1916010190565b60208152813560208201526020820135604082015260018060a01b03610ce160408401610c53565b166060820152606082013560018060a01b038116809103610704576080820152610d24610d116080840184610c67565b6101a060a08501526101c0840191610c99565b60a08301356001600160a01b038116919082900361070457610d649160c0840152610d5260c0850185610c67565b848303601f190160e086015290610c99565b60e08301356001600160a01b038116919082900361070457610da791610100840152610d94610100850185610c67565b848303601f190161012086015290610c99565b6101208301356001600160a01b038116919082900361070457610df2610180916101a093610140860152610ddf610140870187610c67565b868303601f190161016088015290610c99565b936001600160a01b03610e086101608301610c53565b1682850152013591015290565b356001600160a01b03811681036107045790565b919082608091031261070457815160208301519092906001600160a01b03811681036107045791604082015163ffffffff811681036107045760609092015190565b6001600160a01b03610e7c82610c53565b1682526001600160a01b03610e9360208301610c53565b1660208301526040810135604083015260608101359062ffffff821680920361070457608091606084015201359060018060a01b0382168092036107045760800152565b51908160020b820361070457565b90610eee610b0c565b5060405163882db70760e01b81529060a08280610f0e8660048301610cb9565b03815f600180861b037f0000000000000000000000000000000000000000000000000000000000000000165af19182156106b9575f926110a8575b5060e0829301359160018060a01b03831680930361070457604051631bab58f560e01b81526001600160a01b03909116600482018190529261010090829060249082905afa9081156106b9575f91610fda575b508051602082015191936001600160a01b03928316929091168103610fc15750505f90565b03610fcb57600190565b630a55ad8b60e41b5f5260045ffd5b8091506101003d81116110a1575b610ff28183610ae9565b81010361010081126107045761100782610bf5565b506004602083015110156107045760a090603f190112610704576040519061102e82610ace565b61103a60408201610bf5565b825261104860608201610bf5565b6020830152608081015162ffffff8116810361070457604083015261106f60a08201610ed7565b606083015260c08101516001600160a01b038116810361070457608083015261109a9060e001610ed7565b505f610f9c565b503d610fe8565b6110c291925060a03d60a01161047c5761046a8183610ae9565b50505050905f610f49565b602460106020939284936014526370a0823160601b5f525afa601f3d11166020510290565b919060145260345263a9059cbb60601b5f5260205f6044601082855af1908160015f51141615611125575b50505f603452565b3b153d171015611136575f8061111d565b6390b8ec185f526004601cfdfea164736f6c634300081a000a0000000000000000000000003411306ce66c9469bff1535ba955503c4bde1c6e000000000000000000000000492e6456d9528771018deb9e87ef7750ef184104000000000000000000000000c5290058841028f1614f3a6f0f5816cad0df5e270000000000000000000000004a6513c898fe1b2d0e78d3b0e0a4a151589b1cba", + "nonce": "0x7", + "chainId": "0x14a34" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "status": "0x1", + "cumulativeGasUsed": "0x451cb9", + "logs": [], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "type": "0x2", + "transactionHash": "0x82593378e15de863bcc3eeb0f5a6fcf05e70cefa12dbf000193277129af2ff73", + "transactionIndex": "0xa", + "blockHash": "0xa59328087a52490886036aa22ac9b3213721bc6291192d16f2c25dd4967f85ff", + "blockNumber": "0x1dfb167", + "gasUsed": "0xf7af9", + "effectiveGasPrice": "0xf4ee1", + "from": "0x00d1c1c523d0058359850f8a1e49504ef78541ce", + "to": null, + "contractAddress": "0x69db7c20cdda49bed2bfb21e16fa218330c50661", + "l1BaseFeeScalar": "0x44d", + "l1BlobBaseFee": "0x3", + "l1BlobBaseFeeScalar": "0xa118b", + "l1Fee": "0x6b94", + "l1GasPrice": "0x1c7", + "l1GasUsed": "0xac38" + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1758642627919, + "chain": 84532, + "commit": "204d121" +} \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index 9faf85588..2115213c2 100644 --- a/foundry.toml +++ b/foundry.toml @@ -49,6 +49,7 @@ show_solidity = true [etherscan] base = { key = "${BASESCAN_API_KEY}" } +base_sepolia = { key = "${BASESCAN_API_KEY}" } [rpc_endpoints] mainnet = "${ETH_MAINNET_RPC_URL}" diff --git a/script/deploy/Deploy.s.sol b/script/deploy/Deploy.s.sol index c46e12dc1..4e91365ee 100644 --- a/script/deploy/Deploy.s.sol +++ b/script/deploy/Deploy.s.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.24; import { UniversalRouter } from "@universal-router/UniversalRouter.sol"; import { IQuoterV2 } from "@v3-periphery/interfaces/IQuoterV2.sol"; import { IHooks, IPoolManager } from "@v4-core/interfaces/IPoolManager.sol"; +import { IV4Quoter } from "@v4-periphery/interfaces/IV4Quoter.sol"; import { IPositionManager, PositionManager } from "@v4-periphery/PositionManager.sol"; import { IStateView } from "@v4-periphery/lens/StateView.sol"; import { Script, console } from "forge-std/Script.sol"; @@ -30,6 +31,7 @@ struct ScriptData { address poolManager; address protocolOwner; address quoterV2; + address quoterV4; address uniswapV2Factory; address uniswapV2Router02; address uniswapV3Factory; @@ -198,8 +200,13 @@ abstract contract DeployScript is Script { function _deployBundler(ScriptData memory scriptData, Airlock airlock) internal returns (Bundler bundler) { require(scriptData.universalRouter != address(0), "Cannot find UniversalRouter address!"); require(scriptData.quoterV2 != address(0), "Cannot find QuoterV2 address!"); - bundler = - new Bundler(airlock, UniversalRouter(payable(scriptData.universalRouter)), IQuoterV2(scriptData.quoterV2)); + require(scriptData.quoterV4 != address(0), "Cannot find QuoterV4 address!"); + bundler = new Bundler( + airlock, + UniversalRouter(payable(scriptData.universalRouter)), + IQuoterV2(scriptData.quoterV2), + IV4Quoter(scriptData.quoterV4) + ); } function _deployLens(ScriptData memory scriptData) internal returns (DopplerLensQuoter lens) { diff --git a/script/deploy/DeployBaseSepolia.s.sol b/script/deploy/DeployBaseSepolia.s.sol index 8a36f990e..1f2f234da 100644 --- a/script/deploy/DeployBaseSepolia.s.sol +++ b/script/deploy/DeployBaseSepolia.s.sol @@ -11,6 +11,7 @@ contract DeployBaseSepolia is DeployScript { poolManager: 0x05E73354cFDd6745C338b50BcFDfA3Aa6fA03408, protocolOwner: 0xaCE07c3c1D3b556D42633211f0Da71dc6F6d1c42, quoterV2: 0xC5290058841028F1614F3A6F0F5816cAd0df5E27, + quoterV4: 0x4A6513c898fe1B2d0E78d3b0e0A4a151589B1cBa, uniswapV2Factory: 0x7Ae58f10f7849cA6F5fB71b7f45CB416c9204b1e, uniswapV2Router02: 0x1689E7B1F10000AE47eBfE339a4f69dECd19F602, uniswapV3Factory: 0x4752ba5DBc23f44D87826276BF6Fd6b1C372aD24, diff --git a/script/deploy/DeployBundlerOnly.s.sol b/script/deploy/DeployBundlerOnly.s.sol new file mode 100644 index 000000000..33c8255cf --- /dev/null +++ b/script/deploy/DeployBundlerOnly.s.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import { Script, console } from "forge-std/Script.sol"; +import { ChainIds } from "script/ChainIds.sol"; +import { Airlock } from "src/Airlock.sol"; +import { Bundler } from "src/Bundler.sol"; +import { UniversalRouter } from "@universal-router/UniversalRouter.sol"; +import { IQuoterV2 } from "@v3-periphery/interfaces/IQuoterV2.sol"; +import { IV4Quoter } from "@v4-periphery/interfaces/IV4Quoter.sol"; + +/// @notice Deploys only the Bundler contract, using environment variables for dependencies +contract DeployBundlerOnly is Script { + struct BundlerConfig { + address payable airlock; + address universalRouter; + address quoterV2; + address quoterV4; + } + + function run() public { + BundlerConfig memory config = _readConfig(); + + vm.startBroadcast(); + Bundler bundler = new Bundler( + Airlock(config.airlock), + UniversalRouter(payable(config.universalRouter)), + IQuoterV2(config.quoterV2), + IV4Quoter(config.quoterV4) + ); + vm.stopBroadcast(); + + console.log("Bundler deployed at %s", address(bundler)); + } + + function _readConfig() internal view returns (BundlerConfig memory config) { + if (block.chainid == ChainIds.BASE_SEPOLIA) { + // Base Sepolia deployment wiring + config = BundlerConfig({ + airlock: payable(0x3411306Ce66c9469BFF1535BA955503c4Bde1C6e), + universalRouter: 0x492E6456D9528771018DeB9E87ef7750EF184104, + quoterV2: 0xC5290058841028F1614F3A6F0F5816cAd0df5E27, + quoterV4: 0x4A6513c898fe1B2d0E78d3b0e0A4a151589B1cBa + }); + } else { + revert("Unsupported chain"); + } + } +} diff --git a/script/deploy/DeployUnichainSepolia.s.sol b/script/deploy/DeployUnichainSepolia.s.sol index 109fd61c7..392f824d4 100644 --- a/script/deploy/DeployUnichainSepolia.s.sol +++ b/script/deploy/DeployUnichainSepolia.s.sol @@ -11,6 +11,7 @@ contract DeployUnichainSepolia is DeployScript { poolManager: 0x00B036B58a818B1BC34d502D3fE730Db729e62AC, protocolOwner: 0xaCE07c3c1D3b556D42633211f0Da71dc6F6d1c42, quoterV2: 0xbc02cBE6e4E29B504b67b0187A0178E13871fA3C, + quoterV4: 0x56DCD40A3F2d466F48e7F48bDBE5Cc9B92Ae4472, uniswapV2Factory: 0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f, uniswapV2Router02: 0x920b806E40A00E02E7D2b94fFc89860fDaEd3640, uniswapV3Factory: 0x1F98431c8aD98523631AE4a59f267346ea31F984, diff --git a/script/legacy/addresses.toml b/script/legacy/addresses.toml index b08bfe232..7f6451c18 100644 --- a/script/legacy/addresses.toml +++ b/script/legacy/addresses.toml @@ -21,6 +21,7 @@ "universal_router" = "0xf70536B3bcC1bD1a972dc186A2cf84cC6da6Be5D" "quoter_v2" = "0xbc02cbe6e4e29b504b67b0187a0178e13871fa3c" "pool_manager" = "0x00b036b58a818b1bc34d502d3fe730db729e62ac" +"quoter_v4" = "0x56DCD40A3F2d466F48e7F48bDBE5Cc9B92Ae4472" # Base [8453] @@ -28,6 +29,7 @@ "explorer_url" = "https://basescan.org/address/" "protocol_owner" = "0x21E2ce70511e4FE542a97708e89520471DAa7A66" "quoter_v2" = "0x3d4e44Eb1374240CE5F1B871ab261CD16335B76a" +"quoter_v4" = "0x0d5e0F971ED27FBfF6c2837bf31316121532048D" "uniswap_v2_factory" = "0x8909dc15e40173ff4699343b6eb8132c65e18ec6" "uniswap_v2_router_02" = "0x4752ba5dbc23f44d87826276bf6fd6b1c372ad24" "uniswap_v3_factory" = "0x33128a8fC17869897dcE68Ed026d694621f6FDfD" @@ -44,6 +46,7 @@ "weth" = "0x4200000000000000000000000000000000000006" "universal_router" = "0x492e6456d9528771018deb9e87ef7750ef184104" "quoter_v2" = "0xC5290058841028F1614F3A6F0F5816cAd0df5E27" +"quoter_v4" = "0x4A6513c898fe1B2d0E78d3b0e0A4a151589B1cBa" "pool_manager" = "0x05E73354cFDd6745C338b50BcFDfA3Aa6fA03408" "state_view" = "0x571291b572ed32ce6751a2cb2486ebee8defb9b4" diff --git a/snapshots/DistributionCalculationTest.json b/snapshots/DistributionCalculationTest.json new file mode 100644 index 000000000..5702f8885 --- /dev/null +++ b/snapshots/DistributionCalculationTest.json @@ -0,0 +1,4 @@ +{ + "create": "9075206", + "migrate": "1480215" +} \ No newline at end of file diff --git a/snapshots/DistributionMigratorFuzzTest.json b/snapshots/DistributionMigratorFuzzTest.json new file mode 100644 index 000000000..5702f8885 --- /dev/null +++ b/snapshots/DistributionMigratorFuzzTest.json @@ -0,0 +1,4 @@ +{ + "create": "9075206", + "migrate": "1480215" +} \ No newline at end of file diff --git a/snapshots/DistributionMigratorV4Integration.json b/snapshots/DistributionMigratorV4Integration.json new file mode 100644 index 000000000..b51019198 --- /dev/null +++ b/snapshots/DistributionMigratorV4Integration.json @@ -0,0 +1,4 @@ +{ + "create": "9075618", + "migrate": "1480630" +} \ No newline at end of file diff --git a/specs/SPEC-distribution-migrator.md b/specs/SPEC-distribution-migrator.md new file mode 100644 index 000000000..3710176de --- /dev/null +++ b/specs/SPEC-distribution-migrator.md @@ -0,0 +1,318 @@ +# Distribution Migrator Specification + +## Summary +The **DistributionMigrator** is a whitelisted `ILiquidityMigrator` module called by **Airlock** during `create()` and `migrate()`. + +It: +1. **initializes** and **stores** a per-(asset,numeraire) distribution configuration, +2. on migration, **pays a configurable % of the *numeraire proceeds*** to a payout address, +3. then **forwards the remaining balances** to an **underlying liquidity migrator** to create the destination liquidity. + +This is intended to enable launch teams to receive a share of proceeds **without modifying Airlock**. + +--- + +## Core design constraints + +### Airlock is unchanged +- **Airlock must not be modified.** +- Airlock continues to: + - call `liquidityMigrator.initialize(asset, numeraire, data)` during `create()`, + - later transfer post-fee proceeds to the migrator, + - then call `liquidityMigrator.migrate(sqrtPriceX96, token0, token1, timelock)`. + +### Forwarded-underlying migrators (required) +Because existing migrators use `onlyAirlock` (from `ImmutableAirlock`) on `initialize()` and `migrate()`, the wrapper cannot call them directly. + +Therefore, **the underlying migrator used with DistributionMigrator MUST be a “forwarded” migrator** whose `ImmutableAirlock.airlock` is set to the **DistributionMigrator address**, not the real Airlock. + +In other words: +- real Airlock → calls DistributionMigrator +- DistributionMigrator → calls forwarded underlying migrator +- forwarded underlying migrator → treats DistributionMigrator as its `airlock` for `onlyAirlock` + +### Fail-fast requirement +Misconfiguration that would predictably cause migration failure **must revert during `initialize()`**, not during `migrate()`. + +This is achieved by: +- performing strong, explicit validation in `DistributionMigrator.initialize()`, and +- calling `underlying.initialize(...)` during wrapper initialize so the underlying migrator’s own validation also runs at create-time. + +> Note: conditions that depend on future auction outcomes (e.g., the final `sqrtPriceX96` or future balances) cannot be fully prevalidated. + +--- + +## Supported underlying migrators + +This wrapper is designed to support **forwarded variants** of: +- ✅ UniswapV2Migrator +- ✅ UniswapV4Migrator +- ✅ UniswapV4MulticurveMigrator +- ❌ PredictionMigrator (explicitly out of scope) + +### Additional requirements for forwarded Uniswap V4 migrators +The existing V4 migrators call `airlock.owner()` / `Airlock(airlock).owner()`. + +Since the forwarded migrators will have `airlock == DistributionMigrator`, the **DistributionMigrator MUST implement**: + +```solidity +function owner() external view returns (address); +``` + +and it MUST return the **real Airlock owner**, i.e. `return airlock.owner();`. + +### Hook binding requirement (V4) +V4 migrator hooks restrict initialization to a specific migrator address. + +When deploying a forwarded V4 migrator, its hook MUST be deployed/configured so that: +- `hook.migrator() == address(forwardedMigrator)` + +Otherwise the underlying migrator will revert at migration-time. + +### Locker approval requirement (V4) +Both `StreamableFeesLocker` and `StreamableFeesLockerV2` require the migrator to be approved. + +When using a forwarded V4 migrator, the locker MUST have: +- `approvedMigrators[address(forwardedMigrator)] == true` + +Otherwise the underlying migrator will revert at migration-time. + +--- + +## DistributionMigrator interface + +Implements `ILiquidityMigrator` and inherits `ImmutableAirlock` (pointing to the **real Airlock**). + +```solidity +function initialize(address asset, address numeraire, bytes calldata data) + external + returns (address migrationPool); + +function migrate(uint160 sqrtPriceX96, address token0, address token1, address recipient) + external + payable + returns (uint256 liquidity); + +// Required for forwarded V4 migrators: +function owner() external view returns (address); +``` + +### Receiving ETH +Airlock transfers ETH proceeds to the migrator via a plain ETH transfer. + +DistributionMigrator MUST implement: +- `receive() external payable` and it SHOULD be restricted to `onlyAirlock`. + +--- + +## Initialization payload + +`DistributionMigrator.initialize(asset, numeraire, data)` expects: + +```solidity +(address payout, uint256 percentWad, address underlyingMigrator, bytes underlyingData) +``` + +- `underlyingData` is **forwarded** to the underlying migrator’s `initialize()`. +- `underlyingData` is **NOT stored onchain** by DistributionMigrator. + +--- + +## Constants + +- `WAD = 1e18` +- `MAX_DISTRIBUTION_WAD = 5e17` (50%) + +--- + +## Storage + +DistributionMigrator stores per-pair configuration keyed by the sorted pair `(token0, token1)` where: +- `(token0, token1) = asset < numeraire ? (asset, numeraire) : (numeraire, asset)` + +```solidity +struct DistributionConfig { + address payout; + uint256 percentWad; + ILiquidityMigrator underlying; + address asset; // used to identify the numeraire at migrate-time +} + +mapping(address token0 => mapping(address token1 => DistributionConfig)) + public getDistributionConfig; +``` + +### Overwrites are forbidden +If configuration already exists for `(token0, token1)`, `initialize()` MUST revert. + +--- + +## Validation in initialize() (fail-fast) + +Given `asset`, `numeraire`, and decoded `(payout, percentWad, underlyingMigrator, underlyingData)`: + +### Basic checks +- `payout != address(0)` +- `underlyingMigrator != address(0)` +- `underlyingMigrator != address(this)` +- `percentWad <= MAX_DISTRIBUTION_WAD` + +### No overwrites +- Compute `(token0, token1)` from `(asset, numeraire)`. +- If `getDistributionConfig[token0][token1].payout != address(0)` then revert. + +### Underlying is a whitelisted Airlock module +- `airlock.getModuleState(underlyingMigrator) == ModuleState.LiquidityMigrator` MUST hold. + +### Underlying is correctly forwarded to this DistributionMigrator +To prevent a late failure due to `onlyAirlock`, DistributionMigrator MUST verify: +- `IHasAirlock(underlyingMigrator).airlock() == address(this)` + +Where `IHasAirlock` is: +```solidity +interface IHasAirlock { function airlock() external view returns (address); } +``` + +### Hook + locker preflight checks (V4) +If the underlying migrator exposes these accessors, DistributionMigrator SHOULD preflight: +- `locker.approvedMigrators(underlyingMigrator) == true` +- `hook.migrator() == underlyingMigrator` + +These checks exist specifically to satisfy the fail-fast requirement. + +### Persist config +Store `DistributionConfig({ payout, percentWad, underlying, asset })` at `(token0, token1)`. + +### Forward initialize +Call: +- `migrationPool = underlying.initialize(asset, numeraire, underlyingData)` + +and return it. + +Any revert from `underlying.initialize()` MUST bubble up (do not swallow). + +--- + +## Distribution behavior + +### Scope +- Distribution applies **only** to **numeraire** balances. +- Asset balances are untouched. + +### Formula +- `distribution = floor(numeraireBalance * percentWad / WAD)` + +### Rounding +- Round **down**. +- Any remainder stays in the migrator and is used for liquidity. + +--- + +## migrate() behavior + +### Preconditions +- callable only by Airlock (same pattern as other migrators) +- look up `config = getDistributionConfig[token0][token1]` +- if missing, revert `PoolNotInitialized()` + +### Identify numeraire +Using stored `config.asset`: +- if `config.asset == token0` then `numeraire = token1` +- else if `config.asset == token1` then `numeraire = token0` +- else revert `AssetMismatch()` (should never happen if initialize was correct) + +### Pay distribution +- compute `numeraireBalance`: + - if `numeraire == address(0)`: `address(this).balance` + - else: `ERC20(numeraire).balanceOf(address(this))` +- compute `distribution` +- transfer `distribution` to `config.payout`: + - ETH via `safeTransferETH` + - ERC20 via `safeTransfer` + +### Forward balances to underlying +Forward the entire remaining balances of `token0` and `token1` to `config.underlying`: +- If `token0 != address(0)`: transfer full `ERC20(token0).balanceOf(address(this))` to underlying. +- Always transfer full `ERC20(token1).balanceOf(address(this))` to underlying. + +### Forward ETH to underlying (if needed) +If `token0 == address(0)` (i.e., one side is ETH), call the underlying migrator with: +- `value = address(this).balance` (remaining ETH after payout) + +### Call underlying migrate +Call: +- `liquidity = underlying.migrate{value: maybeETH}(sqrtPriceX96, token0, token1, recipient)` + +Any revert from `underlying.migrate()` MUST bubble up. + +### Optional cleanup +Optionally delete config after successful migrate to reduce storage: +- `delete getDistributionConfig[token0][token1];` + +--- + +## Events + +```solidity +event Distribution( + address indexed payout, + address indexed numeraire, + uint256 amount, + uint256 percentWad +); + +event WrappedMigration( + address indexed underlying, + address indexed token0, + address indexed token1, + uint160 sqrtPriceX96 +); +``` + +--- + +## Errors + +Required: +- `InvalidPayout()` +- `InvalidUnderlying()` +- `InvalidPercent()` +- `AlreadyInitialized()` +- `PoolNotInitialized()` +- `AssetMismatch()` + +Recommended: +- `UnderlyingNotWhitelisted()` +- `UnderlyingNotForwarded()` +- `UnderlyingNotLockerApproved()` +- `UnderlyingHookMismatch()` + +--- + +## Test plan + +### Unit tests +- `initialize()`: + - validates payload (payout, percent, underlying) + - rejects overwrites + - checks underlying is whitelisted + - checks underlying is forwarded to distributor (`underlying.airlock() == distributor`) + - forwards `underlyingData` and bubbles underlying revert reasons + +- `migrate()`: + - distributes only numeraire + - does not touch asset balances except forwarding to underlying + - rounding is floor + - ETH-numeraire path pays payout and forwards remaining ETH as `msg.value` + - bubbles underlying revert reasons (no try/catch) + +### Integration tests +- Airlock create + migrate using: + - DistributionMigrator → ForwardedUniswapV2Migrator + - DistributionMigrator → ForwardedUniswapV4Migrator + - DistributionMigrator → ForwardedUniswapV4MulticurveMigrator + +- V4-specific integration: + - forwarded migrator is approved in correct locker + - hook migrator binding is correct + - DistributionMigrator.owner() returns real Airlock owner diff --git a/src/Bundler.sol b/src/Bundler.sol index b0502a0e7..8aca538e4 100644 --- a/src/Bundler.sol +++ b/src/Bundler.sol @@ -4,7 +4,12 @@ pragma solidity ^0.8.24; import { SafeTransferLib } from "@solady/utils/SafeTransferLib.sol"; import { UniversalRouter } from "@universal-router/UniversalRouter.sol"; import { IQuoterV2 } from "@v3-periphery/interfaces/IQuoterV2.sol"; +import { IV4Quoter } from "@v4-periphery/interfaces/IV4Quoter.sol"; import { Airlock, CreateParams } from "src/Airlock.sol"; +import { IBundleCallback, CreateResult, Transfer, Call } from "src/interfaces/IBundleCallback.sol"; +import { PoolKey } from "@v4-core/types/PoolKey.sol"; +import { Currency } from "@v4-core/types/Currency.sol"; +import { UniswapV4MulticurveInitializer } from "src/initializers/UniswapV4MulticurveInitializer.sol"; /// @dev Thrown when an invalid address is passed as a contructor parameter error InvalidAddresses(); @@ -12,6 +17,18 @@ error InvalidAddresses(); /// @dev Thrown when the asset address doesn't match the predicted one error InvalidOutputToken(); +/// @dev Thrown when the amount to quote exceeds the uint128 limit +error ExactAmountTooLarge(); + +/// @dev Thrown when the asset is not part of the resulting pool +error AssetNotInPool(); + +/// @dev Thrown when the provided exact amount is zero +error ExactAmountZero(); + +/// @dev Thrown when a planned call fails +error CallFailed(); + /** * @author Whetstone * @custom:security-contact security@whetstone.cc @@ -26,19 +43,26 @@ contract Bundler { /// @notice Address of the QuoterV2 contract IQuoterV2 public immutable quoter; + /// @notice Address of the Uniswap V4 Quoter contract + IV4Quoter public immutable v4Quoter; + /** * @param airlock_ Immutable address of the Airlock contract * @param router_ Immutable address of the Universal Router contract * @param quoter_ Immutable address of the QuoterV2 contract */ - constructor(Airlock airlock_, UniversalRouter router_, IQuoterV2 quoter_) { - if (address(airlock_) == address(0) || address(router_) == address(0) || address(quoter_) == address(0)) { + constructor(Airlock airlock_, UniversalRouter router_, IQuoterV2 quoter_, IV4Quoter v4Quoter_) { + if ( + address(airlock_) == address(0) || address(router_) == address(0) || address(quoter_) == address(0) + || address(v4Quoter_) == address(0) + ) { revert InvalidAddresses(); } airlock = Airlock(airlock_); router = UniversalRouter(router_); quoter = IQuoterV2(quoter_); + v4Quoter = IV4Quoter(v4Quoter_); } /** @@ -75,6 +99,94 @@ contract Bundler { (amountOut,,,) = quoter.quoteExactInputSingle(params); } + /** + * @notice Simulates a multicurve bundle, returning the pool key and the quote to purchase the issued tokens + * @param createData Creation data to pass to the Airlock contract + * @return asset Address of the created asset token + * @return poolKey PoolKey associated with the initialized Uniswap V4 pool + * @return amountIn Numeraire required to receive the requested asset amount + * @return gasEstimate Estimated gas for the swap quote + */ + function simulateMulticurveBundleExactOut( + CreateParams calldata createData, + uint128 exactAmountOut, + bytes calldata hookData + ) + external + returns (address asset, PoolKey memory poolKey, uint256 amountIn, uint256 gasEstimate) + { + bool zeroForOne; + (asset, poolKey, zeroForOne) = _prepareMulticurveQuote(createData); + + uint128 amount = _resolveExactOutAmount(createData, exactAmountOut); + + (amountIn, gasEstimate) = v4Quoter.quoteExactOutputSingle( + IV4Quoter.QuoteExactSingleParams({ + poolKey: poolKey, + zeroForOne: zeroForOne, + exactAmount: amount, + hookData: hookData + }) + ); + } + + function simulateMulticurveBundleExactIn( + CreateParams calldata createData, + uint128 exactAmountIn, + bytes calldata hookData + ) + external + returns (address asset, PoolKey memory poolKey, uint256 amountOut, uint256 gasEstimate) + { + if (exactAmountIn == 0) revert ExactAmountZero(); + + bool zeroForOne; + (asset, poolKey, zeroForOne) = _prepareMulticurveQuote(createData); + + (amountOut, gasEstimate) = v4Quoter.quoteExactInputSingle( + IV4Quoter.QuoteExactSingleParams({ + poolKey: poolKey, + zeroForOne: zeroForOne, + exactAmount: exactAmountIn, + hookData: hookData + }) + ); + } + + function _prepareMulticurveQuote(CreateParams calldata createData) + private + returns (address asset, PoolKey memory poolKey, bool zeroForOne) + { + (asset,,,,) = airlock.create(createData); + (, , poolKey,) = UniswapV4MulticurveInitializer(payable(address(createData.poolInitializer))).getState(asset); + + address currency0 = Currency.unwrap(poolKey.currency0); + address currency1 = Currency.unwrap(poolKey.currency1); + + if (asset == currency0) { + zeroForOne = false; + } else if (asset == currency1) { + zeroForOne = true; + } else { + revert AssetNotInPool(); + } + } + + function _resolveExactOutAmount(CreateParams calldata createData, uint128 overrideAmount) + private + pure + returns (uint128 amount) + { + if (overrideAmount != 0) { + amount = overrideAmount; + } else { + uint256 numTokensToSell = createData.numTokensToSell; + if (numTokensToSell == 0) revert ExactAmountZero(); + if (numTokensToSell > type(uint128).max) revert ExactAmountTooLarge(); + amount = uint128(numTokensToSell); + } + } + /** * @notice Bundles the creation of an asset via the Airlock contract and a buy operation via the Universal Router * @param createData Creation data to pass to the Airlock contract @@ -90,13 +202,78 @@ contract Bundler { uint256 balance = address(this).balance; router.execute{ value: balance }(commands, inputs); + _sweep(asset, createData.numeraire, msg.sender); + } + + /** + * @notice Bundle with callback for complex flows (e.g., prebuy to vault) + * @param createData Creation parameters + * @param commands Router commands + * @param inputs Router inputs + * @param callback Contract to plan post-execution actions + * @param callbackData Data for callback planning + */ + function bundleWithPlan( + CreateParams calldata createData, + bytes calldata commands, + bytes[] calldata inputs, + IBundleCallback callback, + bytes calldata callbackData + ) external payable { + (address asset, address pool, address governance, address timelock, address migrationPool) = airlock.create( + createData + ); + + // Execute router (tokens end up in bundler) + router.execute{ value: address(this).balance }(commands, inputs); + + // Get callback plan + CreateResult memory result = CreateResult({ + asset: asset, + pool: pool, + governance: governance, + timelock: timelock, + migrationPool: migrationPool + }); + + (Transfer[] memory transfers, Call[] memory calls) = callback.plan(result, callbackData); + + // Execute transfers (e.g., send tokens to vault) + for (uint256 i = 0; i < transfers.length; i++) { + Transfer memory transfer = transfers[i]; + if (transfer.amount > 0) { + SafeTransferLib.safeTransfer(transfer.token, transfer.to, transfer.amount); + } + } + + // Execute calls (e.g., vault records deposit) + for (uint256 i = 0; i < calls.length; i++) { + Call memory plannedCall = calls[i]; + (bool success,) = plannedCall.target.call{ value: plannedCall.value }(plannedCall.data); + if (!success) revert CallFailed(); + } + + // Sweep remaining tokens to caller + _sweep(asset, createData.numeraire, msg.sender); + } + + /** + * @notice Sweep all token balances to recipient + * @param asset Asset token to sweep + * @param numeraire Numeraire token to sweep + * @param recipient Address to receive swept tokens + */ + function _sweep(address asset, address numeraire, address recipient) internal { + // Sweep ETH uint256 ethBalance = address(this).balance; - if (ethBalance > 0) SafeTransferLib.safeTransferETH(msg.sender, ethBalance); + if (ethBalance > 0) SafeTransferLib.safeTransferETH(recipient, ethBalance); + // Sweep asset uint256 assetBalance = SafeTransferLib.balanceOf(asset, address(this)); - if (assetBalance > 0) SafeTransferLib.safeTransfer(asset, msg.sender, assetBalance); + if (assetBalance > 0) SafeTransferLib.safeTransfer(asset, recipient, assetBalance); - uint256 numeraireBalance = SafeTransferLib.balanceOf(createData.numeraire, address(this)); - if (numeraireBalance > 0) SafeTransferLib.safeTransfer(createData.numeraire, msg.sender, numeraireBalance); + // Sweep numeraire + uint256 numeraireBalance = SafeTransferLib.balanceOf(numeraire, address(this)); + if (numeraireBalance > 0) SafeTransferLib.safeTransfer(numeraire, recipient, numeraireBalance); } } diff --git a/src/LaunchVault.sol b/src/LaunchVault.sol new file mode 100644 index 000000000..e8c8c76af --- /dev/null +++ b/src/LaunchVault.sol @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.24; + +import {SafeTransferLib} from "@solady/utils/SafeTransferLib.sol"; +import {ReentrancyGuard} from "@solady/utils/ReentrancyGuard.sol"; +import {Airlock} from "src/Airlock.sol"; +import {DERC20} from "src/tokens/DERC20.sol"; + +/// @notice Simplified LaunchVault - just custody and release +/// @dev Vesting/Merkle/Splits handled by external contracts +contract LaunchVault is ReentrancyGuard { + /// @notice Airlock contract for migration checks + Airlock public immutable airlock; + + /// @notice Total prebuy amount per asset + mapping(address asset => uint256 totalAmount) public prebuyTotal; + + /// @notice Distributor address for each asset (external vesting/merkle contract) + mapping(address asset => address distributor) public distributor; + + /// @notice Trusted executor (timelock/bundler) that can deposit prebuy + mapping(address executor => bool trusted) public trustedExecutors; + + /// @notice Custom errors + error NotUnlocked(); + error ZeroAmount(); + error AlreadyDeposited(); + error NoDistributorSet(); + error NotTrustedExecutor(); + error WrongAmount(uint256 received, uint256 expected); + error InsufficientBalance(uint256 balance, uint256 required); + + /// @notice Events + event PrebuyDeposited(address indexed asset, address indexed beneficiary, uint256 amount); + event ReleasedToDistributor(address indexed asset, address indexed distributor, uint256 amount); + event DistributorSet(address indexed asset, address indexed distributor); + event TrustedExecutorSet(address indexed executor, bool trusted); + + constructor(address airlock_) { + airlock = Airlock(payable(airlock_)); + } + + /// @notice Check if asset has been migrated via Airlock + /// @param asset The asset token address (must be DERC20) + /// @return True if the asset's pool is unlocked (migration complete) + function isUnlocked(address asset) public view returns (bool) { + return DERC20(asset).isPoolUnlocked(); + } + + /// @notice Set trusted executor status + /// @param executor The address to set trust status for + /// @param trusted Whether the executor is trusted + function setTrustedExecutor(address executor, bool trusted) external { + // In production, add access control (only timelock/owner) + trustedExecutors[executor] = trusted; + emit TrustedExecutorSet(executor, trusted); + } + + /// @notice Record prebuy tokens deposited to vault (push model) + /// @dev Only trusted executors (timelock/bundler) can record deposits + /// Tokens must already be transferred to vault before calling this + /// @param asset The asset token address + /// @param beneficiary The beneficiary (for tracking, not custody) + /// @param amount Amount that was deposited + function depositPrebuy(address asset, address beneficiary, uint256 amount) external nonReentrant { + _validateAndRecordDeposit(asset, beneficiary, amount); + + // Verify vault actually received the tokens (push model) + uint256 balance = SafeTransferLib.balanceOf(asset, address(this)); + if (balance < amount) revert InsufficientBalance(balance, amount); + } + + /// @notice Deposit prebuy tokens by pulling from DERC20 vesting (pull model) + /// @dev Users vest tokens in DERC20 with vault as recipient, then this pulls via release() + /// @param asset The asset token address (must be DERC20) + /// @param beneficiary The beneficiary (for tracking) + /// @param expectedAmount Expected amount to pull (for validation) + function depositPrebuyFromRelease(address asset, address beneficiary, uint256 expectedAmount) external nonReentrant { + _validateAndRecordDeposit(asset, beneficiary, expectedAmount); + + // Pull released tokens from DERC20 (pull model) + uint256 balanceBefore = DERC20(asset).balanceOf(address(this)); + DERC20(asset).release(); + uint256 amount = DERC20(asset).balanceOf(address(this)) - balanceBefore; + + // Validate amount matches expectation + if (amount != expectedAmount) revert WrongAmount(amount, expectedAmount); + } + + /// @notice Internal function to validate and record a deposit + /// @param asset The asset token address + /// @param beneficiary The beneficiary (for tracking) + /// @param amount Amount to record + function _validateAndRecordDeposit(address asset, address beneficiary, uint256 amount) internal { + if (!trustedExecutors[msg.sender]) revert NotTrustedExecutor(); + if (amount == 0) revert ZeroAmount(); + if (prebuyTotal[asset] > 0) revert AlreadyDeposited(); + + // Track total + prebuyTotal[asset] = amount; + + emit PrebuyDeposited(asset, beneficiary, amount); + } + + /// @notice Set the distributor address for an asset + /// @param asset The asset token + /// @param distributor_ The external distributor (vesting/merkle/splits contract) + function setDistributor(address asset, address distributor_) external { + // In production, add access control (only timelock/owner) + distributor[asset] = distributor_; + emit DistributorSet(asset, distributor_); + } + + /// @notice Release all tokens to the distributor + /// @param asset The asset to release + function releaseToDistributor(address asset) external nonReentrant { + if (!isUnlocked(asset)) revert NotUnlocked(); + + address dist = distributor[asset]; + if (dist == address(0)) revert NoDistributorSet(); + + uint256 amount = prebuyTotal[asset]; + if (amount == 0) return; // Nothing to release + + // Clear tracking before transfer (CEI pattern) + prebuyTotal[asset] = 0; + + // Send all tokens to distributor + SafeTransferLib.safeTransfer(asset, dist, amount); + + emit ReleasedToDistributor(asset, dist, amount); + } + + /// @notice Release tokens to a specific recipient (fallback for simple cases) + /// @param asset The asset to release + /// @param recipient Where to send the tokens + function releaseTo(address asset, address recipient) external nonReentrant { + if (!isUnlocked(asset)) revert NotUnlocked(); + if (recipient == address(0)) revert NoDistributorSet(); + + uint256 amount = prebuyTotal[asset]; + if (amount == 0) return; + + // Clear tracking before transfer + prebuyTotal[asset] = 0; + + // Send tokens + SafeTransferLib.safeTransfer(asset, recipient, amount); + + emit ReleasedToDistributor(asset, recipient, amount); + } +} diff --git a/src/callbacks/PrebuyToVaultCallback.sol b/src/callbacks/PrebuyToVaultCallback.sol new file mode 100644 index 000000000..07d47e385 --- /dev/null +++ b/src/callbacks/PrebuyToVaultCallback.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.24; + +import {IBundleCallback, CreateResult, Transfer, Call} from "src/interfaces/IBundleCallback.sol"; +import {LaunchVault} from "src/LaunchVault.sol"; + +/// @notice Callback that deposits prebuy tokens to LaunchVault +/// @dev Uses push model: tokens are transferred directly to vault, then recorded +contract PrebuyToVaultCallback is IBundleCallback { + address public immutable launchVault; + + constructor(address launchVault_) { + launchVault = launchVault_; + } + + /// @notice Plan transfers and calls to deposit prebuy to the vault + /// @param result The result of the bundle creation + /// @param data Encoded (beneficiary, prebuyAmount) + /// @return transfers Array with one transfer of the asset to the vault (PUSH model) + /// @return calls Array with one call to record the deposit in vault + /// @dev Flow: 1) Bundler transfers tokens to vault, 2) Bundler calls vault to record + function plan(CreateResult calldata result, bytes calldata data) + external + view + returns (Transfer[] memory transfers, Call[] memory calls) + { + (address beneficiary, uint256 prebuyAmount) = abi.decode(data, (address, uint256)); + + // STEP 1: Transfer asset from bundler to vault (PUSH model - no approval needed) + transfers = new Transfer[](1); + transfers[0] = Transfer({ + token: result.asset, + to: launchVault, + amount: prebuyAmount + }); + + // STEP 2: Call vault to record the deposit + // Vault verifies it received the tokens before recording + calls = new Call[](1); + calls[0] = Call({ + target: launchVault, + value: 0, + data: abi.encodeWithSelector( + LaunchVault.depositPrebuy.selector, + result.asset, + beneficiary, + prebuyAmount + ) + }); + } +} diff --git a/src/interfaces/IBundleCallback.sol b/src/interfaces/IBundleCallback.sol new file mode 100644 index 000000000..ba574ba9a --- /dev/null +++ b/src/interfaces/IBundleCallback.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.24; + +/// @notice Result of a successful bundle creation +/// @param asset The created asset token +/// @param pool The created pool +/// @param governance The deployed governance contract +/// @param timelock The deployed timelock contract +/// @param migrationPool The created migration pool +struct CreateResult { + address asset; + address pool; + address governance; + address timelock; + address migrationPool; +} + +/// @notice A token transfer to be executed +/// @param token The token to transfer +/// @param to The recipient address +/// @param amount The amount to transfer +struct Transfer { + address token; + address to; + uint256 amount; +} + +/// @notice A generic call to be executed +/// @param target The target contract address +/// @param value The ETH value to send +/// @param data The call data +struct Call { + address target; + uint256 value; + bytes data; +} + +/// @notice Interface for callbacks during bundle creation +/// @dev Allows external contracts to plan additional actions based on creation results +interface IBundleCallback { + /// @notice Plan additional transfers and calls based on creation results + /// @param result The result of the bundle creation + /// @param data Additional encoded data for planning + /// @return transfers Array of token transfers to execute + /// @return calls Array of generic calls to execute + function plan(CreateResult calldata result, bytes calldata data) + external + returns (Transfer[] memory transfers, Call[] memory calls); +} diff --git a/src/interfaces/IDistributionTopUpSource.sol b/src/interfaces/IDistributionTopUpSource.sol new file mode 100644 index 000000000..61fccd878 --- /dev/null +++ b/src/interfaces/IDistributionTopUpSource.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.24; + +/// @notice Interface implemented by external contracts supplying top-up liquidity +interface IDistributionTopUpSource { + /// @notice Transfer any available numeraire top-up for a given launch + /// @param asset The launch asset token + /// @param numeraire The numeraire token (address(0) for ETH) + /// @return amount Amount transferred (used for monitoring) + function pullTopUp(address asset, address numeraire) external returns (uint256 amount); +} diff --git a/src/migrators/distribution/DistributionMigrator.sol b/src/migrators/distribution/DistributionMigrator.sol new file mode 100644 index 000000000..1fdc3c41a --- /dev/null +++ b/src/migrators/distribution/DistributionMigrator.sol @@ -0,0 +1,294 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.24; + +import { ReentrancyGuardTransient } from "@solady/utils/ReentrancyGuardTransient.sol"; +import { ERC20, SafeTransferLib } from "@solmate/utils/SafeTransferLib.sol"; +import { Airlock, ModuleState } from "src/Airlock.sol"; +import { ImmutableAirlock } from "src/base/ImmutableAirlock.sol"; +import { IDistributionTopUpSource } from "src/interfaces/IDistributionTopUpSource.sol"; +import { ILiquidityMigrator } from "src/interfaces/ILiquidityMigrator.sol"; + +// ============ Errors ============ + +/// @notice Thrown when payout address is zero +error InvalidPayout(); + +/// @notice Thrown when underlying migrator address is zero or self +error InvalidUnderlying(); + +/// @notice Thrown when percentWad exceeds MAX_DISTRIBUTION_WAD +error InvalidPercent(); + +/// @notice Thrown when config already exists for token pair +error AlreadyInitialized(); + +/// @notice Thrown when config does not exist for token pair +error PoolNotInitialized(); + +/// @notice Thrown when provided token pair doesn't match stored (asset, numeraire) +error TokenPairMismatch(); + +/// @notice Thrown when underlying migrator is not whitelisted by Airlock +error UnderlyingNotWhitelisted(); + +/// @notice Thrown when underlying migrator's airlock is not this contract +error UnderlyingNotForwarded(); + +/// @notice Thrown when underlying V4 migrator is not approved in locker (optional preflight) +error UnderlyingNotLockerApproved(); + +/// @notice Thrown when hook's migrator doesn't match underlying (optional preflight) + +// ============ Events ============ + +/** + * @notice Emitted when distribution is paid to payout address + * @param payout Address receiving the distribution + * @param numeraire Token being distributed (address(0) for ETH) + * @param amount Amount distributed + * @param percentWad Distribution percentage in WAD + */ +event Distribution(address indexed payout, address indexed numeraire, uint256 amount, uint256 percentWad); + +/** + * @notice Emitted when migration is forwarded to underlying migrator + * @param underlying Address of the underlying migrator + * @param token0 First token of the pair + * @param token1 Second token of the pair + * @param sqrtPriceX96 Square root price for migration + */ +event WrappedMigration( + address indexed underlying, address indexed token0, address indexed token1, uint160 sqrtPriceX96 +); + +/// @notice Emitted when top-up sources are configured for a pair +event TopUpSourceConfigured(address indexed token0, address indexed token1, address source); + +/// @notice Emitted when a top-up succeeds +event TopUpPulled(address indexed source, address indexed numeraire, uint256 amount); + +// ============ Interfaces ============ + +/// @notice Interface to check if a contract has an airlock() accessor +interface IHasAirlock { + function airlock() external view returns (Airlock); +} + +// ============ Storage ============ + +/** + * @notice Configuration for distribution per token pair + * @param payout Address receiving the distribution + * @param percentWad Distribution percentage in WAD (1e18 = 100%) + * @param underlying The underlying migrator to forward to + * @param asset The asset token address + * @param numeraire The numeraire token address (address(0) for ETH) + */ +struct DistributionConfig { + address payout; + uint256 percentWad; + ILiquidityMigrator underlying; + address asset; + address numeraire; +} + +// ============ Constants ============ + +/// @dev 1e18 - used for percentage calculations +uint256 constant WAD = 1e18; + +/// @dev Maximum distribution percentage (50%) +uint256 constant MAX_DISTRIBUTION_WAD = 5e17; + +/** + * @title DistributionMigrator + * @author Whetstone Research + * @notice Wrapper migrator that distributes a share of numeraire proceeds before forwarding to underlying migrator + * @custom:security-contact security@whetstone.cc + */ +contract DistributionMigrator is ILiquidityMigrator, ImmutableAirlock, ReentrancyGuardTransient { + using SafeTransferLib for ERC20; + + /// @notice Configuration for each token pair + mapping(address token0 => mapping(address token1 => DistributionConfig)) public getDistributionConfig; + + /// @notice Optional top-up source configured per token pair + mapping(address token0 => mapping(address token1 => address)) internal getTopUpSource; + + /** + * @notice Constructor + * @param airlock_ Address of the real Airlock contract + */ + constructor(address airlock_) ImmutableAirlock(airlock_) { } + + /** + * @notice Returns the owner of the real Airlock + * @dev Required for forwarded V4 migrators that call airlock.owner() + */ + function owner() external view returns (address) { + return airlock.owner(); + } + + /** + * @notice Receives ETH from Airlock + * @dev Restricted to Airlock only + */ + receive() external payable onlyAirlock { } + + /** + * @notice Accepts ETH from a top-up source during pull step + */ + function acceptTopUpETH() external payable { } + + /** + * @notice Initializes distribution config and forwards to underlying migrator + * @param asset The asset token address + * @param numeraire The numeraire token address (address(0) for ETH) + * @param data Encoded (payout, percentWad, underlyingMigrator, underlyingData) + * @return migrationPool The migration pool address from the underlying migrator + */ + function initialize( + address asset, + address numeraire, + bytes calldata data + ) external onlyAirlock returns (address migrationPool) { + // Decode payload + ( + address payout, + uint256 percentWad, + address underlyingMigrator, + bytes memory underlyingData, + address topUpSource + ) = abi.decode(data, (address, uint256, address, bytes, address)); + + // Basic validation + if (payout == address(0)) revert InvalidPayout(); + if (underlyingMigrator == address(0) || underlyingMigrator == address(this)) revert InvalidUnderlying(); + if (percentWad > MAX_DISTRIBUTION_WAD) revert InvalidPercent(); + + // Compute sorted token pair + (address token0, address token1) = asset < numeraire ? (asset, numeraire) : (numeraire, asset); + + // Check for overwrites + if (getDistributionConfig[token0][token1].payout != address(0)) revert AlreadyInitialized(); + + // Verify underlying is whitelisted by Airlock + if (airlock.getModuleState(underlyingMigrator) != ModuleState.LiquidityMigrator) { + revert UnderlyingNotWhitelisted(); + } + + // Verify underlying is forwarded to this contract (its airlock == address(this)) + if (address(IHasAirlock(underlyingMigrator).airlock()) != address(this)) { + revert UnderlyingNotForwarded(); + } + + // Store config with BOTH asset and numeraire explicitly (underlyingData is NOT stored per spec) + // Storing both enables explicit validation in migrate() - prevents ordering spoofing + getDistributionConfig[token0][token1] = DistributionConfig({ + payout: payout, + percentWad: percentWad, + underlying: ILiquidityMigrator(underlyingMigrator), + asset: asset, + numeraire: numeraire + }); + + if (topUpSource != address(0)) { + getTopUpSource[token0][token1] = topUpSource; + emit TopUpSourceConfigured(token0, token1, topUpSource); + } + + // Forward initialize to underlying migrator + migrationPool = ILiquidityMigrator(underlyingMigrator).initialize(asset, numeraire, underlyingData); + } + + /** + * @notice Distributes numeraire proceeds and forwards remaining balances to underlying migrator + * @param sqrtPriceX96 Square root price for migration + * @param token0 First token of the pair (sorted) + * @param token1 Second token of the pair (sorted) + * @param recipient Recipient of the liquidity position + * @return liquidity Amount of liquidity created by underlying migrator + */ + function migrate( + uint160 sqrtPriceX96, + address token0, + address token1, + address recipient + ) external payable onlyAirlock nonReentrant returns (uint256 liquidity) { + // Look up config using sorted key + DistributionConfig memory config = getDistributionConfig[token0][token1]; + if (config.payout == address(0)) revert PoolNotInitialized(); + + // Verify token pair matches stored config EXACTLY (order-independent) + // This prevents ordering spoofing if poolInitializer returns unexpected tokens + bool validPair = (config.asset == token0 && config.numeraire == token1) + || (config.asset == token1 && config.numeraire == token0); + if (!validPair) revert TokenPairMismatch(); + + address numeraire = config.numeraire; + address topUpSource = getTopUpSource[token0][token1]; + + uint256 numeraireBalance = _numeraireBalance(numeraire); + + uint256 distribution = (numeraireBalance * config.percentWad) / WAD; + + // Pay distribution to payout address + if (distribution > 0) { + if (numeraire == address(0)) { + SafeTransferLib.safeTransferETH(config.payout, distribution); + } else { + ERC20(numeraire).safeTransfer(config.payout, distribution); + } + emit Distribution(config.payout, numeraire, distribution, config.percentWad); + } + + _pullSupplementalLiquidity(config, topUpSource, numeraire); + + // Forward remaining balances to underlying migrator + address underlying = address(config.underlying); + + // Transfer token0 balance (if not ETH) + if (token0 != address(0)) { + uint256 balance0 = ERC20(token0).balanceOf(address(this)); + if (balance0 > 0) { + ERC20(token0).safeTransfer(underlying, balance0); + } + } + + // Transfer token1 balance + uint256 balance1 = ERC20(token1).balanceOf(address(this)); + if (balance1 > 0) { + ERC20(token1).safeTransfer(underlying, balance1); + } + + // Forward ETH if token0 is ETH + uint256 ethToForward = token0 == address(0) ? address(this).balance : 0; + + emit WrappedMigration(underlying, token0, token1, sqrtPriceX96); + + liquidity = config.underlying.migrate{ value: ethToForward }(sqrtPriceX96, token0, token1, recipient); + } + + function _numeraireBalance(address numeraire) internal view returns (uint256) { + if (numeraire == address(0)) { + return address(this).balance; + } + return ERC20(numeraire).balanceOf(address(this)); + } + + function _pullSupplementalLiquidity( + DistributionConfig memory config, + address supplementalSource, + address numeraire + ) private { + if (supplementalSource == address(0)) return; + + uint256 preBalance = _numeraireBalance(numeraire); + uint256 reported = IDistributionTopUpSource(supplementalSource).pullTopUp(config.asset, numeraire); + uint256 postBalance = _numeraireBalance(numeraire); + + uint256 delta = postBalance > preBalance ? postBalance - preBalance : 0; + if (delta == 0 && reported == 0) return; + emit TopUpPulled(supplementalSource, numeraire, delta == 0 ? reported : delta); + } +} diff --git a/src/migrators/distribution/ForwardedUniswapV2Migrator.sol b/src/migrators/distribution/ForwardedUniswapV2Migrator.sol new file mode 100644 index 000000000..2ed899cdb --- /dev/null +++ b/src/migrators/distribution/ForwardedUniswapV2Migrator.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.24; + +import { IUniswapV2Factory, IUniswapV2Router02, UniswapV2Migrator } from "src/migrators/UniswapV2Migrator.sol"; + +/** + * @title ForwardedUniswapV2Migrator + * @author Whetstone Research + * @notice UniswapV2Migrator variant whose airlock is set to a DistributionMigrator + * @dev This contract is identical to UniswapV2Migrator except it expects its airlock + * to be a DistributionMigrator rather than the real Airlock. This allows the + * DistributionMigrator to call initialize() and migrate() which are protected + * by onlyAirlock. + * @custom:security-contact security@whetstone.cc + */ +contract ForwardedUniswapV2Migrator is UniswapV2Migrator { + /** + * @notice Constructor + * @param distributor_ Address of the DistributionMigrator (acts as airlock for this contract) + * @param factory_ Address of the Uniswap V2 factory + * @param router_ Address of the Uniswap V2 router + * @param owner_ Address of the owner for the locker contract + */ + constructor( + address distributor_, + IUniswapV2Factory factory_, + IUniswapV2Router02 router_, + address owner_ + ) UniswapV2Migrator(distributor_, factory_, router_, owner_) { } +} diff --git a/src/migrators/distribution/ForwardedUniswapV4Migrator.sol b/src/migrators/distribution/ForwardedUniswapV4Migrator.sol new file mode 100644 index 000000000..c368a137c --- /dev/null +++ b/src/migrators/distribution/ForwardedUniswapV4Migrator.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.24; + +import { IHooks } from "@v4-core/interfaces/IHooks.sol"; +import { IPoolManager } from "@v4-core/interfaces/IPoolManager.sol"; +import { PositionManager } from "@v4-periphery/PositionManager.sol"; +import { StreamableFeesLocker } from "src/StreamableFeesLocker.sol"; +import { UniswapV4Migrator } from "src/migrators/UniswapV4Migrator.sol"; + +/** + * @title ForwardedUniswapV4Migrator + * @author Whetstone Research + * @notice UniswapV4Migrator variant whose airlock is set to a DistributionMigrator + * @dev This contract is identical to UniswapV4Migrator except it expects its airlock + * to be a DistributionMigrator rather than the real Airlock. This allows the + * DistributionMigrator to call initialize() and migrate() which are protected + * by onlyAirlock. + * + * IMPORTANT: The migratorHook passed to this contract MUST be deployed with + * this ForwardedUniswapV4Migrator as its migrator address. And the locker + * MUST have this ForwardedUniswapV4Migrator approved via approveMigrator(). + * + * @custom:security-contact security@whetstone.cc + */ +contract ForwardedUniswapV4Migrator is UniswapV4Migrator { + /** + * @notice Constructor + * @param distributor_ Address of the DistributionMigrator (acts as airlock for this contract) + * @param poolManager_ Address of the Uniswap V4 Pool Manager contract + * @param positionManager_ Address of the Uniswap V4 Position Manager contract + * @param locker_ Address of the Streamable Fees Locker contract + * @param migratorHook_ Address of the Uniswap V4 Migrator Hook contract + */ + constructor( + address distributor_, + IPoolManager poolManager_, + PositionManager positionManager_, + StreamableFeesLocker locker_, + IHooks migratorHook_ + ) UniswapV4Migrator(distributor_, poolManager_, positionManager_, locker_, migratorHook_) { } +} diff --git a/src/migrators/distribution/README.md b/src/migrators/distribution/README.md new file mode 100644 index 000000000..d74f72de8 --- /dev/null +++ b/src/migrators/distribution/README.md @@ -0,0 +1,223 @@ +# Distribution Migrator + +A wrapper migrator that distributes a configurable percentage of numeraire proceeds to a payout address before forwarding the remaining balances to an underlying liquidity migrator. + +## Overview + +The DistributionMigrator enables launch teams to receive a share of proceeds from token sales **without modifying the Airlock contract**. It acts as a middleware between Airlock and the actual liquidity migrator. + +``` +Airlock → DistributionMigrator → ForwardedUniswapV4Migrator → Uniswap V4 Pool + ↓ + Payout Address (receives % of numeraire) +``` + +## Contracts + +| Contract | Description | +|----------|-------------| +| `DistributionMigrator.sol` | Main wrapper that handles distribution logic | +| `ForwardedUniswapV2Migrator.sol` | V2 migrator with airlock pointing to DistributionMigrator | +| `ForwardedUniswapV4Migrator.sol` | V4 migrator with airlock pointing to DistributionMigrator | + +## Configuration + +### Initialization Parameters + +```solidity +(address payout, uint256 percentWad, address underlyingMigrator, bytes underlyingData) +``` + +| Parameter | Description | Constraints | +|-----------|-------------|-------------| +| `payout` | Address receiving distribution | Cannot be `address(0)` | +| `percentWad` | Distribution % in WAD (1e18 = 100%) | Max 50% (`5e17`) | +| `underlyingMigrator` | Forwarded migrator address | Must be whitelisted, must have `airlock == this` | +| `underlyingData` | Data forwarded to underlying `initialize()` | Format depends on underlying migrator | + +### Percentage Examples + +| Desired % | `percentWad` Value | +|-----------|-------------------| +| 1% | `1e16` | +| 5% | `5e16` | +| 10% | `1e17` | +| 25% | `25e16` | +| 50% (max) | `5e17` | + +## Deployment Checklist + +### 1. Deploy Contracts + +```bash +# Deploy DistributionMigrator pointing to real Airlock +DistributionMigrator distributor = new DistributionMigrator(airlockAddress); + +# Deploy ForwardedMigrator pointing to DistributionMigrator (NOT Airlock!) +ForwardedUniswapV4Migrator forwarded = new ForwardedUniswapV4Migrator( + address(distributor), // airlock = distributor + poolManager, + positionManager, + locker, + hook +); +``` + +### 2. Configure Locker (V4 only) + +```solidity +// Approve the FORWARDED migrator in the locker +locker.approveMigrator(address(forwardedMigrator), true); +``` + +### 3. Deploy Hook with Correct Migrator (V4 only) + +The hook MUST be deployed with `migrator = forwardedMigrator`: + +```solidity +// When deploying hook +hook.setMigrator(address(forwardedMigrator)); +``` + +### 4. Whitelist in Airlock + +```solidity +// Whitelist BOTH the distributor AND the forwarded migrator +address[] memory modules = new address[](2); +modules[0] = address(distributor); +modules[1] = address(forwardedMigrator); + +ModuleState[] memory states = new ModuleState[](2); +states[0] = ModuleState.LiquidityMigrator; +states[1] = ModuleState.LiquidityMigrator; + +airlock.setModuleState(modules, states); +``` + +### 5. Validate Deployment + +Run the validation script: + +```bash +DISTRIBUTION_MIGRATOR=0x... \ +FORWARDED_MIGRATOR=0x... \ +AIRLOCK=0x... \ +forge script script/ValidateDistributionMigrator.s.sol --rpc-url $RPC_URL +``` + +## Security Considerations + +### Known Limitations + +| Limitation | Risk | Mitigation | +|------------|------|------------| +| **ERC777 Tokens** | Reentrancy via `tokensReceived` hook | CEI pattern implemented; consider trusted payout addresses | +| **Fee-on-Transfer Tokens** | Underlying receives less than expected | Document limitation; tokens with fees not recommended | +| **Blocklisted Payout** | Transfer reverts if payout is blocklisted | Use non-blocklisted payout addresses | +| **Pausable Tokens** | Transfer reverts if token is paused | Monitor token status before migration | + +### Important: ForwardedMigrator Direct Use + +**ForwardedMigrator MUST NOT be used directly via Airlock.create().** + +Both DistributionMigrator and ForwardedMigrator are whitelisted in Airlock, but: +- **DistributionMigrator**: Can be used directly in `Airlock.create()` +- **ForwardedMigrator**: CANNOT be used directly - will revert with `SenderNotAirlock` + +This is because ForwardedMigrator's `airlock` is set to the DistributionMigrator, not the real Airlock. The `onlyAirlock` modifier will reject calls from the real Airlock. + +``` +✅ CORRECT: Airlock → DistributionMigrator → ForwardedMigrator +❌ WRONG: Airlock → ForwardedMigrator (reverts!) +``` + +**Why is ForwardedMigrator whitelisted then?** + +The DistributionMigrator validates that its underlying migrator is whitelisted by Airlock (trust validation). This requires the ForwardedMigrator to be in Airlock's whitelist, even though it cannot be called directly. + +### Access Control + +| Function | Access | Notes | +|----------|--------|-------| +| `initialize()` | `onlyAirlock` | Called during `Airlock.create()` | +| `migrate()` | `onlyAirlock` | Called during `Airlock.migrate()` | +| `receive()` | `onlyAirlock` | Accepts ETH only from Airlock | +| `owner()` | Public view | Returns `airlock.owner()` | + +### Invariants + +1. **Distribution ≤ 50%**: `percentWad <= MAX_DISTRIBUTION_WAD` +2. **Balance Conservation**: `distribution + forwarded == original_balance` +3. **No Stuck Funds**: After `migrate()`, distributor has 0 balance +4. **Asset Untouched**: Distribution only affects numeraire, never asset +5. **Token Pair Integrity**: Stored `(asset, numeraire)` must match provided `(token0, token1)` + +### Token Pair Validation + +This contract explicitly stores and validates both `asset` AND `numeraire` (rather than deriving numeraire at runtime): + +1. **Explicit Token Storage**: Both tokens are stored in the config during `initialize()` +2. **Strict Validation**: `migrate()` verifies the provided `(token0, token1)` matches the stored pair exactly +3. **Order-Independent Comparison**: Either ordering is accepted, but both tokens must match + +```solidity +// Defense-in-depth validation in migrate() +bool validPair = (config.asset == token0 && config.numeraire == token1) + || (config.asset == token1 && config.numeraire == token0); +if (!validPair) revert TokenPairMismatch(); +``` + +This prevents: +- Ordering spoofing (wrong tokens passed to `migrate()`) +- Distribution to wrong token if `poolInitializer` returns unexpected values +- Asset/numeraire confusion from storage corruption + +## Testing + +```bash +# Run unit tests +forge test --match-contract DistributionMigratorTest -vvv + +# Run with deep fuzzing +forge test --match-path "*distribution*" --fuzz-runs 10000 -v + +# Run integration tests (requires fork) +forge test --match-contract DistributionMigratorV4 -vvv +``` + +## Events + +```solidity +event Distribution( + address indexed payout, + address indexed numeraire, + uint256 amount, + uint256 percentWad +); + +event WrappedMigration( + address indexed underlying, + address indexed token0, + address indexed token1, + uint160 sqrtPriceX96 +); +``` + +## Errors + +| Error | Cause | +|-------|-------| +| `InvalidPayout()` | Payout address is zero | +| `InvalidUnderlying()` | Underlying is zero or self | +| `InvalidPercent()` | Percent exceeds 50% | +| `AlreadyInitialized()` | Config exists for token pair | +| `PoolNotInitialized()` | No config for token pair | +| `TokenPairMismatch()` | Provided tokens don't match stored (asset, numeraire) | +| `UnderlyingNotWhitelisted()` | Underlying not in Airlock whitelist | +| `UnderlyingNotForwarded()` | Underlying's airlock != this contract | +| `UnderlyingNotLockerApproved()` | V4 locker hasn't approved underlying | +| `UnderlyingHookMismatch()` | V4 hook's migrator != underlying | + +## Specification + +See [SPEC-distribution-migrator.md](../../../specs/SPEC-distribution-migrator.md) for the full specification. diff --git a/test/integration/LaunchVaultE2E.t.sol b/test/integration/LaunchVaultE2E.t.sol new file mode 100644 index 000000000..1eec43f0f --- /dev/null +++ b/test/integration/LaunchVaultE2E.t.sol @@ -0,0 +1,282 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import { Test } from "forge-std/Test.sol"; +import { console } from "forge-std/console.sol"; + +import { LaunchVault } from "src/LaunchVault.sol"; +import { Airlock, CreateParams, ModuleState } from "src/Airlock.sol"; +import { DERC20 } from "src/tokens/DERC20.sol"; + +/// @notice Simplified E2E test using real DERC20 +contract LaunchVaultE2ETest is Test { + // Contracts + Airlock airlock; + LaunchVault vault; + + // Test addresses + address owner = makeAddr("OWNER"); + address beneficiary = makeAddr("BENEFICIARY"); + address distributor = makeAddr("DISTRIBUTOR"); + + // Test params + uint256 constant INITIAL_SUPPLY = 1000000e18; + uint256 constant PREBUY_AMOUNT = 10000e18; + + function setUp() public { + vm.startPrank(owner); + + // Deploy Airlock + airlock = new Airlock(owner); + + // Deploy LaunchVault + vault = new LaunchVault(address(airlock)); + + // Set executors + vault.setTrustedExecutor(address(vault), true); + vault.setTrustedExecutor(owner, true); + + vm.stopPrank(); + } + + /// @notice Test full flow: Create DERC20 with vesting → Vault receives tokens → Release after unlock + function test_fullE2EFlow_DERC20Vesting() public { + vm.startPrank(owner); + + // Setup vesting arrays + address[] memory recipients = new address[](1); + recipients[0] = address(vault); // Vault is the vesting recipient + uint256[] memory amounts = new uint256[](1); + amounts[0] = PREBUY_AMOUNT; + + // Step 1: Create a real DERC20 token with vault as vesting recipient + DERC20 token = new DERC20( + "Test Token", // name + "TEST", // symbol + INITIAL_SUPPLY, // initialSupply + owner, // recipient (gets non-vested portion) + address(airlock), // owner (airlock owns the token) + 0, // yearlyMintRate (0 = no inflation) + 0, // vestingDuration (0 = instant vesting) + recipients, // vesting recipients + amounts, // vesting amounts + "" // tokenURI + ); + + address asset = address(token); + + console.log("Token created at:", asset); + (uint256 vaultVestingTotal, uint256 vaultReleased) = token.getVestingDataOf(address(vault)); + console.log("Vault vesting total:", vaultVestingTotal); + console.log("Vault released:", vaultReleased); + + // Step 2: Release tokens to vault (calling as vault) + vm.stopPrank(); + vm.prank(address(vault)); + token.release(); + vm.startPrank(owner); + + uint256 vaultBalance = token.balanceOf(address(vault)); + console.log("Vault balance after release():", vaultBalance); + assertEq(vaultBalance, PREBUY_AMOUNT, "Vault should have received vested tokens"); + + // Step 3: Record the deposit in vault + vault.depositPrebuy(asset, beneficiary, vaultBalance); + console.log("Deposit recorded:", vault.prebuyTotal(asset)); + assertEq(vault.prebuyTotal(asset), vaultBalance, "Deposit should be recorded"); + + // Step 4: Unlock the pool (must be called by airlock as owner) + vm.stopPrank(); + vm.prank(address(airlock)); + token.unlockPool(); + vm.startPrank(owner); + + bool isUnlocked = vault.isUnlocked(asset); + console.log("Is pool unlocked:", isUnlocked); + assertTrue(isUnlocked, "Pool should be unlocked"); + + // Step 5: Set distributor + vault.setDistributor(asset, distributor); + + // Step 6: Release tokens to distributor + uint256 distributorBalanceBefore = token.balanceOf(distributor); + vault.releaseToDistributor(asset); + uint256 distributorBalanceAfter = token.balanceOf(distributor); + + uint256 releasedAmount = distributorBalanceAfter - distributorBalanceBefore; + console.log("Released amount:", releasedAmount); + + // Step 7: Verify final state + assertEq(releasedAmount, vaultBalance, "All tokens should be released"); + assertEq(token.balanceOf(address(vault)), 0, "Vault should be empty"); + assertEq(vault.prebuyTotal(asset), 0, "Prebuy tracking should be cleared"); + + vm.stopPrank(); + } + + /// @notice Test that tokens can't be released before unlock + function test_release_RevertBeforeUnlock() public { + vm.startPrank(owner); + + // Setup vesting + address[] memory recipients = new address[](1); + recipients[0] = address(vault); + uint256[] memory amounts = new uint256[](1); + amounts[0] = PREBUY_AMOUNT; + + // Create token + DERC20 token = new DERC20( + "Test Token", + "TEST", + INITIAL_SUPPLY, + owner, + address(airlock), // airlock is owner + 0, + 0, + recipients, + amounts, + "" + ); + + address asset = address(token); + + // Release to vault + vm.stopPrank(); + vm.prank(address(vault)); + token.release(); + vm.startPrank(owner); + + uint256 vaultBalance = token.balanceOf(address(vault)); + + // Record deposit + vault.depositPrebuy(asset, beneficiary, vaultBalance); + vault.setDistributor(asset, distributor); + + // Try to release before unlock - should fail + assertFalse(vault.isUnlocked(asset), "Pool should be locked"); + + vm.expectRevert(LaunchVault.NotUnlocked.selector); + vault.releaseToDistributor(asset); + + // Now unlock (as airlock) and try again + vm.stopPrank(); + vm.prank(address(airlock)); + token.unlockPool(); + vm.startPrank(owner); + + assertTrue(vault.isUnlocked(asset), "Pool should be unlocked"); + + // Should succeed now + vault.releaseToDistributor(asset); + assertEq(token.balanceOf(distributor), vaultBalance); + + vm.stopPrank(); + } + + /// @notice Test the complete bundler callback flow simulation + function test_bundlerCallbackFlow_Simulation() public { + vm.startPrank(owner); + + // Create token with no vesting to owner + DERC20 token = new DERC20( + "Test Token", + "TEST", + INITIAL_SUPPLY, + owner, // tokens go to owner + address(airlock), // airlock owns the token + 0, + 0, + new address[](0), // no vesting + new uint256[](0), + "" + ); + + address asset = address(token); + uint256 prebuyAmount = 5000e18; + + // Step 1: Owner has tokens (simulating bundler after router) + assertEq(token.balanceOf(owner), INITIAL_SUPPLY, "Owner should have tokens"); + + // Step 2: Transfer to vault (simulating callback transfer) + token.transfer(address(vault), prebuyAmount); + assertEq(token.balanceOf(address(vault)), prebuyAmount, "Vault should have tokens"); + + // Step 3: Record deposit (simulating callback call) + vault.depositPrebuy(asset, beneficiary, prebuyAmount); + assertEq(vault.prebuyTotal(asset), prebuyAmount, "Should be recorded"); + + console.log("Bundler callback flow simulated successfully"); + console.log("Tokens in vault:", token.balanceOf(address(vault))); + console.log("Prebuy recorded:", vault.prebuyTotal(asset)); + + // Complete the release flow (unlock as airlock) + vm.stopPrank(); + vm.prank(address(airlock)); + token.unlockPool(); + vm.startPrank(owner); + + vault.setDistributor(asset, distributor); + vault.releaseToDistributor(asset); + + assertEq(token.balanceOf(distributor), prebuyAmount); + console.log("Tokens released to distributor"); + + vm.stopPrank(); + } + + /// @notice Test depositPrebuyFromRelease with DERC20 + function test_depositPrebuyFromRelease_E2E() public { + vm.startPrank(owner); + + // Setup vesting to vault + address[] memory recipients = new address[](1); + recipients[0] = address(vault); + uint256[] memory amounts = new uint256[](1); + amounts[0] = PREBUY_AMOUNT; + + // Create token + DERC20 token = new DERC20( + "Test Token", + "TEST", + INITIAL_SUPPLY, + owner, + address(airlock), // airlock is owner + 0, + 0, + recipients, + amounts, + "" + ); + + address asset = address(token); + + // Check that vault has vesting allocated + (uint256 vestingTotal, ) = token.getVestingDataOf(address(vault)); + console.log("Vesting allocated to vault:", vestingTotal); + assertEq(vestingTotal, PREBUY_AMOUNT); + + // Call depositPrebuyFromRelease + // This will call token.release() which transfers to vault, then records + vault.depositPrebuyFromRelease(asset, beneficiary, PREBUY_AMOUNT); + + // Verify + assertEq(vault.prebuyTotal(asset), PREBUY_AMOUNT, "Should be recorded"); + assertEq(token.balanceOf(address(vault)), PREBUY_AMOUNT, "Vault should have tokens"); + + console.log("depositPrebuyFromRelease successful"); + + // Complete the flow (unlock as airlock) + vm.stopPrank(); + vm.prank(address(airlock)); + token.unlockPool(); + vm.startPrank(owner); + + vault.setDistributor(asset, distributor); + vault.releaseToDistributor(asset); + + assertEq(token.balanceOf(distributor), PREBUY_AMOUNT); + console.log("Full flow completed"); + + vm.stopPrank(); + } +} diff --git a/test/integration/distribution/DistributionMigratorIntegration.t.sol b/test/integration/distribution/DistributionMigratorIntegration.t.sol new file mode 100644 index 000000000..4fc76ffc2 --- /dev/null +++ b/test/integration/distribution/DistributionMigratorIntegration.t.sol @@ -0,0 +1,717 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import { IHooks } from "@v4-core/interfaces/IHooks.sol"; +import { IPoolManager } from "@v4-core/interfaces/IPoolManager.sol"; +import { Hooks } from "@v4-core/libraries/Hooks.sol"; +import { TickMath } from "@v4-core/libraries/TickMath.sol"; +import { PoolSwapTest } from "@v4-core/test/PoolSwapTest.sol"; +import { TestERC20 } from "@v4-core/test/TestERC20.sol"; +import { Currency } from "@v4-core/types/Currency.sol"; +import { PoolKey } from "@v4-core/types/PoolKey.sol"; +import { PositionManager } from "@v4-periphery/PositionManager.sol"; +import { IPositionManager } from "@v4-periphery/interfaces/IPositionManager.sol"; +import { Vm } from "forge-std/Vm.sol"; + +import { Airlock, CreateParams, ModuleState } from "src/Airlock.sol"; +import { StreamableFeesLocker } from "src/StreamableFeesLocker.sol"; +import { NoOpGovernanceFactory } from "src/governance/NoOpGovernanceFactory.sol"; +import { Doppler } from "src/initializers/Doppler.sol"; +import { DopplerDeployer, UniswapV4Initializer } from "src/initializers/UniswapV4Initializer.sol"; +import { ILiquidityMigrator } from "src/interfaces/ILiquidityMigrator.sol"; +import { IDistributionTopUpSource } from "src/interfaces/IDistributionTopUpSource.sol"; +import { ITokenFactory } from "src/interfaces/ITokenFactory.sol"; +import { + IUniswapV2Factory, + IUniswapV2Pair, + IUniswapV2Router02, + UniswapV2Migrator +} from "src/migrators/UniswapV2Migrator.sol"; +import { UniswapV4Migrator } from "src/migrators/UniswapV4Migrator.sol"; +import { UniswapV4MigratorHook } from "src/migrators/UniswapV4MigratorHook.sol"; +import { DistributionMigrator, MAX_DISTRIBUTION_WAD, WAD } from "src/migrators/distribution/DistributionMigrator.sol"; +import { ForwardedUniswapV2Migrator } from "src/migrators/distribution/ForwardedUniswapV2Migrator.sol"; +import { ForwardedUniswapV4Migrator } from "src/migrators/distribution/ForwardedUniswapV4Migrator.sol"; +import { TokenFactory } from "src/tokens/TokenFactory.sol"; +import { BeneficiaryData } from "src/types/BeneficiaryData.sol"; + +import { + BaseIntegrationTest, + deployNoOpGovernanceFactory, + deployTokenFactory, + prepareTokenFactoryData +} from "test/integration/BaseIntegrationTest.sol"; +import { deployUniswapV4Initializer, preparePoolInitializerData } from "test/integration/UniswapV4Initializer.t.sol"; +import { UNISWAP_V2_FACTORY_MAINNET, UNISWAP_V2_ROUTER_MAINNET } from "test/shared/Addresses.sol"; +import { MineV4Params, mineV4 } from "test/shared/AirlockMiner.sol"; + +// ============================================================================= +// HELPER FUNCTIONS +// ============================================================================= + +/// @notice Deploys a DistributionMigrator and whitelists it in the Airlock +function deployDistributionMigrator( + Vm vm, + Airlock airlock, + address airlockOwner +) returns (DistributionMigrator distributor) { + distributor = new DistributionMigrator(address(airlock)); + + address[] memory modules = new address[](1); + modules[0] = address(distributor); + ModuleState[] memory states = new ModuleState[](1); + states[0] = ModuleState.LiquidityMigrator; + vm.prank(airlockOwner); + airlock.setModuleState(modules, states); +} + +/// @notice Deploys a ForwardedUniswapV2Migrator and whitelists it in the Airlock +function deployForwardedUniswapV2Migrator( + Vm vm, + Airlock airlock, + address airlockOwner, + address distributor, + address v2Factory, + address v2Router +) returns (ForwardedUniswapV2Migrator forwardedMigrator) { + forwardedMigrator = new ForwardedUniswapV2Migrator( + distributor, IUniswapV2Factory(v2Factory), IUniswapV2Router02(v2Router), airlockOwner + ); + + address[] memory modules = new address[](1); + modules[0] = address(forwardedMigrator); + ModuleState[] memory states = new ModuleState[](1); + states[0] = ModuleState.LiquidityMigrator; + vm.prank(airlockOwner); + airlock.setModuleState(modules, states); +} + +/// @notice Deploys a ForwardedUniswapV4Migrator with hook and locker, whitelists in Airlock +function deployForwardedUniswapV4Migrator( + Vm vm, + function(string memory, bytes memory, address) deployCodeTo, + Airlock airlock, + address airlockOwner, + address distributor, + address poolManager, + address positionManager +) + returns ( + StreamableFeesLocker locker, + UniswapV4MigratorHook migratorHook, + ForwardedUniswapV4Migrator forwardedMigrator + ) +{ + locker = new StreamableFeesLocker(IPositionManager(positionManager), airlockOwner); + + // Compute hook address with required flags + migratorHook = UniswapV4MigratorHook( + address( + uint160(Hooks.BEFORE_INITIALIZE_FLAG | Hooks.AFTER_ADD_LIQUIDITY_FLAG | Hooks.AFTER_REMOVE_LIQUIDITY_FLAG) + ^ (0x5555 << 144) // Different salt from regular V4 migrator + ) + ); + + forwardedMigrator = new ForwardedUniswapV4Migrator( + distributor, IPoolManager(poolManager), PositionManager(payable(positionManager)), locker, IHooks(migratorHook) + ); + + deployCodeTo( + "UniswapV4MigratorHook", abi.encode(address(poolManager), address(forwardedMigrator)), address(migratorHook) + ); + + // Whitelist and approve + address[] memory modules = new address[](1); + modules[0] = address(forwardedMigrator); + ModuleState[] memory states = new ModuleState[](1); + states[0] = ModuleState.LiquidityMigrator; + vm.startPrank(airlockOwner); + airlock.setModuleState(modules, states); + locker.approveMigrator(address(forwardedMigrator)); + vm.stopPrank(); +} + +/// @notice Simple numeraire-only top-up source used in integration tests +contract IntegrationTopUpSource is IDistributionTopUpSource { + TestERC20 public immutable numeraireToken; + mapping(address asset => uint256 amount) public available; + + constructor(TestERC20 numeraire_) { + numeraireToken = numeraire_; + } + + function fund(address asset, uint256 amount) external { + numeraireToken.transferFrom(msg.sender, address(this), amount); + available[asset] += amount; + } + + function pullTopUp(address asset, address numeraire) external override returns (uint256 amount) { + if (numeraire != address(numeraireToken)) return 0; + amount = available[asset]; + if (amount == 0) return 0; + available[asset] = 0; + numeraireToken.transfer(msg.sender, amount); + } +} + +/// @notice Encodes distribution migrator initialization data +function prepareDistributionMigratorData( + address payout, + uint256 percentWad, + address underlyingMigrator, + bytes memory underlyingData, + address topUpSource +) pure returns (bytes memory) { + return abi.encode(payout, percentWad, underlyingMigrator, underlyingData, topUpSource); +} + +/// @notice Prepares standard V4 migrator data with default beneficiaries +function prepareForwardedUniswapV4MigratorData(Airlock airlock) view returns (bytes memory) { + BeneficiaryData[] memory beneficiaries = new BeneficiaryData[](3); + beneficiaries[0] = BeneficiaryData({ beneficiary: airlock.owner(), shares: 0.05e18 }); + beneficiaries[1] = BeneficiaryData({ beneficiary: address(0xbeef), shares: 0.05e18 }); + beneficiaries[2] = BeneficiaryData({ beneficiary: address(0xb0b), shares: 0.9e18 }); + beneficiaries = sortBeneficiaries(beneficiaries); + + return abi.encode(2000, int24(8), 30 days, beneficiaries); +} + +/// @notice Sorts beneficiaries by address (required by V4 migrator) +function sortBeneficiaries(BeneficiaryData[] memory beneficiaries) pure returns (BeneficiaryData[] memory) { + uint256 length = beneficiaries.length; + for (uint256 i = 0; i < length - 1; i++) { + for (uint256 j = 0; j < length - i - 1; j++) { + if (uint160(beneficiaries[j].beneficiary) > uint160(beneficiaries[j + 1].beneficiary)) { + BeneficiaryData memory temp = beneficiaries[j]; + beneficiaries[j] = beneficiaries[j + 1]; + beneficiaries[j + 1] = temp; + } + } + } + return beneficiaries; +} + +// ============================================================================= +// BASE TEST CONTRACT +// ============================================================================= + +/// @notice Base contract for V4 distribution migrator integration tests +abstract contract DistributionMigratorV4BaseTest is BaseIntegrationTest { + DistributionMigrator public distributor; + ForwardedUniswapV4Migrator public forwardedV4Migrator; + StreamableFeesLocker public locker; + UniswapV4MigratorHook public migratorHook; + + /// @notice Sets up common V4 infrastructure without configuring the migrator + function _setupV4Infrastructure() internal { + // Deploy token factory + TokenFactory tokenFactory = deployTokenFactory(vm, airlock, AIRLOCK_OWNER); + createParams.tokenFactory = tokenFactory; + createParams.tokenFactoryData = + abi.encode("Test Token", "TEST", 0, 0, new address[](0), new uint256[](0), "TOKEN_URI"); + + // Deploy V4 initializer + (, UniswapV4Initializer initializer) = deployUniswapV4Initializer(vm, airlock, AIRLOCK_OWNER, address(manager)); + createParams.poolInitializer = initializer; + (bytes32 salt, bytes memory poolInitializerData) = preparePoolInitializerData( + address(airlock), + address(manager), + address(tokenFactory), + createParams.tokenFactoryData, + address(initializer) + ); + createParams.poolInitializerData = poolInitializerData; + createParams.salt = salt; + createParams.numTokensToSell = 1e23; + createParams.initialSupply = 1e23; + + // Deploy DistributionMigrator + distributor = deployDistributionMigrator(vm, airlock, AIRLOCK_OWNER); + + // Deploy ForwardedUniswapV4Migrator + (locker, migratorHook, forwardedV4Migrator) = deployForwardedUniswapV4Migrator( + vm, _deployCodeTo, airlock, AIRLOCK_OWNER, address(distributor), address(manager), address(positionManager) + ); + + // Deploy governance factory + NoOpGovernanceFactory governanceFactory = deployNoOpGovernanceFactory(vm, airlock, AIRLOCK_OWNER); + createParams.governanceFactory = governanceFactory; + } + + /// @notice Configures the distribution migrator with given parameters + function _configureDistributor(address payout, uint256 percentWad) internal { + _configureDistributorWithSource(payout, percentWad, address(0)); + } + + function _configureDistributorWithSource(address payout, uint256 percentWad, address topUpSource) internal { + bytes memory underlyingData = prepareForwardedUniswapV4MigratorData(airlock); + bytes memory distributionData = + prepareDistributionMigratorData(payout, percentWad, address(forwardedV4Migrator), underlyingData, topUpSource); + createParams.liquidityMigrator = distributor; + createParams.liquidityMigratorData = distributionData; + } + + /// @notice Performs swaps until the pool has enough proceeds to migrate + function _doSwapsUntilMigrateable() internal { + bool canMigrate; + uint256 i; + + do { + i++; + deal(address(this), 1 ether); + + (Currency currency0, Currency currency1, uint24 fee, int24 tickSpacing, IHooks hooks) = + Doppler(payable(pool)).poolKey(); + + swapRouter.swap{ value: 0.01 ether }( + PoolKey({ + currency0: currency0, currency1: currency1, hooks: hooks, fee: fee, tickSpacing: tickSpacing + }), + IPoolManager.SwapParams(true, -int256(0.01 ether), TickMath.MIN_SQRT_PRICE + 1), + PoolSwapTest.TestSettings(false, false), + "" + ); + + (,,, uint256 totalProceeds,,) = Doppler(payable(pool)).state(); + canMigrate = totalProceeds > Doppler(payable(pool)).minimumProceeds(); + + vm.warp(block.timestamp + 200); + } while (!canMigrate && i < 200); + + vm.warp(block.timestamp + 1 days); + } + + /// @notice Verifies migration completed successfully + function _assertMigrationComplete() internal view { + assertTrue( + PositionManager(payable(address(positionManager))).balanceOf(address(locker)) >= 1, + "Locker should have positions" + ); + } +} + +// ============================================================================= +// V2 INTEGRATION TEST (requires mainnet fork) +// ============================================================================= + +/** + * @title DistributionMigrator + UniswapV2 Integration Test + * @notice Tests full create → migrate flow with V2 underlying migrator + * @dev Requires MAINNET_RPC_URL environment variable + */ +contract DistributionMigratorV2IntegrationTest is BaseIntegrationTest { + DistributionMigrator public distributor; + ForwardedUniswapV2Migrator public forwardedV2Migrator; + + address public payout = address(0xCafe); + uint256 public percentWad = 1e17; // 10% + + TestERC20 internal numeraire; + IntegrationTopUpSource internal topUpSource; + + function setUp() public override { + vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), 21_093_509); + super.setUp(); + + name = "DistributionMigratorV2Integration"; + + numeraire = new TestERC20(0); + topUpSource = new IntegrationTopUpSource(numeraire); + + TokenFactory tokenFactory = deployTokenFactory(vm, airlock, AIRLOCK_OWNER); + createParams.tokenFactory = tokenFactory; + bytes32 salt = bytes32(uint256(456)); + (, bytes memory tokenFactoryData) = prepareTokenFactoryData(vm, address(airlock), address(tokenFactory), salt); + createParams.tokenFactoryData = tokenFactoryData; + createParams.salt = salt; + createParams.numTokensToSell = 1e23; + createParams.initialSupply = 1e23; + createParams.numeraire = address(numeraire); + + (, UniswapV4Initializer initializer) = deployUniswapV4Initializer(vm, airlock, AIRLOCK_OWNER, address(manager)); + createParams.poolInitializer = initializer; + (bytes32 minedSalt, bytes memory poolInitializerData) = preparePoolInitializerData( + address(airlock), address(manager), address(tokenFactory), tokenFactoryData, address(initializer) + ); + createParams.poolInitializerData = poolInitializerData; + createParams.salt = minedSalt; + + distributor = deployDistributionMigrator(vm, airlock, AIRLOCK_OWNER); + forwardedV2Migrator = deployForwardedUniswapV2Migrator( + vm, airlock, AIRLOCK_OWNER, address(distributor), UNISWAP_V2_FACTORY_MAINNET, UNISWAP_V2_ROUTER_MAINNET + ); + + bytes memory distributionData = + prepareDistributionMigratorData(payout, percentWad, address(forwardedV2Migrator), "", address(0)); + createParams.liquidityMigrator = distributor; + createParams.liquidityMigratorData = distributionData; + + NoOpGovernanceFactory governanceFactory = deployNoOpGovernanceFactory(vm, airlock, AIRLOCK_OWNER); + createParams.governanceFactory = governanceFactory; + } + + function _beforeMigrate() internal override { + bool canMigrate; + uint256 i; + + do { + i++; + numeraire.mint(address(this), 1 ether); + numeraire.approve(address(swapRouter), type(uint256).max); + + (Currency currency0, Currency currency1, uint24 fee, int24 tickSpacing, IHooks hooks) = + Doppler(payable(pool)).poolKey(); + + bool zeroForOne = Currency.unwrap(currency0) == address(numeraire); + + swapRouter.swap( + PoolKey({ + currency0: currency0, currency1: currency1, hooks: hooks, fee: fee, tickSpacing: tickSpacing + }), + IPoolManager.SwapParams( + zeroForOne, + int256(0.1 ether), + zeroForOne ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1 + ), + PoolSwapTest.TestSettings(false, false), + "" + ); + + (,,, uint256 totalProceeds,,) = Doppler(payable(pool)).state(); + canMigrate = totalProceeds > Doppler(payable(pool)).minimumProceeds(); + + vm.warp(block.timestamp + 200); + } while (!canMigrate && i < 100); + + vm.warp(block.timestamp + 1 days); + } + + function test_fullFlow_V2_WithDistribution() public { + (asset, pool, governance, timelock, migrationPool) = airlock.create(createParams); + + uint256 payoutBalanceBefore = numeraire.balanceOf(payout); + _beforeMigrate(); + airlock.migrate(asset); + + assertTrue(numeraire.balanceOf(payout) > payoutBalanceBefore, "Payout should have received distribution"); + + address v2Pair = IUniswapV2Factory(UNISWAP_V2_FACTORY_MAINNET).getPair(asset, address(numeraire)); + assertTrue(v2Pair != address(0), "V2 pair should exist"); + assertTrue(IUniswapV2Pair(v2Pair).totalSupply() > 0, "V2 pair should have liquidity"); + } + + function test_fullFlow_V2_WithTopUpSource() public { + createParams.liquidityMigratorData = + prepareDistributionMigratorData(payout, percentWad, address(forwardedV2Migrator), "", address(topUpSource)); + + (asset, pool, governance, timelock, migrationPool) = airlock.create(createParams); + + uint256 topUpAmount = 1_000 ether; + numeraire.mint(address(this), topUpAmount); + numeraire.approve(address(topUpSource), topUpAmount); + topUpSource.fund(asset, topUpAmount); + + uint256 payoutBalanceBefore = numeraire.balanceOf(payout); + _beforeMigrate(); + uint256 proceedsBefore = numeraire.balanceOf(address(distributor)); + airlock.migrate(asset); + + uint256 expectedDistribution = (proceedsBefore * percentWad) / WAD; + assertEq(numeraire.balanceOf(payout) - payoutBalanceBefore, expectedDistribution, "Top-up should not be skimmed"); + assertEq(topUpSource.available(asset), 0, "Top-up source should be drained"); + } +} + +// ============================================================================= +// V4 INTEGRATION TEST +// ============================================================================= + +/** + * @title DistributionMigrator + UniswapV4 Integration Test + * @notice Tests full create → migrate flow with V4 underlying migrator + */ +contract DistributionMigratorV4IntegrationTest is DistributionMigratorV4BaseTest { + address public payout = address(0xCafe); + uint256 public percentWad = 1e17; // 10% + + function setUp() public override { + super.setUp(); + name = "DistributionMigratorV4Integration"; + _setupV4Infrastructure(); + _configureDistributor(payout, percentWad); + } + + function _beforeMigrate() internal override { + _doSwapsUntilMigrateable(); + } + + function test_fullFlow_V4_WithDistribution() public { + (asset, pool, governance, timelock, migrationPool) = airlock.create(createParams); + + uint256 payoutBalanceBefore = payout.balance; + _beforeMigrate(); + airlock.migrate(asset); + + assertTrue(payout.balance > payoutBalanceBefore, "Payout should have received ETH distribution"); + _assertMigrationComplete(); + } + + function test_fullFlow_V4_ZeroFunds_ShouldRevert() public { + (asset, pool, governance, timelock, migrationPool) = airlock.create(createParams); + + // Skip swaps, just warp past auction end + vm.warp(block.timestamp + 2 days); + + vm.expectRevert(); + airlock.migrate(asset); + } +} + +// ============================================================================= +// DISTRIBUTION CALCULATION TEST +// ============================================================================= + +/** + * @title Distribution Calculation Test + * @notice Verifies exact distribution amounts with different percentages + */ +contract DistributionCalculationTest is DistributionMigratorV4BaseTest { + address public payout = address(0xCafe); + + function setUp() public override { + super.setUp(); + name = "DistributionCalculationTest"; + _setupV4Infrastructure(); + _configureDistributor(payout, 1e17); // Default 10% + } + + function _beforeMigrate() internal override { + _doSwapsUntilMigrateable(); + } + + function test_distributionCalculation_50Percent() public { + _configureDistributor(payout, 5e17); // 50% + + (asset, pool, governance, timelock, migrationPool) = airlock.create(createParams); + + uint256 totalSwapped; + bool canMigrate; + uint256 i; + + do { + i++; + deal(address(this), 0.1 ether); + + (Currency currency0, Currency currency1, uint24 fee, int24 tickSpacing, IHooks hooks) = + Doppler(payable(pool)).poolKey(); + + swapRouter.swap{ value: 0.01 ether }( + PoolKey({ + currency0: currency0, currency1: currency1, hooks: hooks, fee: fee, tickSpacing: tickSpacing + }), + IPoolManager.SwapParams(true, -int256(0.01 ether), TickMath.MIN_SQRT_PRICE + 1), + PoolSwapTest.TestSettings(false, false), + "" + ); + totalSwapped += 0.01 ether; + + (,,, uint256 totalProceeds,,) = Doppler(payable(pool)).state(); + canMigrate = totalProceeds > Doppler(payable(pool)).minimumProceeds(); + + vm.warp(block.timestamp + 200); + } while (!canMigrate && i < 200); + + vm.warp(block.timestamp + 1 days); + + uint256 payoutBalanceBefore = payout.balance; + airlock.migrate(asset); + + uint256 distribution = payout.balance - payoutBalanceBefore; + assertTrue(distribution > 0, "Distribution should be > 0"); + + emit log_named_uint("Total swapped", totalSwapped); + emit log_named_uint("Distribution received", distribution); + } +} + +// ============================================================================= +// FUZZ TESTS +// ============================================================================= + +/** + * @title Distribution Migrator Fuzz Tests + * @notice Fuzz tests for the full flow with varying parameters + * @dev Tests invariants: + * - Distribution > 0 for percentWad > 0 (above dust threshold) + * - Any valid EOA can receive distribution + * - Migration always completes after distribution + */ +contract DistributionMigratorFuzzTest is DistributionMigratorV4BaseTest { + function setUp() public override { + super.setUp(); + name = "DistributionMigratorFuzzTest"; + _setupV4Infrastructure(); + _configureDistributor(address(0xCafe), 1e17); // Default config + } + + function _beforeMigrate() internal override { + _doSwapsUntilMigrateable(); + } + + /// @notice Tests distribution calculation with varying percentages + /// @dev Minimum 0.1% to ensure non-zero distribution after rounding + function testFuzz_fullFlow_VaryingPercent(uint256 percentWad) public { + percentWad = bound(percentWad, 1e15, MAX_DISTRIBUTION_WAD); // 0.1% to 50% + + address payoutAddr = address(0xF0001); + _configureDistributor(payoutAddr, percentWad); + + (asset, pool, governance, timelock, migrationPool) = airlock.create(createParams); + _doSwapsUntilMigrateable(); + + uint256 payoutBalanceBefore = payoutAddr.balance; + airlock.migrate(asset); + + assertTrue(payoutAddr.balance > payoutBalanceBefore, "Distribution should be > 0"); + _assertMigrationComplete(); + } + + /// @notice Tests that any valid EOA can receive distribution + function testFuzz_fullFlow_VaryingPayout(address payoutAddr) public { + // Exclude invalid addresses + vm.assume(payoutAddr != address(0)); + vm.assume(uint160(payoutAddr) > 0x100); + vm.assume(payoutAddr != address(this)); + vm.assume(payoutAddr != address(airlock)); + vm.assume(payoutAddr != address(distributor)); + vm.assume(payoutAddr != address(manager)); + vm.assume(payoutAddr != address(permit2)); + vm.assume(payoutAddr != address(positionManager)); + vm.assume(payoutAddr != address(swapRouter)); + vm.assume(payoutAddr.code.length == 0); // EOA only + + _configureDistributor(payoutAddr, 1e17); + + (asset, pool, governance, timelock, migrationPool) = airlock.create(createParams); + _doSwapsUntilMigrateable(); + + uint256 payoutBalanceBefore = payoutAddr.balance; + airlock.migrate(asset); + + assertTrue(payoutAddr.balance > payoutBalanceBefore, "Payout should have received distribution"); + } + + /// @notice Tests distribution with varying swap amounts + function testFuzz_fullFlow_VaryingSwapAmount(uint256 swapAmount) public { + swapAmount = bound(swapAmount, 0.005 ether, 0.1 ether); + + address payoutAddr = address(0xF0003); + _configureDistributor(payoutAddr, 25e16); // 25% + + (asset, pool, governance, timelock, migrationPool) = airlock.create(createParams); + + // Custom swap loop with fuzzed amount + bool canMigrate; + uint256 i; + + do { + i++; + deal(address(this), 10 ether); + + (Currency currency0, Currency currency1, uint24 fee, int24 tickSpacing, IHooks hooks) = + Doppler(payable(pool)).poolKey(); + + swapRouter.swap{ value: swapAmount }( + PoolKey({ + currency0: currency0, currency1: currency1, hooks: hooks, fee: fee, tickSpacing: tickSpacing + }), + IPoolManager.SwapParams(true, -int256(swapAmount), TickMath.MIN_SQRT_PRICE + 1), + PoolSwapTest.TestSettings(false, false), + "" + ); + + (,,, uint256 totalProceeds,,) = Doppler(payable(pool)).state(); + canMigrate = totalProceeds > Doppler(payable(pool)).minimumProceeds(); + + vm.warp(block.timestamp + 200); + } while (!canMigrate && i < 200); + + vm.warp(block.timestamp + 1 days); + + uint256 payoutBalanceBefore = payoutAddr.balance; + airlock.migrate(asset); + + assertTrue(payoutAddr.balance > payoutBalanceBefore, "Distribution should be > 0"); + } + + /// @notice Tests varying percentage with extra swaps after minimum proceeds + function testFuzz_fullFlow_PercentAndIterations(uint256 percentWad, uint8 extraSwaps) public { + percentWad = bound(percentWad, 1e16, MAX_DISTRIBUTION_WAD); // 1% to 50% + extraSwaps = uint8(bound(extraSwaps, 0, 5)); + + address payoutAddr = address(0xF0004); + _configureDistributor(payoutAddr, percentWad); + + (asset, pool, governance, timelock, migrationPool) = airlock.create(createParams); + _doSwapsUntilMigrateable(); + + // Extra swaps (may fail if pool exhausted) + for (uint256 j = 0; j < extraSwaps; j++) { + deal(address(this), 1 ether); + + (Currency currency0, Currency currency1, uint24 fee, int24 tickSpacing, IHooks hooks) = + Doppler(payable(pool)).poolKey(); + + try swapRouter.swap{ value: 0.01 ether }( + PoolKey({ + currency0: currency0, currency1: currency1, hooks: hooks, fee: fee, tickSpacing: tickSpacing + }), + IPoolManager.SwapParams(true, -int256(0.01 ether), TickMath.MIN_SQRT_PRICE + 1), + PoolSwapTest.TestSettings(false, false), + "" + ) { + vm.warp(block.timestamp + 200); + } catch { + break; // Pool exhausted + } + } + + vm.warp(block.timestamp + 1 days); + + uint256 payoutBalanceBefore = payoutAddr.balance; + airlock.migrate(asset); + + assertTrue(payoutAddr.balance > payoutBalanceBefore, "Distribution should be > 0"); + _assertMigrationComplete(); + } + + /// @notice Edge case: 0% distribution should give nothing to payout + function testFuzz_fullFlow_ZeroPercent() public { + address payoutAddr = address(0xF0005); + _configureDistributor(payoutAddr, 0); + + (asset, pool, governance, timelock, migrationPool) = airlock.create(createParams); + _doSwapsUntilMigrateable(); + + uint256 payoutBalanceBefore = payoutAddr.balance; + airlock.migrate(asset); + + assertEq(payoutAddr.balance, payoutBalanceBefore, "Payout should receive nothing with 0%"); + _assertMigrationComplete(); + } + + /// @notice Edge case: 50% (max) distribution + function testFuzz_fullFlow_MaxPercent() public { + address payoutAddr = address(0xF0006); + _configureDistributor(payoutAddr, MAX_DISTRIBUTION_WAD); + + (asset, pool, governance, timelock, migrationPool) = airlock.create(createParams); + _doSwapsUntilMigrateable(); + + uint256 payoutBalanceBefore = payoutAddr.balance; + airlock.migrate(asset); + + assertTrue(payoutAddr.balance > payoutBalanceBefore, "Distribution should be > 0 at 50%"); + _assertMigrationComplete(); + } +} diff --git a/test/shared/Addresses.sol b/test/shared/Addresses.sol index f355687a1..d056c6223 100644 --- a/test/shared/Addresses.sol +++ b/test/shared/Addresses.sol @@ -22,6 +22,7 @@ address constant UNISWAP_V3_ROUTER_BASE = 0x2626664c2603336E57B271c5C0b26F421741 address constant UNISWAP_V3_POSITION_MANAGER_BASE = 0x03a520b32C04BF3bEEf7BEb72E919cf822Ed34f1; address constant UNISWAP_V4_POOL_MANAGER_BASE = 0x498581fF718922c3f8e6A244956aF099B2652b2b; address constant UNISWAP_V4_POSITION_MANAGER_BASE = 0x7C5f5A4bBd8fD63184577525326123B519429bDc; +address constant UNISWAP_V4_QUOTER_BASE = 0x0d5e0F971ED27FBfF6c2837bf31316121532048D; address constant UNISWAP_V2_FACTORY_BASE = 0x8909Dc15e40173Ff4699343b6eB8132c65e18eC6; address constant UNISWAP_V2_ROUTER_BASE = 0x4752ba5DBc23f44D87826276BF6Fd6b1C372aD24; @@ -29,8 +30,12 @@ address constant UNISWAP_V2_ROUTER_BASE = 0x4752ba5DBc23f44D87826276BF6Fd6b1C372 address constant UNISWAP_V3_FACTORY_BASE_SEPOLIA = 0x4752ba5DBc23f44D87826276BF6Fd6b1C372aD24; address constant UNISWAP_V4_POOL_MANAGER_BASE_SEPOLIA = 0x05E73354cFDd6745C338b50BcFDfA3Aa6fA03408; address constant UNISWAP_V4_POSITION_MANAGER_BASE_SEPOLIA = 0x4B2C77d209D3405F41a037Ec6c77F7F5b8e2ca80; +address constant UNISWAP_V4_QUOTER_BASE_SEPOLIA = 0x4A6513c898fe1B2d0E78d3b0e0A4a151589B1cBa; // Note: Need to find V2 addresses for Base Sepolia if available +// Unichain Sepolia (Chain ID 1301) +address constant UNISWAP_V4_QUOTER_UNICHAIN_SEPOLIA = 0x56DCD40A3F2d466F48e7F48bDBE5Cc9B92Ae4472; + // Monad Testnet address constant UNISWAP_V2_FACTORY_MONAD_TESTNET = 0x733E88f248b742db6C14C0b1713Af5AD7fDd59D0; address constant UNISWAP_V2_ROUTER_MONAD_TESTNET = 0xfB8e1C3b833f9E67a71C859a132cf783b645e436; diff --git a/test/unit/Bundler.t.sol b/test/unit/Bundler.t.sol index 770a14859..68dd340ef 100644 --- a/test/unit/Bundler.t.sol +++ b/test/unit/Bundler.t.sol @@ -5,6 +5,7 @@ import { Create2 } from "@openzeppelin/utils/Create2.sol"; import { UniversalRouter } from "@universal-router/UniversalRouter.sol"; import { Commands } from "@universal-router/libraries/Commands.sol"; import { IQuoterV2 } from "@v3-periphery/interfaces/IQuoterV2.sol"; +import { IV4Quoter } from "@v4-periphery/interfaces/IV4Quoter.sol"; import { Test } from "forge-std/Test.sol"; import { Airlock, ModuleState } from "src/Airlock.sol"; import { CreateParams } from "src/Airlock.sol"; @@ -24,12 +25,15 @@ address constant WETH = 0x4200000000000000000000000000000000000006; contract BundlerTest is Test { Bundler bundler; TokenFactory tokenFactory; + DummyV4Quoter dummyV4Quoter; receive() external payable { } function setUp() public { vm.createSelectFork(vm.envString("UNICHAIN_MAINNET_RPC_URL"), 10_594_210); - bundler = new Bundler(Airlock(airlock), UniversalRouter(ur), IQuoterV2(quoterV2)); + dummyV4Quoter = new DummyV4Quoter(); + bundler = + new Bundler(Airlock(airlock), UniversalRouter(ur), IQuoterV2(quoterV2), IV4Quoter(address(dummyV4Quoter))); tokenFactory = new TokenFactory(airlock); vm.prank(Airlock(airlock).owner()); @@ -99,3 +103,21 @@ contract BundlerTest is Test { assertGt(DERC20(asset).balanceOf(address(this)), 0, "Wrong asset balance"); } } + +contract DummyV4Quoter { + function quoteExactOutputSingle(IV4Quoter.QuoteExactSingleParams memory) + external + pure + returns (uint256 amountIn, uint256 gasEstimate) + { + return (0, 0); + } + + function quoteExactInputSingle(IV4Quoter.QuoteExactSingleParams memory) + external + pure + returns (uint256 amountOut, uint256 gasEstimate) + { + return (0, 0); + } +} diff --git a/test/unit/BundlerCallback.t.sol b/test/unit/BundlerCallback.t.sol new file mode 100644 index 000000000..0bd9be955 --- /dev/null +++ b/test/unit/BundlerCallback.t.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import { Test } from "forge-std/Test.sol"; + +import { Bundler } from "src/Bundler.sol"; +import { IBundleCallback, CreateResult, Transfer, Call } from "src/interfaces/IBundleCallback.sol"; +import { Airlock, CreateParams } from "src/Airlock.sol"; + +// Simplified unit tests for BundlerCallback +// Full integration tests are in test/integration/LaunchVaultFlow.t.sol + +contract BundlerCallbackTest is Test { + + function test_bundleWithPlan_Exists() public { + // This test just verifies the function exists + // The actual functionality is tested in the integration tests + + // Check that IBundleCallback interface is properly defined + assertTrue(isInterfaceDefined()); + } + + function isInterfaceDefined() internal pure returns (bool) { + // Interface is defined if compilation succeeds + return true; + } +} diff --git a/test/unit/BundlerEmptyCallback.t.sol b/test/unit/BundlerEmptyCallback.t.sol new file mode 100644 index 000000000..7927380c7 --- /dev/null +++ b/test/unit/BundlerEmptyCallback.t.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import { Test } from "forge-std/Test.sol"; + +import { IBundleCallback, CreateResult, Transfer, Call } from "src/interfaces/IBundleCallback.sol"; + +/// @notice Test callback that returns empty lists +contract EmptyCallback is IBundleCallback { + function plan(CreateResult calldata, bytes calldata) + external + pure + returns (Transfer[] memory transfers, Call[] memory calls) + { + // Return empty arrays - this should work fine in bundler + return (new Transfer[](0), new Call[](0)); + } +} + +/// @notice Test to verify empty callback behavior +contract BundlerEmptyCallbackTest is Test { + EmptyCallback emptyCallback; + + function setUp() public { + emptyCallback = new EmptyCallback(); + } + + function test_emptyCallback_ReturnsEmptyArrays() public { + // Create dummy CreateResult + CreateResult memory result = CreateResult({ + asset: address(0x1234), + pool: address(0x5678), + governance: address(0x9ABC), + timelock: address(0xDEF0), + migrationPool: address(0x1111) + }); + + // Call plan with empty callback data + (Transfer[] memory transfers, Call[] memory calls) = emptyCallback.plan(result, ""); + + // Verify both arrays are empty + assertEq(transfers.length, 0); + assertEq(calls.length, 0); + } + + function test_emptyCallback_CanBeCalledMultipleTimes() public { + CreateResult memory result = CreateResult({ + asset: address(0x1234), + pool: address(0x5678), + governance: address(0x9ABC), + timelock: address(0xDEF0), + migrationPool: address(0x1111) + }); + + // Call multiple times - should always return empty + for (uint256 i = 0; i < 5; i++) { + (Transfer[] memory transfers, Call[] memory calls) = emptyCallback.plan(result, ""); + assertEq(transfers.length, 0); + assertEq(calls.length, 0); + } + } +} diff --git a/test/unit/BundlerMulticurve.t.sol b/test/unit/BundlerMulticurve.t.sol new file mode 100644 index 000000000..8e49c856f --- /dev/null +++ b/test/unit/BundlerMulticurve.t.sol @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import { Test } from "forge-std/Test.sol"; + +import { Bundler } from "src/Bundler.sol"; +import { Airlock, CreateParams } from "src/Airlock.sol"; +import { ITokenFactory } from "src/tokens/TokenFactory.sol"; +import { IGovernanceFactory } from "src/interfaces/IGovernanceFactory.sol"; +import { IPoolInitializer } from "src/interfaces/IPoolInitializer.sol"; +import { ILiquidityMigrator } from "src/interfaces/ILiquidityMigrator.sol"; +import { PoolKey } from "@v4-core/types/PoolKey.sol"; +import { Currency } from "@v4-core/types/Currency.sol"; +import { IHooks } from "@v4-core/interfaces/IHooks.sol"; +import { IV4Quoter } from "@v4-periphery/interfaces/IV4Quoter.sol"; +import { PoolStatus } from "src/initializers/UniswapV4MulticurveInitializer.sol"; +import { UniversalRouter } from "@universal-router/UniversalRouter.sol"; +import { IQuoterV2 } from "@v3-periphery/interfaces/IQuoterV2.sol"; + +contract BundlerMulticurveTest is Test { + Bundler bundler; + MockAirlock mockAirlock; + MockUniversalRouter mockRouter; + MockQuoter mockQuoter; + MockMulticurveInitializer mockInitializer; + MockV4Quoter mockV4Quoter; + + function setUp() public { + mockAirlock = new MockAirlock(); + mockRouter = new MockUniversalRouter(); + mockQuoter = new MockQuoter(); + mockV4Quoter = new MockV4Quoter(); + bundler = new Bundler( + Airlock(payable(address(mockAirlock))), + UniversalRouter(payable(address(mockRouter))), + IQuoterV2(address(mockQuoter)), + IV4Quoter(address(mockV4Quoter)) + ); + mockInitializer = new MockMulticurveInitializer(); + } + + function test_simulateMulticurveBundleExactOut() public { + address expectedAsset = address(0x1111); + address expectedNumeraire = address(0x2222); + mockAirlock.setAsset(expectedAsset); + + PoolKey memory expectedKey = PoolKey({ + currency0: Currency.wrap(expectedAsset), + currency1: Currency.wrap(expectedNumeraire), + hooks: IHooks(address(0x3333)), + fee: 500, + tickSpacing: 60 + }); + mockInitializer.setPoolKey(expectedKey); + + CreateParams memory createParams = CreateParams({ + initialSupply: 1, + numTokensToSell: 1, + numeraire: expectedNumeraire, + tokenFactory: ITokenFactory(address(0x1)), + tokenFactoryData: new bytes(0), + governanceFactory: IGovernanceFactory(address(0x2)), + governanceFactoryData: new bytes(0), + poolInitializer: IPoolInitializer(address(mockInitializer)), + poolInitializerData: new bytes(0), + liquidityMigrator: ILiquidityMigrator(address(0x3)), + liquidityMigratorData: new bytes(0), + integrator: address(0x4), + salt: bytes32(uint256(0x1234)) + }); + + mockV4Quoter.setExactOutResponse(321, 654); + + (address asset, PoolKey memory poolKey, uint256 amountIn, uint256 gasEstimate) = + bundler.simulateMulticurveBundleExactOut(createParams, 1, bytes("")); + + assertEq(asset, expectedAsset, "wrong asset"); + assertEq(Currency.unwrap(poolKey.currency0), Currency.unwrap(expectedKey.currency0), "wrong currency0"); + assertEq(Currency.unwrap(poolKey.currency1), Currency.unwrap(expectedKey.currency1), "wrong currency1"); + assertEq(address(poolKey.hooks), address(expectedKey.hooks), "wrong hooks"); + assertEq(poolKey.fee, expectedKey.fee, "wrong fee"); + assertEq(poolKey.tickSpacing, expectedKey.tickSpacing, "wrong tick spacing"); + assertEq(amountIn, 321, "wrong amountIn"); + assertEq(gasEstimate, 654, "wrong gas estimate"); + assertTrue(mockV4Quoter.lastWasExactOut(), "wrong quote type"); + assertEq(mockV4Quoter.lastZeroForOne(), false, "wrong direction"); + assertEq(mockV4Quoter.lastExactAmount(), 1, "wrong exact amount"); + assertEq(mockV4Quoter.lastHookDataLength(), 0, "hook data should be empty"); + + PoolKey memory recorded = mockV4Quoter.lastPoolKey(); + assertEq(Currency.unwrap(recorded.currency0), Currency.unwrap(expectedKey.currency0), "recorded currency0"); + assertEq(Currency.unwrap(recorded.currency1), Currency.unwrap(expectedKey.currency1), "recorded currency1"); + assertEq(address(recorded.hooks), address(expectedKey.hooks), "recorded hooks"); + assertEq(recorded.fee, expectedKey.fee, "recorded fee"); + assertEq(recorded.tickSpacing, expectedKey.tickSpacing, "recorded tick spacing"); + } + + function test_simulateMulticurveBundleExactIn() public { + address expectedAsset = address(0x1111); + address expectedNumeraire = address(0x2222); + mockAirlock.setAsset(expectedAsset); + + PoolKey memory expectedKey = PoolKey({ + currency0: Currency.wrap(expectedAsset), + currency1: Currency.wrap(expectedNumeraire), + hooks: IHooks(address(0x3333)), + fee: 500, + tickSpacing: 60 + }); + mockInitializer.setPoolKey(expectedKey); + + CreateParams memory createParams = CreateParams({ + initialSupply: 1, + numTokensToSell: 1, + numeraire: expectedNumeraire, + tokenFactory: ITokenFactory(address(0x1)), + tokenFactoryData: new bytes(0), + governanceFactory: IGovernanceFactory(address(0x2)), + governanceFactoryData: new bytes(0), + poolInitializer: IPoolInitializer(address(mockInitializer)), + poolInitializerData: new bytes(0), + liquidityMigrator: ILiquidityMigrator(address(0x3)), + liquidityMigratorData: new bytes(0), + integrator: address(0x4), + salt: bytes32(uint256(0x5678)) + }); + + mockV4Quoter.setExactInResponse(123, 999); + bytes memory hookData = bytes("hook"); + + (address asset, PoolKey memory poolKey, uint256 amountOut, uint256 gasEstimate) = + bundler.simulateMulticurveBundleExactIn(createParams, 5, hookData); + + assertEq(asset, expectedAsset, "wrong asset"); + assertEq(Currency.unwrap(poolKey.currency0), Currency.unwrap(expectedKey.currency0), "wrong currency0"); + assertEq(Currency.unwrap(poolKey.currency1), Currency.unwrap(expectedKey.currency1), "wrong currency1"); + assertTrue(mockV4Quoter.lastWasExactIn(), "wrong quote type"); + assertEq(amountOut, 123, "wrong amountOut"); + assertEq(gasEstimate, 999, "wrong gas estimate"); + assertEq(mockV4Quoter.lastExactAmount(), 5, "wrong exact amount"); + assertEq(keccak256(mockV4Quoter.lastHookData()), keccak256(hookData), "wrong hook data"); + } +} + +contract MockAirlock { + address internal asset; + + function setAsset(address asset_) external { + asset = asset_; + } + + function create( + CreateParams calldata + ) external view returns (address asset_, address, address, address, address) { + asset_ = asset; + return (asset_, address(0), address(0), address(0), address(0)); + } +} + +contract MockUniversalRouter { } + +contract MockQuoter { } + +contract MockV4Quoter { + enum QuoteType { None, ExactOut, ExactIn } + + PoolKey internal storedPoolKey; + bool internal storedZeroForOne; + uint128 internal storedExactAmount; + bytes internal storedHookData; + uint256 internal exactOutAmountIn; + uint256 internal exactOutGas; + uint256 internal exactInAmountOut; + uint256 internal exactInGas; + QuoteType internal lastQuoteType; + + function setExactOutResponse(uint256 amountIn_, uint256 gasEstimate) external { + exactOutAmountIn = amountIn_; + exactOutGas = gasEstimate; + } + + function setExactInResponse(uint256 amountOut_, uint256 gasEstimate) external { + exactInAmountOut = amountOut_; + exactInGas = gasEstimate; + } + + function quoteExactOutputSingle(IV4Quoter.QuoteExactSingleParams memory params) + external + returns (uint256 amountIn, uint256 gasEstimate) + { + lastQuoteType = QuoteType.ExactOut; + storedPoolKey = params.poolKey; + storedZeroForOne = params.zeroForOne; + storedExactAmount = params.exactAmount; + storedHookData = params.hookData; + return (exactOutAmountIn, exactOutGas); + } + + function quoteExactInputSingle(IV4Quoter.QuoteExactSingleParams memory params) + external + returns (uint256 amountOut, uint256 gasEstimate) + { + lastQuoteType = QuoteType.ExactIn; + storedPoolKey = params.poolKey; + storedZeroForOne = params.zeroForOne; + storedExactAmount = params.exactAmount; + storedHookData = params.hookData; + return (exactInAmountOut, exactInGas); + } + + function lastPoolKey() external view returns (PoolKey memory) { + return storedPoolKey; + } + + function lastZeroForOne() external view returns (bool) { + return storedZeroForOne; + } + + function lastExactAmount() external view returns (uint128) { + return storedExactAmount; + } + + function lastHookDataLength() external view returns (uint256) { + return storedHookData.length; + } + + function lastHookData() external view returns (bytes memory) { + return storedHookData; + } + + function lastWasExactOut() external view returns (bool) { + return lastQuoteType == QuoteType.ExactOut; + } + + function lastWasExactIn() external view returns (bool) { + return lastQuoteType == QuoteType.ExactIn; + } +} + +contract MockMulticurveInitializer { + PoolKey internal storedKey; + + function setPoolKey(PoolKey memory poolKey) external { + storedKey = poolKey; + } + + function getState(address) + external + view + returns (address numeraire, PoolStatus status, PoolKey memory poolKey, int24 farTick) + { + numeraire = address(0); + status = PoolStatus.Initialized; + poolKey = storedKey; + farTick = 0; + } +} diff --git a/test/unit/LaunchVault.t.sol b/test/unit/LaunchVault.t.sol new file mode 100644 index 000000000..11a3176d0 --- /dev/null +++ b/test/unit/LaunchVault.t.sol @@ -0,0 +1,274 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import { Test } from "forge-std/Test.sol"; + +import { LaunchVault } from "src/LaunchVault.sol"; +import { Airlock, CreateParams, ModuleState } from "src/Airlock.sol"; +import { DERC20 } from "src/tokens/DERC20.sol"; + +/// @notice Unit tests for LaunchVault using real DERC20 contracts +contract LaunchVaultTest is Test { + LaunchVault vault; + Airlock airlock; + DERC20 asset; + + address owner = address(0x1); + address beneficiary = address(0x3); + address distributor = address(0x4); + address trustedExecutor = address(0x5); + + uint256 constant INITIAL_SUPPLY = 1000000e18; + uint256 constant PREBUY_AMOUNT = 10000e18; + + function setUp() public { + vm.startPrank(owner); + airlock = new Airlock(owner); + vault = new LaunchVault(address(airlock)); + + // Set trusted executor + vault.setTrustedExecutor(trustedExecutor, true); + vault.setTrustedExecutor(owner, true); + + vm.stopPrank(); + } + + /// @notice Helper to create a DERC20 token with vault as vesting recipient + function _createTokenWithVesting() internal returns (DERC20) { + address[] memory recipients = new address[](1); + recipients[0] = address(vault); + uint256[] memory amounts = new uint256[](1); + amounts[0] = PREBUY_AMOUNT; + + DERC20 token = new DERC20( + "Test Token", + "TEST", + INITIAL_SUPPLY, + owner, + address(airlock), + 0, + 0, + recipients, + amounts, + "" + ); + + return token; + } + + function test_depositPrebuy_Success() public { + DERC20 token = _createTokenWithVesting(); + + // Release tokens to vault (as vault) + vm.prank(address(vault)); + token.release(); + + uint256 amount = token.balanceOf(address(vault)); + assertGt(amount, 0, "Vault should have tokens"); + + // Record deposit + vm.prank(trustedExecutor); + vault.depositPrebuy(address(token), beneficiary, amount); + + // Verify + assertEq(vault.prebuyTotal(address(token)), amount); + assertEq(token.balanceOf(address(vault)), amount); + } + + function test_depositPrebuy_RevertNotTrustedExecutor() public { + DERC20 token = _createTokenWithVesting(); + + address randomUser = address(0x999); + + vm.prank(randomUser); + vm.expectRevert(LaunchVault.NotTrustedExecutor.selector); + vault.depositPrebuy(address(token), beneficiary, 1000e18); + } + + function test_depositPrebuy_RevertZeroAmount() public { + DERC20 token = _createTokenWithVesting(); + + vm.prank(trustedExecutor); + vm.expectRevert(LaunchVault.ZeroAmount.selector); + vault.depositPrebuy(address(token), beneficiary, 0); + } + + function test_depositPrebuy_RevertAlreadyDeposited() public { + DERC20 token = _createTokenWithVesting(); + + // Release and deposit first time + vm.prank(address(vault)); + token.release(); + + uint256 amount = token.balanceOf(address(vault)); + + vm.prank(trustedExecutor); + vault.depositPrebuy(address(token), beneficiary, amount); + + // Second deposit should fail + vm.prank(trustedExecutor); + vm.expectRevert(LaunchVault.AlreadyDeposited.selector); + vault.depositPrebuy(address(token), beneficiary, amount); + } + + function test_depositPrebuy_RevertInsufficientBalance() public { + DERC20 token = _createTokenWithVesting(); + + // Don't release tokens, try to deposit more than vault has (0 balance) + vm.prank(trustedExecutor); + vm.expectRevert(abi.encodeWithSelector(LaunchVault.InsufficientBalance.selector, 0, PREBUY_AMOUNT)); + vault.depositPrebuy(address(token), beneficiary, PREBUY_AMOUNT); + } + + function test_setDistributor() public { + DERC20 token = _createTokenWithVesting(); + + vm.prank(owner); + vault.setDistributor(address(token), distributor); + assertEq(vault.distributor(address(token)), distributor); + } + + function test_releaseToDistributor_Success() public { + DERC20 token = _createTokenWithVesting(); + + // Setup + vm.prank(address(vault)); + token.release(); + + uint256 amount = token.balanceOf(address(vault)); + + vm.prank(trustedExecutor); + vault.depositPrebuy(address(token), beneficiary, amount); + + vm.prank(owner); + vault.setDistributor(address(token), distributor); + + // Unlock (as airlock) + vm.prank(address(airlock)); + token.unlockPool(); + + // Release + vault.releaseToDistributor(address(token)); + + // Verify + assertEq(vault.prebuyTotal(address(token)), 0); + assertEq(token.balanceOf(distributor), amount); + assertEq(token.balanceOf(address(vault)), 0); + } + + function test_releaseToDistributor_RevertNoDistributor() public { + DERC20 token = _createTokenWithVesting(); + + vm.prank(address(vault)); + token.release(); + + uint256 amount = token.balanceOf(address(vault)); + + vm.prank(trustedExecutor); + vault.depositPrebuy(address(token), beneficiary, amount); + + // Unlock + vm.prank(address(airlock)); + token.unlockPool(); + + // Try to release without distributor + vm.expectRevert(LaunchVault.NoDistributorSet.selector); + vault.releaseToDistributor(address(token)); + } + + function test_releaseToDistributor_RevertNotUnlocked() public { + DERC20 token = _createTokenWithVesting(); + + vm.prank(address(vault)); + token.release(); + + uint256 amount = token.balanceOf(address(vault)); + + vm.prank(trustedExecutor); + vault.depositPrebuy(address(token), beneficiary, amount); + + vm.prank(owner); + vault.setDistributor(address(token), distributor); + + // Try to release before unlock + vm.expectRevert(LaunchVault.NotUnlocked.selector); + vault.releaseToDistributor(address(token)); + } + + function test_releaseTo_Success() public { + DERC20 token = _createTokenWithVesting(); + address recipient = address(0x5); + + // Setup + vm.prank(address(vault)); + token.release(); + + uint256 amount = token.balanceOf(address(vault)); + + vm.prank(trustedExecutor); + vault.depositPrebuy(address(token), beneficiary, amount); + + // Unlock + vm.prank(address(airlock)); + token.unlockPool(); + + // Release to specific recipient + vault.releaseTo(address(token), recipient); + + // Verify + assertEq(vault.prebuyTotal(address(token)), 0); + assertEq(token.balanceOf(recipient), amount); + } + + function test_isUnlocked() public { + DERC20 token = _createTokenWithVesting(); + + // Initially locked + assertFalse(vault.isUnlocked(address(token))); + + // Unlock + vm.prank(address(airlock)); + token.unlockPool(); + + // Now unlocked + assertTrue(vault.isUnlocked(address(token))); + } + + function test_setTrustedExecutor() public { + address newExecutor = address(0x999); + + // Initially not trusted + assertFalse(vault.trustedExecutors(newExecutor)); + + // Set as trusted + vm.prank(owner); + vault.setTrustedExecutor(newExecutor, true); + assertTrue(vault.trustedExecutors(newExecutor)); + + // Revoke trust + vm.prank(owner); + vault.setTrustedExecutor(newExecutor, false); + assertFalse(vault.trustedExecutors(newExecutor)); + } + + function test_depositPrebuyFromRelease_Success() public { + DERC20 token = _createTokenWithVesting(); + + // Call depositPrebuyFromRelease - pulls via DERC20.release() + vm.prank(trustedExecutor); + vault.depositPrebuyFromRelease(address(token), beneficiary, PREBUY_AMOUNT); + + // Verify + assertEq(vault.prebuyTotal(address(token)), PREBUY_AMOUNT); + assertEq(token.balanceOf(address(vault)), PREBUY_AMOUNT); + } + + function test_depositPrebuyFromRelease_RevertWrongAmount() public { + DERC20 token = _createTokenWithVesting(); + + // Try with wrong expected amount + vm.prank(trustedExecutor); + vm.expectRevert(abi.encodeWithSelector(LaunchVault.WrongAmount.selector, PREBUY_AMOUNT, PREBUY_AMOUNT / 2)); + vault.depositPrebuyFromRelease(address(token), beneficiary, PREBUY_AMOUNT / 2); + } +} diff --git a/test/unit/LaunchVaultFuzz.t.sol b/test/unit/LaunchVaultFuzz.t.sol new file mode 100644 index 000000000..e1dac8517 --- /dev/null +++ b/test/unit/LaunchVaultFuzz.t.sol @@ -0,0 +1,452 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import { Test } from "forge-std/Test.sol"; +import { console } from "forge-std/console.sol"; + +import { LaunchVault } from "src/LaunchVault.sol"; +import { Airlock, CreateParams, ModuleState } from "src/Airlock.sol"; +import { DERC20 } from "src/tokens/DERC20.sol"; + +/// @notice Fuzz and additional full flow tests for LaunchVault +contract LaunchVaultFuzzTest is Test { + // Contracts + Airlock airlock; + LaunchVault vault; + + // Test addresses + address owner; + address beneficiary; + address distributor; + + // Bound constants for fuzzing + uint256 constant MIN_SUPPLY = 1000e18; + uint256 constant MAX_SUPPLY = 1e9 * 1e18; // 1 billion tokens + uint256 constant MIN_PREBUY = 1e18; + + function setUp() public { + owner = makeAddr("OWNER"); + + vm.startPrank(owner); + airlock = new Airlock(owner); + vault = new LaunchVault(address(airlock)); + vault.setTrustedExecutor(address(vault), true); + vault.setTrustedExecutor(owner, true); + vm.stopPrank(); + + beneficiary = makeAddr("BENEFICIARY"); + distributor = makeAddr("DISTRIBUTOR"); + } + + /// @notice Fuzz test: Various supply and prebuy amounts + function testFuzz_FullFlow_VariousAmounts( + uint256 initialSupply, + uint256 prebuyAmount + ) public { + // Bound inputs + initialSupply = bound(initialSupply, MIN_SUPPLY, MAX_SUPPLY); + prebuyAmount = bound(prebuyAmount, MIN_PREBUY, initialSupply / 10); // Max 10% of supply + + vm.startPrank(owner); + + // Setup vesting + address[] memory recipients = new address[](1); + recipients[0] = address(vault); + uint256[] memory amounts = new uint256[](1); + amounts[0] = prebuyAmount; + + // Create token + DERC20 token = new DERC20( + "Test Token", + "TEST", + initialSupply, + owner, + address(airlock), + 0, + 0, + recipients, + amounts, + "" + ); + + address asset = address(token); + + // Release to vault + vm.stopPrank(); + vm.prank(address(vault)); + token.release(); + vm.startPrank(owner); + + uint256 vaultBalance = token.balanceOf(address(vault)); + assertEq(vaultBalance, prebuyAmount, "Vault should have exact prebuy amount"); + + // Record deposit + vault.depositPrebuy(asset, beneficiary, vaultBalance); + assertEq(vault.prebuyTotal(asset), vaultBalance); + + // Unlock and release + vm.stopPrank(); + vm.prank(address(airlock)); + token.unlockPool(); + vm.startPrank(owner); + + vault.setDistributor(asset, distributor); + vault.releaseToDistributor(asset); + + // Verify + assertEq(token.balanceOf(distributor), prebuyAmount); + assertEq(token.balanceOf(address(vault)), 0); + assertEq(vault.prebuyTotal(asset), 0); + + vm.stopPrank(); + } + + /// @notice Fuzz test: Multiple vesting recipients + function testFuzz_MultipleVestingRecipients( + uint256 initialSupply, + uint256 numRecipients + ) public { + initialSupply = bound(initialSupply, MIN_SUPPLY, MAX_SUPPLY); + numRecipients = bound(numRecipients, 1, 10); + + vm.startPrank(owner); + + address[] memory recipients = new address[](numRecipients); + uint256[] memory amounts = new uint256[](numRecipients); + + uint256 totalVesting = 0; + for (uint256 i = 0; i < numRecipients; i++) { + recipients[i] = makeAddr(string.concat("RECIPIENT_", vm.toString(i))); + amounts[i] = initialSupply / (numRecipients * 10); // Each gets 10%/numRecipients + totalVesting += amounts[i]; + } + + // Add vault as one recipient + recipients[numRecipients - 1] = address(vault); + + DERC20 token = new DERC20( + "Test Token", + "TEST", + initialSupply, + owner, + address(airlock), + 0, + 0, + recipients, + amounts, + "" + ); + + // All recipients release + for (uint256 i = 0; i < numRecipients; i++) { + vm.stopPrank(); + vm.prank(recipients[i]); + DERC20(token).release(); + vm.startPrank(owner); + } + + uint256 vaultBalance = token.balanceOf(address(vault)); + assertGt(vaultBalance, 0, "Vault should have some tokens"); + + // Record and release + vault.depositPrebuy(address(token), beneficiary, vaultBalance); + + vm.stopPrank(); + vm.prank(address(airlock)); + token.unlockPool(); + vm.startPrank(owner); + + vault.setDistributor(address(token), distributor); + vault.releaseToDistributor(address(token)); + + assertEq(token.balanceOf(distributor), vaultBalance); + + vm.stopPrank(); + } + + /// @notice Test: Multiple assets in same vault + function test_MultipleAssetsSameVault() public { + vm.startPrank(owner); + + uint256 numTokens = 5; + DERC20[] memory tokens = new DERC20[](numTokens); + uint256[] memory amounts = new uint256[](numTokens); + + for (uint256 i = 0; i < numTokens; i++) { + address[] memory recipients = new address[](1); + recipients[0] = address(vault); + uint256[] memory vestingAmounts = new uint256[](1); + amounts[i] = (i + 1) * 10000e18; + vestingAmounts[0] = amounts[i]; + + tokens[i] = new DERC20( + string.concat("Token ", vm.toString(i)), + string.concat("TKN", vm.toString(i)), + 1000000e18, + owner, + address(airlock), + 0, + 0, + recipients, + vestingAmounts, + "" + ); + + // Release to vault + vm.stopPrank(); + vm.prank(address(vault)); + tokens[i].release(); + vm.startPrank(owner); + + // Record deposit + vault.depositPrebuy(address(tokens[i]), beneficiary, amounts[i]); + } + + // Verify all deposits recorded + for (uint256 i = 0; i < numTokens; i++) { + assertEq(vault.prebuyTotal(address(tokens[i])), amounts[i]); + assertEq(tokens[i].balanceOf(address(vault)), amounts[i]); + } + + // Unlock all and release + for (uint256 i = 0; i < numTokens; i++) { + vm.stopPrank(); + vm.prank(address(airlock)); + tokens[i].unlockPool(); + vm.startPrank(owner); + + address tokenDistributor = makeAddr(string.concat("DISTRIBUTOR_", vm.toString(i))); + vault.setDistributor(address(tokens[i]), tokenDistributor); + vault.releaseToDistributor(address(tokens[i])); + + assertEq(tokens[i].balanceOf(tokenDistributor), amounts[i]); + assertEq(vault.prebuyTotal(address(tokens[i])), 0); + } + + vm.stopPrank(); + } + + /// @notice Test: Release to multiple different recipients + function test_ReleaseToDifferentRecipients() public { + vm.startPrank(owner); + + address[] memory recipients = new address[](1); + recipients[0] = address(vault); + uint256[] memory amounts = new uint256[](1); + amounts[0] = 100000e18; + + DERC20 token = new DERC20( + "Test Token", + "TEST", + 1000000e18, + owner, + address(airlock), + 0, + 0, + recipients, + amounts, + "" + ); + + address asset = address(token); + + // Release to vault + vm.stopPrank(); + vm.prank(address(vault)); + token.release(); + vm.startPrank(owner); + + vault.depositPrebuy(asset, beneficiary, 100000e18); + + // Unlock + vm.stopPrank(); + vm.prank(address(airlock)); + token.unlockPool(); + vm.startPrank(owner); + + // Test releaseTo with different recipients + address[] memory testRecipients = new address[](3); + testRecipients[0] = makeAddr("RECIPIENT_1"); + testRecipients[1] = makeAddr("RECIPIENT_2"); + testRecipients[2] = makeAddr("RECIPIENT_3"); + + for (uint256 i = 0; i < testRecipients.length; i++) { + // First we need to deposit again for each test + // Actually, let's just use releaseTo with the same deposit + if (i == 0) { + // First release + vault.releaseTo(asset, testRecipients[i]); + assertEq(token.balanceOf(testRecipients[i]), 100000e18); + } + // For subsequent releases, we need new deposits + } + + vm.stopPrank(); + } + + /// @notice Test: Edge case - zero duration vesting (immediate) + function test_ZeroDurationVesting_ImmediateRelease() public { + vm.startPrank(owner); + + address[] memory recipients = new address[](1); + recipients[0] = address(vault); + uint256[] memory amounts = new uint256[](1); + amounts[0] = 10000e18; + + // 0 duration = immediate vesting + DERC20 token = new DERC20( + "Test Token", + "TEST", + 1000000e18, + owner, + address(airlock), + 0, + 0, // 0 duration + recipients, + amounts, + "" + ); + + // With 0 duration, tokens are available immediately + (uint256 total, uint256 released) = token.getVestingDataOf(address(vault)); + assertEq(total, 10000e18); + assertEq(released, 0); // Not yet released + + // Release + vm.stopPrank(); + vm.prank(address(vault)); + token.release(); + vm.startPrank(owner); + + assertEq(token.balanceOf(address(vault)), 10000e18); + + // Complete flow + vault.depositPrebuy(address(token), beneficiary, 10000e18); + vm.stopPrank(); + vm.prank(address(airlock)); + token.unlockPool(); + vm.startPrank(owner); + vault.setDistributor(address(token), distributor); + vault.releaseToDistributor(address(token)); + + assertEq(token.balanceOf(distributor), 10000e18); + + vm.stopPrank(); + } + + /// @notice Fuzz test: Trusted executor management + function testFuzz_TrustedExecutorManagement( + address[] calldata executors, + bool[] calldata trustedStatus + ) public { + vm.assume(executors.length <= 20); + vm.assume(executors.length == trustedStatus.length); + + vm.startPrank(owner); + + for (uint256 i = 0; i < executors.length; i++) { + vm.assume(executors[i] != address(0)); + + vault.setTrustedExecutor(executors[i], trustedStatus[i]); + assertEq(vault.trustedExecutors(executors[i]), trustedStatus[i]); + } + + vm.stopPrank(); + } + + /// @notice Invariant: Vault balance + Distributor balance = Prebuy total (before release) + function test_Invariant_BalanceConservation() public { + vm.startPrank(owner); + + address[] memory recipients = new address[](1); + recipients[0] = address(vault); + uint256[] memory amounts = new uint256[](1); + amounts[0] = 50000e18; + + DERC20 token = new DERC20( + "Test Token", + "TEST", + 1000000e18, + owner, + address(airlock), + 0, + 0, + recipients, + amounts, + "" + ); + + address asset = address(token); + + // Release to vault + vm.stopPrank(); + vm.prank(address(vault)); + token.release(); + vm.startPrank(owner); + + uint256 initialBalance = token.balanceOf(address(vault)); + vault.depositPrebuy(asset, beneficiary, initialBalance); + + // Invariant: vault balance == prebuyTotal + assertEq(token.balanceOf(address(vault)), vault.prebuyTotal(asset)); + + // Unlock and release + vm.stopPrank(); + vm.prank(address(airlock)); + token.unlockPool(); + vm.startPrank(owner); + + vault.setDistributor(asset, distributor); + uint256 distributorBalanceBefore = token.balanceOf(distributor); + vault.releaseToDistributor(asset); + uint256 distributorBalanceAfter = token.balanceOf(distributor); + + uint256 released = distributorBalanceAfter - distributorBalanceBefore; + + // Invariant after release: vault has 0, distributor has everything + assertEq(token.balanceOf(address(vault)), 0); + assertEq(released, initialBalance); + assertEq(vault.prebuyTotal(asset), 0); + + vm.stopPrank(); + } + + /// @notice Test: Cannot double deposit + function test_CannotDoubleDeposit() public { + vm.startPrank(owner); + + address[] memory recipients = new address[](1); + recipients[0] = address(vault); + uint256[] memory amounts = new uint256[](1); + amounts[0] = 10000e18; + + DERC20 token = new DERC20( + "Test Token", + "TEST", + 1000000e18, + owner, + address(airlock), + 0, + 0, + recipients, + amounts, + "" + ); + + address asset = address(token); + + // First deposit + vm.stopPrank(); + vm.prank(address(vault)); + token.release(); + vm.startPrank(owner); + + vault.depositPrebuy(asset, beneficiary, 10000e18); + + // Try second deposit - should fail + vm.expectRevert(LaunchVault.AlreadyDeposited.selector); + vault.depositPrebuy(asset, beneficiary, 10000e18); + + vm.stopPrank(); + } +} diff --git a/test/unit/migrators/distribution/DistributionMigrator.t.sol b/test/unit/migrators/distribution/DistributionMigrator.t.sol new file mode 100644 index 000000000..3c4652d4b --- /dev/null +++ b/test/unit/migrators/distribution/DistributionMigrator.t.sol @@ -0,0 +1,1122 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import { TestERC20 } from "@v4-core/test/TestERC20.sol"; +import { Test } from "forge-std/Test.sol"; +import { VmSafe } from "forge-std/Vm.sol"; +import { Airlock, ModuleState } from "src/Airlock.sol"; +import { SenderNotAirlock } from "src/base/ImmutableAirlock.sol"; +import { ILiquidityMigrator } from "src/interfaces/ILiquidityMigrator.sol"; +import { + AlreadyInitialized, + DistributionConfig, + DistributionMigrator, + InvalidPayout, + InvalidPercent, + InvalidUnderlying, + MAX_DISTRIBUTION_WAD, + PoolNotInitialized, + TokenPairMismatch, + UnderlyingNotForwarded, + UnderlyingNotWhitelisted, + WAD +} from "src/migrators/distribution/DistributionMigrator.sol"; +import { IDistributionTopUpSource } from "src/interfaces/IDistributionTopUpSource.sol"; + +/// @notice Mock underlying migrator for testing +contract MockForwardedMigrator is ILiquidityMigrator { + address public airlock; + address public lastInitAsset; + address public lastInitNumeraire; + bytes public lastInitData; + address public returnPool; + + uint256 public lastMigrateSqrtPriceX96; + address public lastMigrateToken0; + address public lastMigrateToken1; + address public lastMigrateRecipient; + uint256 public returnLiquidity; + + constructor(address airlock_, address returnPool_, uint256 returnLiquidity_) { + airlock = airlock_; + returnPool = returnPool_; + returnLiquidity = returnLiquidity_; + } + + function initialize(address asset, address numeraire, bytes calldata data) external returns (address) { + lastInitAsset = asset; + lastInitNumeraire = numeraire; + lastInitData = data; + return returnPool; + } + + function migrate( + uint160 sqrtPriceX96, + address token0, + address token1, + address recipient + ) external payable returns (uint256) { + lastMigrateSqrtPriceX96 = sqrtPriceX96; + lastMigrateToken0 = token0; + lastMigrateToken1 = token1; + lastMigrateRecipient = recipient; + return returnLiquidity; + } + + receive() external payable { } +} + +/// @notice Mock underlying that reverts +contract MockRevertingMigrator is ILiquidityMigrator { + address public airlock; + string public revertMessage; + + constructor(address airlock_, string memory revertMessage_) { + airlock = airlock_; + revertMessage = revertMessage_; + } + + function initialize(address, address, bytes calldata) external view returns (address) { + revert(revertMessage); + } + + function migrate(uint160, address, address, address) external payable returns (uint256) { + revert(revertMessage); + } +} + +/// @notice Mock forwarded migrator WITH onlyAirlock modifier (for security tests) +/// @dev This mock properly simulates the real ForwardedMigrator behavior +contract MockForwardedMigratorWithGuard is ILiquidityMigrator { + address public immutable airlock; + address public returnPool; + + error SenderNotAirlockMock(); + + modifier onlyAirlock() { + if (msg.sender != airlock) revert SenderNotAirlockMock(); + _; + } + + constructor(address airlock_, address returnPool_) { + airlock = airlock_; + returnPool = returnPool_; + } + + function initialize(address, address, bytes calldata) external onlyAirlock returns (address) { + return returnPool; + } + + function migrate(uint160, address, address, address) external payable onlyAirlock returns (uint256) { + return 1000 ether; + } + + receive() external payable { } +} + +/// @notice Mock ERC20 top-up source that transfers preset amounts +contract MockTopUpSourceERC20 is IDistributionTopUpSource { + mapping(address => uint256) public available; + TestERC20 public immutable token; + + constructor(TestERC20 token_) { + token = token_; + } + + function setAmount(address asset, uint256 amount) external { + available[asset] = amount; + } + + function pullTopUp(address asset, address numeraire) external override returns (uint256 amount) { + if (numeraire != address(token)) return 0; + amount = available[asset]; + if (amount == 0) return 0; + available[asset] = 0; + token.transfer(msg.sender, amount); + } +} + +/// @notice Mock ETH top-up source that forwards stored ETH through acceptTopUpETH +contract MockTopUpSourceETH is IDistributionTopUpSource { + mapping(address => uint256) public available; + + function fund(address asset) external payable { + available[asset] += msg.value; + } + + function pullTopUp(address asset, address numeraire) external override returns (uint256 amount) { + if (numeraire != address(0)) return 0; + amount = available[asset]; + if (amount == 0) return 0; + available[asset] = 0; + DistributionMigrator(payable(msg.sender)).acceptTopUpETH{ value: amount }(); + } +} + +/// @notice Mock top-up source that always reverts +contract MockRevertingTopUpSource is IDistributionTopUpSource { + function pullTopUp(address, address) external pure override returns (uint256) { + revert("topup revert"); + } +} + +contract DistributionMigratorTest is Test { + Airlock public airlock; + DistributionMigrator public distributor; + MockForwardedMigrator public mockUnderlying; + + TestERC20 public asset; + TestERC20 public numeraire; + + address public owner = address(0xb055); + address public payout = address(0xbeef); + address public recipient = address(0xdead); + + uint256 constant PERCENT_10 = 1e17; // 10% + uint256 constant PERCENT_50 = 5e17; // 50% + + function setUp() public { + // Deploy Airlock with owner + airlock = new Airlock(owner); + + // Deploy DistributionMigrator + distributor = new DistributionMigrator(address(airlock)); + + // Deploy mock underlying with distributor as its airlock + mockUnderlying = new MockForwardedMigrator(address(distributor), address(0x1234), 1000 ether); + + // Whitelist both distributor and mockUnderlying + address[] memory modules = new address[](2); + modules[0] = address(distributor); + modules[1] = address(mockUnderlying); + ModuleState[] memory states = new ModuleState[](2); + states[0] = ModuleState.LiquidityMigrator; + states[1] = ModuleState.LiquidityMigrator; + vm.prank(owner); + airlock.setModuleState(modules, states); + + // Deploy test tokens + asset = new TestERC20(0); + numeraire = new TestERC20(0); + + // Ensure asset < numeraire for consistent sorting + if (address(asset) > address(numeraire)) { + (asset, numeraire) = (numeraire, asset); + } + } + + // ============ Constructor Tests ============ + + function test_constructor_SetsAirlock() public view { + assertEq(address(distributor.airlock()), address(airlock)); + } + + // ============ owner() Tests ============ + + function test_owner_ReturnsAirlockOwner() public view { + assertEq(distributor.owner(), owner); + } + + // ============ receive() Tests ============ + + function test_receive_AcceptsETHFromAirlock() public { + vm.deal(address(airlock), 1 ether); + vm.prank(address(airlock)); + (bool success,) = address(distributor).call{ value: 1 ether }(""); + assertTrue(success); + assertEq(address(distributor).balance, 1 ether); + } + + function test_receive_RevertsWhenNotAirlock() public { + vm.deal(address(this), 1 ether); + vm.expectRevert(SenderNotAirlock.selector); + (bool success,) = address(distributor).call{ value: 1 ether }(""); + // The call will fail but expectRevert catches it + success; // silence unused warning + } + + // ============ initialize() Validation Tests ============ + + function test_initialize_RevertsWhenNotAirlock() public { + bytes memory data = abi.encode(payout, PERCENT_10, address(mockUnderlying), "", address(0)); + vm.expectRevert(SenderNotAirlock.selector); + distributor.initialize(address(asset), address(numeraire), data); + } + + function test_initialize_RevertsWhenPayoutIsZero() public { + bytes memory data = abi.encode(address(0), PERCENT_10, address(mockUnderlying), "", address(0)); + vm.prank(address(airlock)); + vm.expectRevert(InvalidPayout.selector); + distributor.initialize(address(asset), address(numeraire), data); + } + + function test_initialize_RevertsWhenUnderlyingIsZero() public { + bytes memory data = abi.encode(payout, PERCENT_10, address(0), "", address(0)); + vm.prank(address(airlock)); + vm.expectRevert(InvalidUnderlying.selector); + distributor.initialize(address(asset), address(numeraire), data); + } + + function test_initialize_RevertsWhenUnderlyingIsSelf() public { + bytes memory data = abi.encode(payout, PERCENT_10, address(distributor), "", address(0)); + vm.prank(address(airlock)); + vm.expectRevert(InvalidUnderlying.selector); + distributor.initialize(address(asset), address(numeraire), data); + } + + function test_initialize_RevertsWhenPercentExceedsMax() public { + bytes memory data = abi.encode(payout, PERCENT_50 + 1, address(mockUnderlying), "", address(0)); + vm.prank(address(airlock)); + vm.expectRevert(InvalidPercent.selector); + distributor.initialize(address(asset), address(numeraire), data); + } + + function test_initialize_RevertsWhenUnderlyingNotWhitelisted() public { + // Deploy non-whitelisted mock + MockForwardedMigrator nonWhitelisted = new MockForwardedMigrator(address(distributor), address(0), 0); + + bytes memory data = abi.encode(payout, PERCENT_10, address(nonWhitelisted), "", address(0)); + vm.prank(address(airlock)); + vm.expectRevert(UnderlyingNotWhitelisted.selector); + distributor.initialize(address(asset), address(numeraire), data); + } + + function test_initialize_RevertsWhenUnderlyingNotForwarded() public { + // Deploy mock with wrong airlock (not the distributor) + MockForwardedMigrator wrongAirlock = new MockForwardedMigrator(address(0x9999), address(0), 0); + + // Whitelist it + address[] memory modules = new address[](1); + modules[0] = address(wrongAirlock); + ModuleState[] memory states = new ModuleState[](1); + states[0] = ModuleState.LiquidityMigrator; + vm.prank(owner); + airlock.setModuleState(modules, states); + + bytes memory data = abi.encode(payout, PERCENT_10, address(wrongAirlock), "", address(0)); + vm.prank(address(airlock)); + vm.expectRevert(UnderlyingNotForwarded.selector); + distributor.initialize(address(asset), address(numeraire), data); + } + + function test_initialize_RevertsOnOverwrite() public { + bytes memory data = abi.encode(payout, PERCENT_10, address(mockUnderlying), "", address(0)); + + // First initialization succeeds + vm.prank(address(airlock)); + distributor.initialize(address(asset), address(numeraire), data); + + // Second initialization fails + vm.prank(address(airlock)); + vm.expectRevert(AlreadyInitialized.selector); + distributor.initialize(address(asset), address(numeraire), data); + } + + function test_initialize_BubblesUnderlyingRevert() public { + // Deploy reverting mock + MockRevertingMigrator revertingMock = new MockRevertingMigrator(address(distributor), "UNDERLYING_FAILED"); + + // Whitelist it + address[] memory modules = new address[](1); + modules[0] = address(revertingMock); + ModuleState[] memory states = new ModuleState[](1); + states[0] = ModuleState.LiquidityMigrator; + vm.prank(owner); + airlock.setModuleState(modules, states); + + bytes memory data = abi.encode(payout, PERCENT_10, address(revertingMock), "", address(0)); + vm.prank(address(airlock)); + vm.expectRevert("UNDERLYING_FAILED"); + distributor.initialize(address(asset), address(numeraire), data); + } + + // ============ initialize() Success Tests ============ + + function test_initialize_StoresConfig() public { + bytes memory underlyingData = abi.encode("test data", new address[](0)); + bytes memory data = abi.encode(payout, PERCENT_10, address(mockUnderlying), underlyingData, address(0)); + + vm.prank(address(airlock)); + address pool = distributor.initialize(address(asset), address(numeraire), data); + + // Check return value + assertEq(pool, address(0x1234)); + + // Check stored config + (address token0, address token1) = address(asset) < address(numeraire) + ? (address(asset), address(numeraire)) + : (address(numeraire), address(asset)); + + ( + address storedPayout, + uint256 storedPercent, + ILiquidityMigrator storedUnderlying, + address storedAsset, + address storedNumeraire + ) = distributor.getDistributionConfig(token0, token1); + + assertEq(storedPayout, payout); + assertEq(storedPercent, PERCENT_10); + assertEq(address(storedUnderlying), address(mockUnderlying)); + assertEq(storedAsset, address(asset)); + assertEq(storedNumeraire, address(numeraire)); + } + + function test_initialize_ForwardsToUnderlying() public { + bytes memory underlyingData = abi.encode("test data", new address[](0)); + bytes memory data = abi.encode(payout, PERCENT_10, address(mockUnderlying), underlyingData, address(0)); + + vm.prank(address(airlock)); + distributor.initialize(address(asset), address(numeraire), data); + + // Check underlying received correct params + assertEq(mockUnderlying.lastInitAsset(), address(asset)); + assertEq(mockUnderlying.lastInitNumeraire(), address(numeraire)); + assertEq(mockUnderlying.lastInitData(), underlyingData); + } + + // ============ migrate() Tests ============ + + function test_migrate_RevertsWhenNotAirlock() public { + vm.expectRevert(SenderNotAirlock.selector); + distributor.migrate(1e18, address(asset), address(numeraire), recipient); + } + + function test_migrate_RevertsWhenNotInitialized() public { + (address token0, address token1) = address(asset) < address(numeraire) + ? (address(asset), address(numeraire)) + : (address(numeraire), address(asset)); + + vm.prank(address(airlock)); + vm.expectRevert(PoolNotInitialized.selector); + distributor.migrate(1e18, token0, token1, recipient); + } + + function test_migrate_RevertsWhenTokenPairMismatch_WrongKeyLookup() public { + // Initialize config with (asset, numeraire) + bytes memory data = abi.encode(payout, PERCENT_10, address(mockUnderlying), "", address(0)); + vm.prank(address(airlock)); + distributor.initialize(address(asset), address(numeraire), data); + + // Create a third token that doesn't match either + TestERC20 rogueToken = new TestERC20(2); + + // Try to migrate with (asset, rogueToken) - different tokens than initialized + // This simulates a malicious poolInitializer returning wrong tokens + (address token0, address token1) = address(asset) < address(rogueToken) + ? (address(asset), address(rogueToken)) + : (address(rogueToken), address(asset)); + + vm.prank(address(airlock)); + // First line of defense: different key lookup → PoolNotInitialized + vm.expectRevert(PoolNotInitialized.selector); + distributor.migrate(1e18, token0, token1, recipient); + } + + function test_migrate_ValidatesStoredTokensMatch() public { + // This test verifies that migrate() explicitly validates the stored (asset, numeraire) + // matches the provided (token0, token1) pair, providing defense-in-depth against: + // 1. Storage corruption + // 2. Mapping collision attacks (extremely unlikely but checked anyway) + // 3. Untrusted poolInitializer returning unexpected tokens + // + // With sorted key storage, a mismatch is only possible through storage corruption + // or collision. The explicit check ensures we fail safely even in those cases. + + // Initialize config + bytes memory data = abi.encode(payout, PERCENT_10, address(mockUnderlying), "", address(0)); + vm.prank(address(airlock)); + distributor.initialize(address(asset), address(numeraire), data); + + // Verify config stores BOTH asset and numeraire explicitly + (address token0, address token1) = address(asset) < address(numeraire) + ? (address(asset), address(numeraire)) + : (address(numeraire), address(asset)); + + (address storedPayout,, address storedAsset, address storedNumeraire) = _getConfigValues(token0, token1); + + assertEq(storedPayout, payout, "payout should be stored"); + assertEq(storedAsset, address(asset), "asset should be stored explicitly"); + assertEq(storedNumeraire, address(numeraire), "numeraire should be stored explicitly"); + } + + // Helper to read config values + function _getConfigValues( + address token0, + address token1 + ) internal view returns (address payout_, uint256 percentWad_, address asset_, address numeraire_) { + (payout_, percentWad_,, asset_, numeraire_) = distributor.getDistributionConfig(token0, token1); + } + + function test_migrate_DistributesNumeraireOnly() public { + // Initialize + bytes memory data = abi.encode(payout, PERCENT_10, address(mockUnderlying), "", address(0)); + vm.prank(address(airlock)); + distributor.initialize(address(asset), address(numeraire), data); + + // Fund distributor with both tokens + uint256 assetAmount = 1000 ether; + uint256 numeraireAmount = 500 ether; + asset.mint(address(distributor), assetAmount); + numeraire.mint(address(distributor), numeraireAmount); + + // Get sorted tokens + (address token0, address token1) = address(asset) < address(numeraire) + ? (address(asset), address(numeraire)) + : (address(numeraire), address(asset)); + + // Migrate + vm.prank(address(airlock)); + distributor.migrate(1e18, token0, token1, recipient); + + // Check payout received 10% of numeraire + uint256 expectedDistribution = (numeraireAmount * PERCENT_10) / WAD; + assertEq(numeraire.balanceOf(payout), expectedDistribution); + + // Check underlying received remaining balances + assertEq(asset.balanceOf(address(mockUnderlying)), assetAmount); + assertEq(numeraire.balanceOf(address(mockUnderlying)), numeraireAmount - expectedDistribution); + } + + function test_migrate_PullsERC20TopUpsWithoutSkimming() public { + MockTopUpSourceERC20 topUpSource = new MockTopUpSourceERC20(numeraire); + address topUpAddr = address(topUpSource); + + bytes memory data = abi.encode(payout, PERCENT_10, address(mockUnderlying), "", topUpAddr); + vm.prank(address(airlock)); + distributor.initialize(address(asset), address(numeraire), data); + + uint256 proceeds = 500 ether; + uint256 topUpAmount = 200 ether; + asset.mint(address(distributor), 1000 ether); + numeraire.mint(address(distributor), proceeds); + numeraire.mint(address(topUpSource), topUpAmount); + topUpSource.setAmount(address(asset), topUpAmount); + + (address token0, address token1) = address(asset) < address(numeraire) + ? (address(asset), address(numeraire)) + : (address(numeraire), address(asset)); + + vm.prank(address(airlock)); + distributor.migrate(1e18, token0, token1, recipient); + + uint256 expectedDistribution = (proceeds * PERCENT_10) / WAD; + assertEq(numeraire.balanceOf(payout), expectedDistribution); + assertEq( + numeraire.balanceOf(address(mockUnderlying)), proceeds - expectedDistribution + topUpAmount + ); + assertEq(numeraire.balanceOf(address(topUpSource)), 0); + } + + function test_migrate_PullsETHTopUps() public { + // fresh underlying for ETH path + MockForwardedMigrator ethMock = new MockForwardedMigrator(address(distributor), address(0x9999), 42 ether); + address[] memory modules = new address[](1); + modules[0] = address(ethMock); + ModuleState[] memory states = new ModuleState[](1); + states[0] = ModuleState.LiquidityMigrator; + vm.prank(owner); + airlock.setModuleState(modules, states); + + MockTopUpSourceETH topUpSource = new MockTopUpSourceETH(); + address topUpAddr = address(topUpSource); + + bytes memory data = abi.encode(payout, PERCENT_10, address(ethMock), "", topUpAddr); + vm.prank(address(airlock)); + distributor.initialize(address(asset), address(0), data); + + uint256 proceeds = 10 ether; + uint256 topUpAmount = 3 ether; + asset.mint(address(distributor), 500 ether); + vm.deal(address(airlock), proceeds); + vm.prank(address(airlock)); + (bool sent,) = address(distributor).call{ value: proceeds }(""); + assertTrue(sent); + topUpSource.fund{ value: topUpAmount }(address(asset)); + + address token0 = address(0); + address token1 = address(asset); + + uint256 payoutBefore = payout.balance; + vm.prank(address(airlock)); + distributor.migrate(1e18, token0, token1, recipient); + + uint256 expectedDistribution = (proceeds * PERCENT_10) / WAD; + assertEq(payout.balance - payoutBefore, expectedDistribution); + assertEq(address(ethMock).balance, proceeds - expectedDistribution + topUpAmount); + } + + function test_migrate_RevertsWhenTopUpSourceReverts() public { + address topUpAddr = address(new MockRevertingTopUpSource()); + + bytes memory data = abi.encode(payout, PERCENT_10, address(mockUnderlying), "", topUpAddr); + vm.prank(address(airlock)); + distributor.initialize(address(asset), address(numeraire), data); + + numeraire.mint(address(distributor), 200 ether); + asset.mint(address(distributor), 50 ether); + + (address token0, address token1) = address(asset) < address(numeraire) + ? (address(asset), address(numeraire)) + : (address(numeraire), address(asset)); + + vm.prank(address(airlock)); + vm.expectRevert(bytes("topup revert")); + distributor.migrate(1e18, token0, token1, recipient); + } + + function test_migrate_RoundingFavorsProtocol() public { + // Initialize with 10% + bytes memory data = abi.encode(payout, PERCENT_10, address(mockUnderlying), "", address(0)); + vm.prank(address(airlock)); + distributor.initialize(address(asset), address(numeraire), data); + + // Fund with amount that doesn't divide evenly + // 999 * 0.1 = 99.9 -> should be 99 (floor) + uint256 numeraireAmount = 999; + numeraire.mint(address(distributor), numeraireAmount); + + (address token0, address token1) = address(asset) < address(numeraire) + ? (address(asset), address(numeraire)) + : (address(numeraire), address(asset)); + + vm.prank(address(airlock)); + distributor.migrate(1e18, token0, token1, recipient); + + // Check floor rounding + uint256 expectedDistribution = (numeraireAmount * PERCENT_10) / WAD; // 99 + assertEq(numeraire.balanceOf(payout), expectedDistribution); + assertEq(numeraire.balanceOf(address(mockUnderlying)), numeraireAmount - expectedDistribution); + } + + function test_migrate_ETHNumerairePath() public { + // For ETH tests, numeraire is address(0) + // asset should be a real token + address ethNumeraire = address(0); + + // Deploy new mock with distributor as airlock + MockForwardedMigrator ethMock = new MockForwardedMigrator(address(distributor), address(0x5678), 2000 ether); + + // Whitelist it + address[] memory modules = new address[](1); + modules[0] = address(ethMock); + ModuleState[] memory states = new ModuleState[](1); + states[0] = ModuleState.LiquidityMigrator; + vm.prank(owner); + airlock.setModuleState(modules, states); + + // Initialize with ETH as numeraire (token0 since address(0) < any other address) + bytes memory data = abi.encode(payout, PERCENT_10, address(ethMock), "", address(0)); + vm.prank(address(airlock)); + distributor.initialize(address(asset), ethNumeraire, data); + + // Fund distributor with ETH and asset + uint256 ethAmount = 10 ether; + uint256 assetAmount = 1000 ether; + vm.deal(address(airlock), ethAmount); + vm.prank(address(airlock)); + (bool success,) = address(distributor).call{ value: ethAmount }(""); + assertTrue(success); + asset.mint(address(distributor), assetAmount); + + // token0 = address(0) (ETH), token1 = asset + address token0 = ethNumeraire; // address(0) + address token1 = address(asset); + + uint256 payoutBalanceBefore = payout.balance; + + vm.prank(address(airlock)); + distributor.migrate(1e18, token0, token1, recipient); + + // Check payout received 10% of ETH + uint256 expectedDistribution = (ethAmount * PERCENT_10) / WAD; + assertEq(payout.balance - payoutBalanceBefore, expectedDistribution); + + // Check underlying received remaining ETH (via msg.value in migrate call) + assertEq(address(ethMock).balance, ethAmount - expectedDistribution); + assertEq(asset.balanceOf(address(ethMock)), assetAmount); + } + + function test_migrate_ConfigPersistsAfterMigration() public { + // Initialize + bytes memory data = abi.encode(payout, PERCENT_10, address(mockUnderlying), "", address(0)); + vm.prank(address(airlock)); + distributor.initialize(address(asset), address(numeraire), data); + + // Fund and migrate + numeraire.mint(address(distributor), 1000 ether); + + (address token0, address token1) = address(asset) < address(numeraire) + ? (address(asset), address(numeraire)) + : (address(numeraire), address(asset)); + + vm.prank(address(airlock)); + distributor.migrate(1e18, token0, token1, recipient); + + // Check config still exists for post-migration inspection + (address storedPayout,,,,) = distributor.getDistributionConfig(token0, token1); + assertEq(storedPayout, payout); + } + + function test_migrate_ForwardsToUnderlying() public { + // Initialize + bytes memory data = abi.encode(payout, PERCENT_10, address(mockUnderlying), "", address(0)); + vm.prank(address(airlock)); + distributor.initialize(address(asset), address(numeraire), data); + + // Fund + asset.mint(address(distributor), 1000 ether); + numeraire.mint(address(distributor), 500 ether); + + (address token0, address token1) = address(asset) < address(numeraire) + ? (address(asset), address(numeraire)) + : (address(numeraire), address(asset)); + + uint160 sqrtPrice = 1e18; + vm.prank(address(airlock)); + uint256 liquidity = distributor.migrate(sqrtPrice, token0, token1, recipient); + + // Check underlying received correct migrate params + assertEq(mockUnderlying.lastMigrateSqrtPriceX96(), sqrtPrice); + assertEq(mockUnderlying.lastMigrateToken0(), token0); + assertEq(mockUnderlying.lastMigrateToken1(), token1); + assertEq(mockUnderlying.lastMigrateRecipient(), recipient); + + // Check return value + assertEq(liquidity, 1000 ether); + } + + // ============ Fuzz Tests ============ + + function testFuzz_migrate_DistributionCalculation(uint256 balance, uint256 percentWad) public { + // Bound inputs + balance = bound(balance, 1, type(uint128).max); + percentWad = bound(percentWad, 0, MAX_DISTRIBUTION_WAD); + + // Initialize + bytes memory data = abi.encode(payout, percentWad, address(mockUnderlying), "", address(0)); + vm.prank(address(airlock)); + distributor.initialize(address(asset), address(numeraire), data); + + // Fund + numeraire.mint(address(distributor), balance); + + (address token0, address token1) = address(asset) < address(numeraire) + ? (address(asset), address(numeraire)) + : (address(numeraire), address(asset)); + + vm.prank(address(airlock)); + distributor.migrate(1e18, token0, token1, recipient); + + // Verify distribution calculation + uint256 expectedDistribution = (balance * percentWad) / WAD; + assertEq(numeraire.balanceOf(payout), expectedDistribution); + assertEq(numeraire.balanceOf(address(mockUnderlying)), balance - expectedDistribution); + } + + // ============ Edge Case Tests (Trail of Bits patterns) ============ + + /// @notice Test with zero numeraire balance - distribution should be 0 + function test_migrate_ZeroNumeraireBalance() public { + bytes memory data = abi.encode(payout, PERCENT_10, address(mockUnderlying), "", address(0)); + vm.prank(address(airlock)); + distributor.initialize(address(asset), address(numeraire), data); + + // Fund with only asset, no numeraire + asset.mint(address(distributor), 1000 ether); + // No numeraire minted + + (address token0, address token1) = address(asset) < address(numeraire) + ? (address(asset), address(numeraire)) + : (address(numeraire), address(asset)); + + vm.prank(address(airlock)); + distributor.migrate(1e18, token0, token1, recipient); + + // Payout should receive nothing + assertEq(numeraire.balanceOf(payout), 0); + // Underlying should receive all asset + assertEq(asset.balanceOf(address(mockUnderlying)), 1000 ether); + } + + /// @notice Test with very large balance to check overflow safety + function test_migrate_LargeBalanceNoOverflow() public { + bytes memory data = abi.encode(payout, PERCENT_50, address(mockUnderlying), "", address(0)); + vm.prank(address(airlock)); + distributor.initialize(address(asset), address(numeraire), data); + + // Fund with max reasonable balance (1e30 tokens) + uint256 largeBalance = 1e30; + numeraire.mint(address(distributor), largeBalance); + + (address token0, address token1) = address(asset) < address(numeraire) + ? (address(asset), address(numeraire)) + : (address(numeraire), address(asset)); + + vm.prank(address(airlock)); + distributor.migrate(1e18, token0, token1, recipient); + + // Verify no overflow - 50% of 1e30 = 5e29 + uint256 expectedDistribution = (largeBalance * PERCENT_50) / WAD; + assertEq(numeraire.balanceOf(payout), expectedDistribution); + } + + /// @notice Test that underlying.migrate() revert bubbles up correctly + function test_migrate_BubblesUnderlyingRevert() public { + // Deploy reverting mock + MockRevertingMigrator revertingMock = new MockRevertingMigrator(address(distributor), "MIGRATE_FAILED"); + + // Whitelist it + address[] memory modules = new address[](1); + modules[0] = address(revertingMock); + ModuleState[] memory states = new ModuleState[](1); + states[0] = ModuleState.LiquidityMigrator; + vm.prank(owner); + airlock.setModuleState(modules, states); + + bytes memory data = abi.encode(payout, PERCENT_10, address(revertingMock), "", address(0)); + vm.prank(address(airlock)); + // Note: initialize will also revert since the mock reverts on initialize, and the error bubbles up + vm.expectRevert("MIGRATE_FAILED"); + distributor.initialize(address(asset), address(numeraire), data); + } + + /// @notice Test event emission when distribution is zero (percentWad = 0) + function test_migrate_NoEventWhenZeroDistribution() public { + bytes memory data = abi.encode(payout, 0, address(mockUnderlying), "", address(0)); // 0% + vm.prank(address(airlock)); + distributor.initialize(address(asset), address(numeraire), data); + + numeraire.mint(address(distributor), 1000 ether); + + (address token0, address token1) = address(asset) < address(numeraire) + ? (address(asset), address(numeraire)) + : (address(numeraire), address(asset)); + + // Record logs to check no Distribution event + vm.recordLogs(); + + vm.prank(address(airlock)); + distributor.migrate(1e18, token0, token1, recipient); + + // Check that Distribution event was NOT emitted (only WrappedMigration should be) + VmSafe.Log[] memory logs = vm.getRecordedLogs(); + bool distributionEmitted = false; + for (uint256 i = 0; i < logs.length; i++) { + // Distribution event topic0 + if (logs[i].topics[0] == keccak256("Distribution(address,address,uint256,uint256)")) { + distributionEmitted = true; + } + } + assertFalse(distributionEmitted, "Distribution event should not be emitted when distribution is 0"); + } + + /// @notice Test that asset balance is never affected by distribution + function test_migrate_AssetBalanceUnaffected() public { + bytes memory data = abi.encode(payout, PERCENT_50, address(mockUnderlying), "", address(0)); + vm.prank(address(airlock)); + distributor.initialize(address(asset), address(numeraire), data); + + uint256 assetAmount = 1000 ether; + uint256 numeraireAmount = 500 ether; + asset.mint(address(distributor), assetAmount); + numeraire.mint(address(distributor), numeraireAmount); + + (address token0, address token1) = address(asset) < address(numeraire) + ? (address(asset), address(numeraire)) + : (address(numeraire), address(asset)); + + vm.prank(address(airlock)); + distributor.migrate(1e18, token0, token1, recipient); + + // Payout should ONLY receive numeraire, NEVER asset + assertEq(asset.balanceOf(payout), 0, "Payout should not receive any asset"); + // Underlying receives ALL asset + assertEq(asset.balanceOf(address(mockUnderlying)), assetAmount, "Underlying should receive all asset"); + } + + /// @notice Fuzz test: distribution + remaining always equals original balance (conservation) + function testFuzz_migrate_BalanceConservation(uint256 balance, uint256 percentWad) public { + balance = bound(balance, 1, type(uint128).max); + percentWad = bound(percentWad, 0, MAX_DISTRIBUTION_WAD); + + bytes memory data = abi.encode(payout, percentWad, address(mockUnderlying), "", address(0)); + vm.prank(address(airlock)); + distributor.initialize(address(asset), address(numeraire), data); + + numeraire.mint(address(distributor), balance); + + (address token0, address token1) = address(asset) < address(numeraire) + ? (address(asset), address(numeraire)) + : (address(numeraire), address(asset)); + + vm.prank(address(airlock)); + distributor.migrate(1e18, token0, token1, recipient); + + // Conservation: payout + underlying = original balance + uint256 payoutReceived = numeraire.balanceOf(payout); + uint256 underlyingReceived = numeraire.balanceOf(address(mockUnderlying)); + assertEq(payoutReceived + underlyingReceived, balance, "Balance not conserved"); + } + + // ============ Trail of Bits Recommended Tests ============ + + /// @notice Test that no funds get stuck in distributor after migrate + function testFuzz_migrate_NoStuckFunds(uint256 assetAmt, uint256 numAmt, uint256 percentWad) public { + assetAmt = bound(assetAmt, 0, type(uint128).max); + numAmt = bound(numAmt, 0, type(uint128).max); + percentWad = bound(percentWad, 0, MAX_DISTRIBUTION_WAD); + + bytes memory data = abi.encode(payout, percentWad, address(mockUnderlying), "", address(0)); + vm.prank(address(airlock)); + distributor.initialize(address(asset), address(numeraire), data); + + if (assetAmt > 0) asset.mint(address(distributor), assetAmt); + if (numAmt > 0) numeraire.mint(address(distributor), numAmt); + + (address token0, address token1) = address(asset) < address(numeraire) + ? (address(asset), address(numeraire)) + : (address(numeraire), address(asset)); + + vm.prank(address(airlock)); + distributor.migrate(1e18, token0, token1, recipient); + + // After migrate, distributor should have zero balance + assertEq(asset.balanceOf(address(distributor)), 0, "Asset stuck in distributor"); + assertEq(numeraire.balanceOf(address(distributor)), 0, "Numeraire stuck in distributor"); + } + + /// @notice Test multiple independent pools don't interfere with each other + function test_migrate_MultiplePoolsIndependent() public { + // Create second token pair + TestERC20 asset2 = new TestERC20(0); + TestERC20 numeraire2 = new TestERC20(0); + if (address(asset2) > address(numeraire2)) { + (asset2, numeraire2) = (numeraire2, asset2); + } + + // Initialize pool 1 with 10% + bytes memory data1 = abi.encode(payout, PERCENT_10, address(mockUnderlying), "", address(0)); + vm.prank(address(airlock)); + distributor.initialize(address(asset), address(numeraire), data1); + + // Create second mock underlying for pool 2 + MockForwardedMigrator mockUnderlying2 = + new MockForwardedMigrator(address(distributor), address(0x5678), 2000 ether); + address[] memory modules = new address[](1); + modules[0] = address(mockUnderlying2); + ModuleState[] memory states = new ModuleState[](1); + states[0] = ModuleState.LiquidityMigrator; + vm.prank(owner); + airlock.setModuleState(modules, states); + + // Initialize pool 2 with 50% + bytes memory data2 = abi.encode(payout, PERCENT_50, address(mockUnderlying2), "", address(0)); + vm.prank(address(airlock)); + distributor.initialize(address(asset2), address(numeraire2), data2); + + // Fund both pools + asset.mint(address(distributor), 1000 ether); + numeraire.mint(address(distributor), 500 ether); + asset2.mint(address(distributor), 2000 ether); + numeraire2.mint(address(distributor), 1000 ether); + + // Migrate pool 1 + (address token0_1, address token1_1) = address(asset) < address(numeraire) + ? (address(asset), address(numeraire)) + : (address(numeraire), address(asset)); + + vm.prank(address(airlock)); + distributor.migrate(1e18, token0_1, token1_1, recipient); + + // Verify pool 1 distributed correctly (10%) + assertEq(numeraire.balanceOf(payout), 50 ether); // 10% of 500 + + // Pool 2 funds should be untouched in distributor still + assertEq(asset2.balanceOf(address(distributor)), 2000 ether); + assertEq(numeraire2.balanceOf(address(distributor)), 1000 ether); + + // Reset payout balance for clean check + uint256 payoutBalanceBefore = numeraire2.balanceOf(payout); + + // Migrate pool 2 + (address token0_2, address token1_2) = address(asset2) < address(numeraire2) + ? (address(asset2), address(numeraire2)) + : (address(numeraire2), address(asset2)); + + vm.prank(address(airlock)); + distributor.migrate(1e18, token0_2, token1_2, recipient); + + // Verify pool 2 distributed correctly (50%) + assertEq(numeraire2.balanceOf(payout) - payoutBalanceBefore, 500 ether); // 50% of 1000 + } + + /// @notice Test with extreme values near safe max (overflow safety) + function testFuzz_migrate_ExtremeValues(uint256 balance) public { + // Test with very large balances - max safe value is type(uint256).max / WAD + // to prevent overflow in distribution calculation: balance * percentWad / WAD + // With percentWad up to 5e17 (50%), max safe balance is ~type(uint256).max / 5e17 + uint256 maxSafeBalance = type(uint256).max / WAD; // ~1.15e59 + balance = bound(balance, type(uint128).max, maxSafeBalance); + + bytes memory data = abi.encode(payout, PERCENT_50, address(mockUnderlying), "", address(0)); + vm.prank(address(airlock)); + distributor.initialize(address(asset), address(numeraire), data); + + // Mint extreme balance + numeraire.mint(address(distributor), balance); + + (address token0, address token1) = address(asset) < address(numeraire) + ? (address(asset), address(numeraire)) + : (address(numeraire), address(asset)); + + // Should not overflow + vm.prank(address(airlock)); + distributor.migrate(1e18, token0, token1, recipient); + + // Verify math is correct even with large numbers + uint256 expectedDistribution = (balance * PERCENT_50) / WAD; + assertEq(numeraire.balanceOf(payout), expectedDistribution); + } + + /// @notice Test that distribution never exceeds 50% regardless of input + function testFuzz_migrate_DistributionNeverExceedsHalf(uint256 balance, uint256 percentWad) public { + balance = bound(balance, 1, type(uint128).max); + percentWad = bound(percentWad, 0, MAX_DISTRIBUTION_WAD); + + bytes memory data = abi.encode(payout, percentWad, address(mockUnderlying), "", address(0)); + vm.prank(address(airlock)); + distributor.initialize(address(asset), address(numeraire), data); + + numeraire.mint(address(distributor), balance); + + (address token0, address token1) = address(asset) < address(numeraire) + ? (address(asset), address(numeraire)) + : (address(numeraire), address(asset)); + + vm.prank(address(airlock)); + distributor.migrate(1e18, token0, token1, recipient); + + // Distribution should never exceed half the balance + uint256 distribution = numeraire.balanceOf(payout); + assertLe(distribution, balance / 2 + 1, "Distribution exceeded 50%"); // +1 for rounding + } + + /// @notice Config remains accessible after migrate for telemetry/reading + function test_migrate_ConfigPersistsForInspection() public { + bytes memory data = abi.encode(payout, PERCENT_10, address(mockUnderlying), "", address(0)); + vm.prank(address(airlock)); + distributor.initialize(address(asset), address(numeraire), data); + + numeraire.mint(address(distributor), 1000 ether); + + (address token0, address token1) = address(asset) < address(numeraire) + ? (address(asset), address(numeraire)) + : (address(numeraire), address(asset)); + + // Before migrate - config exists + (address storedPayoutBefore,,,,) = distributor.getDistributionConfig(token0, token1); + assertEq(storedPayoutBefore, payout, "Config should exist before migrate"); + + vm.prank(address(airlock)); + distributor.migrate(1e18, token0, token1, recipient); + + // After migrate - config should remain for offchain inspection + (address storedPayoutAfter,,,,) = distributor.getDistributionConfig(token0, token1); + assertEq(storedPayoutAfter, payout, "Config should persist after migrate"); + } + + /// @notice Test with zero percent - no distribution should occur + function test_migrate_ZeroPercent() public { + bytes memory data = abi.encode(payout, 0, address(mockUnderlying), "", address(0)); // 0% + vm.prank(address(airlock)); + distributor.initialize(address(asset), address(numeraire), data); + + uint256 balance = 1000 ether; + numeraire.mint(address(distributor), balance); + + (address token0, address token1) = address(asset) < address(numeraire) + ? (address(asset), address(numeraire)) + : (address(numeraire), address(asset)); + + vm.prank(address(airlock)); + distributor.migrate(1e18, token0, token1, recipient); + + // Payout should receive nothing + assertEq(numeraire.balanceOf(payout), 0); + // Underlying should receive everything + assertEq(numeraire.balanceOf(address(mockUnderlying)), balance); + } + + /// @notice Test exact max percent (50%) + function test_migrate_ExactMaxPercent() public { + bytes memory data = abi.encode(payout, MAX_DISTRIBUTION_WAD, address(mockUnderlying), "", address(0)); // exactly 50% + vm.prank(address(airlock)); + distributor.initialize(address(asset), address(numeraire), data); + + uint256 balance = 1000 ether; + numeraire.mint(address(distributor), balance); + + (address token0, address token1) = address(asset) < address(numeraire) + ? (address(asset), address(numeraire)) + : (address(numeraire), address(asset)); + + vm.prank(address(airlock)); + distributor.migrate(1e18, token0, token1, recipient); + + // Payout should receive exactly 50% + assertEq(numeraire.balanceOf(payout), balance / 2); + // Underlying should receive exactly 50% + assertEq(numeraire.balanceOf(address(mockUnderlying)), balance / 2); + } + + // ============ Security: ForwardedMigrator Direct Use Protection ============ + + /// @notice Test that ForwardedMigrator CANNOT be called directly from real Airlock + /// @dev This is a critical security property - ForwardedMigrator should only accept calls + /// from DistributionMigrator, not from the real Airlock + function test_forwardedMigrator_RevertsWhenCalledDirectlyFromAirlock() public { + // Deploy a mock that has onlyAirlock modifier (like real ForwardedMigrator) + // Its airlock is set to the distributor, NOT the real Airlock + MockForwardedMigratorWithGuard guardedMock = + new MockForwardedMigratorWithGuard(address(distributor), address(0x1234)); + + // Whitelist it in Airlock (simulating deployment misconfiguration risk) + address[] memory modules = new address[](1); + modules[0] = address(guardedMock); + ModuleState[] memory states = new ModuleState[](1); + states[0] = ModuleState.LiquidityMigrator; + vm.prank(owner); + airlock.setModuleState(modules, states); + + // Now simulate: user tries to use guardedMock directly in Airlock.create() + // This mimics what would happen if someone specified ForwardedMigrator directly + vm.prank(address(airlock)); // Real Airlock is the caller + vm.expectRevert(MockForwardedMigratorWithGuard.SenderNotAirlockMock.selector); + // guardedMock.airlock = distributor, so msg.sender (airlock) != airlock (distributor) → REVERT + guardedMock.initialize(address(asset), address(numeraire), ""); + } + + /// @notice Test that ForwardedMigrator CAN be called from DistributionMigrator + function test_forwardedMigrator_AcceptsCallsFromDistributor() public { + // Deploy a mock that has onlyAirlock modifier + MockForwardedMigratorWithGuard guardedMock = + new MockForwardedMigratorWithGuard(address(distributor), address(0x5678)); + + // Whitelist it + address[] memory modules = new address[](1); + modules[0] = address(guardedMock); + ModuleState[] memory states = new ModuleState[](1); + states[0] = ModuleState.LiquidityMigrator; + vm.prank(owner); + airlock.setModuleState(modules, states); + + // Now use it through the proper flow: Airlock → Distributor → ForwardedMigrator + bytes memory data = abi.encode(payout, PERCENT_10, address(guardedMock), "", address(0)); + vm.prank(address(airlock)); + // Airlock calls Distributor, Distributor calls guardedMock + // guardedMock.airlock = distributor, msg.sender = distributor → SUCCESS + address pool = distributor.initialize(address(asset), address(numeraire), data); + assertEq(pool, address(0x5678)); + } +}