diff --git a/README.md b/README.md index 73c94be8..a7e0868a 100644 --- a/README.md +++ b/README.md @@ -2154,6 +2154,16 @@ Though redemptions are permissionless, they are complicated and MEV-competitive. As [explained here](https://github.com/liquity/bold?tab=readme-ov-file#closing-the-last-trove-in-the-system), the last Trove in a branch can not be closed unless that branch has been shut down. +## `MIN_DEBT` parameter choice + +The minimum debt should be chosen based on gas costs of redemption (both USD and raw gas). In canonical v2, 2000 BOLD was chosen as the `MIN_DEBT` in order to ensure that, in the wost case where the lower end of the interest-sorted list is filled with minimum debt Troves, redemptions can not be significantly griefed and substantial BOLD volumes can still be redeemed. For chains with cheaper gas costs (in USD), a lower `MIN_DEBT` may be economically viable. It's also worth comparing raw gas costs - though often very similar across EVM chains, opcodes may be priced differently and so raw gas cost may vary. Lower raw gas costs imply more redemptions can be fit into a single block/tx and vice versa. + +## `ETH_GAS_COMPENSATION` and `COLL_GAS_COMPENSATION_CAP` parameter choices + +The canonical v2 value of `ETH_GAS_COMPENSATION = 0.0375` was chosen based on ETH, WSTETH and RETH collateral. For collateral tokens whereby the market price differs by orders of magnitude, the `ETH_GAS_COMPENSATION` should be adjusted accordingly - the default v2 value may be insufficient if your collateral price is much lower, and too generous if the price is much higher. + +Similarly, the `COLL_GAS_COMPENSATION_CAP` may be too low or high depending on order of magnitude of your collateral price. + ## Security and audits It is advisable to perform one or more security audits for any changes made to the core system contracts or parameters. Even seemingly tiny or trivial changes can have outsized and unintended impacts on system security and economic resilience. diff --git a/contracts/addresses/1.json b/contracts/addresses/1.json index bdc78ea5..6e5615b9 100644 --- a/contracts/addresses/1.json +++ b/contracts/addresses/1.json @@ -14,6 +14,8 @@ "multiTroveGetter": "0xfa61db085510c64b83056db3a7acf3b6f631d235", "debtInFrontHelper": "0x4bb5e28fdb12891369b560f2fab3c032600677c6", "exchangeHelpers": "0x2f60bab0072abec7058017f48d7256ec288c8686", + "exchangeHelpersV2": "0xe453b864d3841469763bda2437e3dd0e38dca222", + "redemptionHelper": "0xb366256d033ae7e4f7bddec822a5adec9df07b80", "branches": [ { "collSymbol": "WETH", diff --git a/contracts/addresses/11155111.json b/contracts/addresses/11155111.json index 83e32aa7..85f61f2e 100644 --- a/contracts/addresses/11155111.json +++ b/contracts/addresses/11155111.json @@ -12,8 +12,10 @@ "boldToken": "0x181dff47198bf3f3ed65877332e8395eb6817c4c", "hintHelpers": "0x10fe36cc9a830c86f8117c74e85ce6c58705ef21", "multiTroveGetter": "0x19303bc4d3518039d0780a55e9950b222b178467", - "debtInFrontHelper": "0xaeeafadd6d39e1e7c82977fe3fa274fa32a98da8", + "debtInFrontHelper": "0x99d799D62611849D4D1dA5FF770147164873Eb99", "exchangeHelpers": "0x2b50462f3026446fa9f6e618f3f08a67aae96317", + "exchangeHelpersV2": "0x3fff2e6b2f7fb3121d966c1d67fa8f3ab5a99f3f", + "redemptionHelper": "0xf299f3c504904c5f0b67f0ea0caf745d8912dc45", "branches": [ { "collSymbol": "WETH", diff --git a/contracts/broadcast/DeployOnlyExchangeHelpersV2.s.sol/1/run-1757655932243.json b/contracts/broadcast/DeployOnlyExchangeHelpersV2.s.sol/1/run-1757655932243.json new file mode 100644 index 00000000..a9c7c9c8 --- /dev/null +++ b/contracts/broadcast/DeployOnlyExchangeHelpersV2.s.sol/1/run-1757655932243.json @@ -0,0 +1,55 @@ +{ + "transactions": [ + { + "hash": "0x47f583a1e4f0e424426b8aa4b06f14f510c67bc11efa026abd776932b79b5c01", + "transactionType": "CREATE", + "contractName": "HybridCurveUniV3ExchangeHelpersV2", + "contractAddress": "0xe453b864d3841469763bda2437e3dd0e38dca222", + "function": null, + "arguments": [ + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "0xEFc6516323FbD28e80B85A497B65A86243a54B3E", + "1", + "0", + "500", + "100", + "0x61fFE014bA17989E743c5F6cB21bF9697530B21e" + ], + "transaction": { + "from": "0x147676a5e327080386bcc227e103a73dd2979049", + "gas": "0x13ef22", + "value": "0x0", + "input": "0x61018060405234801562000011575f80fd5b506040516200145f3803806200145f8339810160408190526200003491620000b9565b6001600160a01b0397881660805295871660a05293861660c052600f92830b60e052910b6101005262ffffff908116610120521661014052166101605262000168565b6001600160a01b03811681146200008c575f80fd5b50565b8051600f81900b8114620000a1575f80fd5b919050565b805162ffffff81168114620000a1575f80fd5b5f805f805f805f80610100898b031215620000d2575f80fd5b8851620000df8162000077565b60208a0151909850620000f28162000077565b60408a0151909750620001058162000077565b95506200011560608a016200008f565b94506200012560808a016200008f565b93506200013560a08a01620000a6565b92506200014560c08a01620000a6565b915060e0890151620001578162000077565b809150509295985092959890939650565b60805160a05160c05160e0516101005161012051610140516101605161114c620003135f395f8181610144015281816103e0015281816107a801528181610a9e0152610caf01525f81816101920152818161031f0152818161074b01528181610a410152610bee01525f8181610109015281816102b4015281816103610152818161067e0152818161070901528181610974015281816109ff01528181610b830152610c3001525f818161022d0152818161049501528181610551015281816108720152610d3901525f81816101e00152818161046a0152818161057c015281816108470152610d6401525f818160a4015281816104c8015281816105ad015281816108a30152610d9701525f81816101b90152818161026401528181610293015281816103400152818161062e0152818161069f0152818161072a015281816109240152818161099501528181610a2001528181610b3301528181610b620152610c0f01525f818161016b015281816102d5015281816103820152818161065d015281816106e801528181610953015281816109de01528181610ba40152610c51015261114c5ff3fe608060405234801561000f575f80fd5b506004361061009b575f3560e01c806390a7e01f1161006357806390a7e01f1461018d578063ad5c4648146101b4578063c38cff9a146101db578063dad1ef3514610215578063e8f2751114610228575f80fd5b8063218751b21461009f57806325b1445c146100e357806347398d4b14610104578063795674ff1461013f57806389a3027114610166575b5f80fd5b6100c67f000000000000000000000000000000000000000000000000000000000000000081565b6040516001600160a01b0390911681526020015b60405180910390f35b6100f66100f1366004610e29565b61024f565b6040519081526020016100da565b61012b7f000000000000000000000000000000000000000000000000000000000000000081565b60405162ffffff90911681526020016100da565b6100c67f000000000000000000000000000000000000000000000000000000000000000081565b6100c67f000000000000000000000000000000000000000000000000000000000000000081565b61012b7f000000000000000000000000000000000000000000000000000000000000000081565b6100c67f000000000000000000000000000000000000000000000000000000000000000081565b6102027f000000000000000000000000000000000000000000000000000000000000000081565b604051600f9190910b81526020016100da565b6100f6610223366004610e29565b610831565b6102027f000000000000000000000000000000000000000000000000000000000000000081565b5f8215610542576060826001600160a01b03167f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03160361031c577f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f000000000000000000000000000000000000000000000000000000000000000060405160200161030693929190610e6c565b60405160208183030381529060405290506103c7565b827f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000006040516020016103b5959493929190610ea7565b60405160208183030381529060405290505b60405163cdca175360e01b81525f906001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000169063cdca1753906104179085908a90600401610ef9565b5f604051808303815f875af1158015610432573d5f803e3d5ffd5b505050506040513d5f823e601f3d908101601f191682016040526104599190810190611030565b5050604051635e0d443f60e01b81527f0000000000000000000000000000000000000000000000000000000000000000600f90810b60048301527f0000000000000000000000000000000000000000000000000000000000000000900b602482015260448101839052919250507f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031690635e0d443f90606401602060405180830381865afa158015610515573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061053991906110ff565b9250505061082a565b604051635e0d443f60e01b81527f0000000000000000000000000000000000000000000000000000000000000000600f90810b60048301527f0000000000000000000000000000000000000000000000000000000000000000900b6024820152604481018590525f907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031690635e0d443f90606401602060405180830381865afa1580156105fa573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061061e91906110ff565b90506060836001600160a01b03167f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316036106e6577f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000006040516020016106d093929190610e6c565b6040516020818303038152906040529050610791565b7f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000008760405160200161077f959493929190610ea7565b60405160208183030381529060405290505b60405163cdca175360e01b81526001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000169063cdca1753906107df9084908690600401610ef9565b5f604051808303815f875af11580156107fa573d5f803e3d5ffd5b505050506040513d5f823e601f3d908101601f191682016040526108219190810190611030565b50919450505050505b9392505050565b5f8215610b25576040516333ef816560e11b81527f0000000000000000000000000000000000000000000000000000000000000000600f90810b60048301527f0000000000000000000000000000000000000000000000000000000000000000900b6024820152604481018590525f907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906367df02ca90606401602060405180830381865afa1580156108f0573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061091491906110ff565b90506060836001600160a01b03167f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316036109dc577f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000006040516020016109c693929190610e6c565b6040516020818303038152906040529050610a87565b7f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f000000000000000000000000000000000000000000000000000000000000000087604051602001610a75959493929190610ea7565b60405160208183030381529060405290505b604051632f80bb1d60e01b81526001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001690632f80bb1d90610ad59084908690600401610ef9565b5f604051808303815f875af1158015610af0573d5f803e3d5ffd5b505050506040513d5f823e601f3d908101601f19168201604052610b179190810190611030565b5091945061082a9350505050565b6060826001600160a01b03167f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031603610beb577f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f0000000000000000000000000000000000000000000000000000000000000000604051602001610bd593929190610e6c565b6040516020818303038152906040529050610c96565b827f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f0000000000000000000000000000000000000000000000000000000000000000604051602001610c84959493929190610ea7565b60405160208183030381529060405290505b604051632f80bb1d60e01b81525f906001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001690632f80bb1d90610ce69085908a90600401610ef9565b5f604051808303815f875af1158015610d01573d5f803e3d5ffd5b505050506040513d5f823e601f3d908101601f19168201604052610d289190810190611030565b50506040516333ef816560e11b81527f0000000000000000000000000000000000000000000000000000000000000000600f90810b60048301527f0000000000000000000000000000000000000000000000000000000000000000900b602482015260448101839052919250507f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906367df02ca90606401602060405180830381865afa158015610de4573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610e0891906110ff565b9695505050505050565b6001600160a01b0381168114610e26575f80fd5b50565b5f805f60608486031215610e3b575f80fd5b8335925060208401358015158114610e51575f80fd5b91506040840135610e6181610e12565b809150509250925092565b606093841b6bffffffffffffffffffffffff19908116825260e89390931b6001600160e81b0319166014820152921b166017820152602b0190565b6bffffffffffffffffffffffff19606096871b811682526001600160e81b031960e896871b8116601484015294871b811660178301529290941b909216602b840152921b909116602e82015260420190565b604081525f83518060408401525f5b81811015610f255760208187018101516060868401015201610f08565b505f606082850101526060601f19601f8301168401019150508260208301529392505050565b634e487b7160e01b5f52604160045260245ffd5b604051601f8201601f1916810167ffffffffffffffff81118282101715610f8857610f88610f4b565b604052919050565b5f67ffffffffffffffff821115610fa957610fa9610f4b565b5060051b60200190565b5f82601f830112610fc2575f80fd5b81516020610fd7610fd283610f90565b610f5f565b8083825260208201915060208460051b870101935086841115610ff8575f80fd5b602086015b8481101561102557805163ffffffff81168114611018575f80fd5b8352918301918301610ffd565b509695505050505050565b5f805f8060808587031215611043575f80fd5b8451935060208086015167ffffffffffffffff80821115611062575f80fd5b818801915088601f830112611075575f80fd5b8151611083610fd282610f90565b81815260059190911b8301840190848101908b8311156110a1575f80fd5b938501935b828510156110c85784516110b981610e12565b825293850193908501906110a6565b60408b015190985094505050808311156110e0575f80fd5b50506110ee87828801610fb3565b606096909601519497939650505050565b5f6020828403121561110f575f80fd5b505191905056fea26469706673582212203cdf613b5ac039ea5b2bbe6511db3b085cffa3a45fc2e457ca4f5a889ea3befa64736f6c63430008180033000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000efc6516323fbd28e80b85a497b65a86243a54b3e0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001f4000000000000000000000000000000000000000000000000000000000000006400000000000000000000000061ffe014ba17989e743c5f6cb21bf9697530b21e", + "nonce": "0x35", + "chainId": "0x1" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "status": "0x1", + "cumulativeGasUsed": "0x195c259", + "logs": [], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "type": "0x2", + "transactionHash": "0x47f583a1e4f0e424426b8aa4b06f14f510c67bc11efa026abd776932b79b5c01", + "transactionIndex": "0xf4", + "blockHash": "0x598cc1c5f0177d031e5be4874f16e12d128d0b9cfb7e3f75e05d473cc0f53f01", + "blockNumber": "0x164374e", + "gasUsed": "0xf557d", + "effectiveGasPrice": "0x8408fc8", + "from": "0x147676a5e327080386bcc227e103a73dd2979049", + "to": null, + "contractAddress": "0xe453b864d3841469763bda2437e3dd0e38dca222" + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1757655932243, + "chain": 1, + "commit": "5cebde67" +} \ No newline at end of file diff --git a/contracts/broadcast/DeployOnlyExchangeHelpersV2.s.sol/1/run-latest.json b/contracts/broadcast/DeployOnlyExchangeHelpersV2.s.sol/1/run-latest.json new file mode 100644 index 00000000..a9c7c9c8 --- /dev/null +++ b/contracts/broadcast/DeployOnlyExchangeHelpersV2.s.sol/1/run-latest.json @@ -0,0 +1,55 @@ +{ + "transactions": [ + { + "hash": "0x47f583a1e4f0e424426b8aa4b06f14f510c67bc11efa026abd776932b79b5c01", + "transactionType": "CREATE", + "contractName": "HybridCurveUniV3ExchangeHelpersV2", + "contractAddress": "0xe453b864d3841469763bda2437e3dd0e38dca222", + "function": null, + "arguments": [ + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "0xEFc6516323FbD28e80B85A497B65A86243a54B3E", + "1", + "0", + "500", + "100", + "0x61fFE014bA17989E743c5F6cB21bF9697530B21e" + ], + "transaction": { + "from": "0x147676a5e327080386bcc227e103a73dd2979049", + "gas": "0x13ef22", + "value": "0x0", + "input": "0x61018060405234801562000011575f80fd5b506040516200145f3803806200145f8339810160408190526200003491620000b9565b6001600160a01b0397881660805295871660a05293861660c052600f92830b60e052910b6101005262ffffff908116610120521661014052166101605262000168565b6001600160a01b03811681146200008c575f80fd5b50565b8051600f81900b8114620000a1575f80fd5b919050565b805162ffffff81168114620000a1575f80fd5b5f805f805f805f80610100898b031215620000d2575f80fd5b8851620000df8162000077565b60208a0151909850620000f28162000077565b60408a0151909750620001058162000077565b95506200011560608a016200008f565b94506200012560808a016200008f565b93506200013560a08a01620000a6565b92506200014560c08a01620000a6565b915060e0890151620001578162000077565b809150509295985092959890939650565b60805160a05160c05160e0516101005161012051610140516101605161114c620003135f395f8181610144015281816103e0015281816107a801528181610a9e0152610caf01525f81816101920152818161031f0152818161074b01528181610a410152610bee01525f8181610109015281816102b4015281816103610152818161067e0152818161070901528181610974015281816109ff01528181610b830152610c3001525f818161022d0152818161049501528181610551015281816108720152610d3901525f81816101e00152818161046a0152818161057c015281816108470152610d6401525f818160a4015281816104c8015281816105ad015281816108a30152610d9701525f81816101b90152818161026401528181610293015281816103400152818161062e0152818161069f0152818161072a015281816109240152818161099501528181610a2001528181610b3301528181610b620152610c0f01525f818161016b015281816102d5015281816103820152818161065d015281816106e801528181610953015281816109de01528181610ba40152610c51015261114c5ff3fe608060405234801561000f575f80fd5b506004361061009b575f3560e01c806390a7e01f1161006357806390a7e01f1461018d578063ad5c4648146101b4578063c38cff9a146101db578063dad1ef3514610215578063e8f2751114610228575f80fd5b8063218751b21461009f57806325b1445c146100e357806347398d4b14610104578063795674ff1461013f57806389a3027114610166575b5f80fd5b6100c67f000000000000000000000000000000000000000000000000000000000000000081565b6040516001600160a01b0390911681526020015b60405180910390f35b6100f66100f1366004610e29565b61024f565b6040519081526020016100da565b61012b7f000000000000000000000000000000000000000000000000000000000000000081565b60405162ffffff90911681526020016100da565b6100c67f000000000000000000000000000000000000000000000000000000000000000081565b6100c67f000000000000000000000000000000000000000000000000000000000000000081565b61012b7f000000000000000000000000000000000000000000000000000000000000000081565b6100c67f000000000000000000000000000000000000000000000000000000000000000081565b6102027f000000000000000000000000000000000000000000000000000000000000000081565b604051600f9190910b81526020016100da565b6100f6610223366004610e29565b610831565b6102027f000000000000000000000000000000000000000000000000000000000000000081565b5f8215610542576060826001600160a01b03167f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03160361031c577f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f000000000000000000000000000000000000000000000000000000000000000060405160200161030693929190610e6c565b60405160208183030381529060405290506103c7565b827f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000006040516020016103b5959493929190610ea7565b60405160208183030381529060405290505b60405163cdca175360e01b81525f906001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000169063cdca1753906104179085908a90600401610ef9565b5f604051808303815f875af1158015610432573d5f803e3d5ffd5b505050506040513d5f823e601f3d908101601f191682016040526104599190810190611030565b5050604051635e0d443f60e01b81527f0000000000000000000000000000000000000000000000000000000000000000600f90810b60048301527f0000000000000000000000000000000000000000000000000000000000000000900b602482015260448101839052919250507f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031690635e0d443f90606401602060405180830381865afa158015610515573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061053991906110ff565b9250505061082a565b604051635e0d443f60e01b81527f0000000000000000000000000000000000000000000000000000000000000000600f90810b60048301527f0000000000000000000000000000000000000000000000000000000000000000900b6024820152604481018590525f907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031690635e0d443f90606401602060405180830381865afa1580156105fa573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061061e91906110ff565b90506060836001600160a01b03167f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316036106e6577f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000006040516020016106d093929190610e6c565b6040516020818303038152906040529050610791565b7f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000008760405160200161077f959493929190610ea7565b60405160208183030381529060405290505b60405163cdca175360e01b81526001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000169063cdca1753906107df9084908690600401610ef9565b5f604051808303815f875af11580156107fa573d5f803e3d5ffd5b505050506040513d5f823e601f3d908101601f191682016040526108219190810190611030565b50919450505050505b9392505050565b5f8215610b25576040516333ef816560e11b81527f0000000000000000000000000000000000000000000000000000000000000000600f90810b60048301527f0000000000000000000000000000000000000000000000000000000000000000900b6024820152604481018590525f907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906367df02ca90606401602060405180830381865afa1580156108f0573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061091491906110ff565b90506060836001600160a01b03167f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316036109dc577f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000006040516020016109c693929190610e6c565b6040516020818303038152906040529050610a87565b7f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f000000000000000000000000000000000000000000000000000000000000000087604051602001610a75959493929190610ea7565b60405160208183030381529060405290505b604051632f80bb1d60e01b81526001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001690632f80bb1d90610ad59084908690600401610ef9565b5f604051808303815f875af1158015610af0573d5f803e3d5ffd5b505050506040513d5f823e601f3d908101601f19168201604052610b179190810190611030565b5091945061082a9350505050565b6060826001600160a01b03167f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031603610beb577f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f0000000000000000000000000000000000000000000000000000000000000000604051602001610bd593929190610e6c565b6040516020818303038152906040529050610c96565b827f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f0000000000000000000000000000000000000000000000000000000000000000604051602001610c84959493929190610ea7565b60405160208183030381529060405290505b604051632f80bb1d60e01b81525f906001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001690632f80bb1d90610ce69085908a90600401610ef9565b5f604051808303815f875af1158015610d01573d5f803e3d5ffd5b505050506040513d5f823e601f3d908101601f19168201604052610d289190810190611030565b50506040516333ef816560e11b81527f0000000000000000000000000000000000000000000000000000000000000000600f90810b60048301527f0000000000000000000000000000000000000000000000000000000000000000900b602482015260448101839052919250507f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906367df02ca90606401602060405180830381865afa158015610de4573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610e0891906110ff565b9695505050505050565b6001600160a01b0381168114610e26575f80fd5b50565b5f805f60608486031215610e3b575f80fd5b8335925060208401358015158114610e51575f80fd5b91506040840135610e6181610e12565b809150509250925092565b606093841b6bffffffffffffffffffffffff19908116825260e89390931b6001600160e81b0319166014820152921b166017820152602b0190565b6bffffffffffffffffffffffff19606096871b811682526001600160e81b031960e896871b8116601484015294871b811660178301529290941b909216602b840152921b909116602e82015260420190565b604081525f83518060408401525f5b81811015610f255760208187018101516060868401015201610f08565b505f606082850101526060601f19601f8301168401019150508260208301529392505050565b634e487b7160e01b5f52604160045260245ffd5b604051601f8201601f1916810167ffffffffffffffff81118282101715610f8857610f88610f4b565b604052919050565b5f67ffffffffffffffff821115610fa957610fa9610f4b565b5060051b60200190565b5f82601f830112610fc2575f80fd5b81516020610fd7610fd283610f90565b610f5f565b8083825260208201915060208460051b870101935086841115610ff8575f80fd5b602086015b8481101561102557805163ffffffff81168114611018575f80fd5b8352918301918301610ffd565b509695505050505050565b5f805f8060808587031215611043575f80fd5b8451935060208086015167ffffffffffffffff80821115611062575f80fd5b818801915088601f830112611075575f80fd5b8151611083610fd282610f90565b81815260059190911b8301840190848101908b8311156110a1575f80fd5b938501935b828510156110c85784516110b981610e12565b825293850193908501906110a6565b60408b015190985094505050808311156110e0575f80fd5b50506110ee87828801610fb3565b606096909601519497939650505050565b5f6020828403121561110f575f80fd5b505191905056fea26469706673582212203cdf613b5ac039ea5b2bbe6511db3b085cffa3a45fc2e457ca4f5a889ea3befa64736f6c63430008180033000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000efc6516323fbd28e80b85a497b65a86243a54b3e0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001f4000000000000000000000000000000000000000000000000000000000000006400000000000000000000000061ffe014ba17989e743c5f6cb21bf9697530b21e", + "nonce": "0x35", + "chainId": "0x1" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "status": "0x1", + "cumulativeGasUsed": "0x195c259", + "logs": [], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "type": "0x2", + "transactionHash": "0x47f583a1e4f0e424426b8aa4b06f14f510c67bc11efa026abd776932b79b5c01", + "transactionIndex": "0xf4", + "blockHash": "0x598cc1c5f0177d031e5be4874f16e12d128d0b9cfb7e3f75e05d473cc0f53f01", + "blockNumber": "0x164374e", + "gasUsed": "0xf557d", + "effectiveGasPrice": "0x8408fc8", + "from": "0x147676a5e327080386bcc227e103a73dd2979049", + "to": null, + "contractAddress": "0xe453b864d3841469763bda2437e3dd0e38dca222" + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1757655932243, + "chain": 1, + "commit": "5cebde67" +} \ No newline at end of file diff --git a/contracts/broadcast/DeployOnlyExchangeHelpersV2.s.sol/11155111/run-1757656902589.json b/contracts/broadcast/DeployOnlyExchangeHelpersV2.s.sol/11155111/run-1757656902589.json new file mode 100644 index 00000000..825e1eaf --- /dev/null +++ b/contracts/broadcast/DeployOnlyExchangeHelpersV2.s.sol/11155111/run-1757656902589.json @@ -0,0 +1,55 @@ +{ + "transactions": [ + { + "hash": "0xad0d3f10302f23d87edabd80672e9a3daaf6a15cff3c7da11ef7ec063e2f752c", + "transactionType": "CREATE", + "contractName": "HybridCurveUniV3ExchangeHelpersV2", + "contractAddress": "0x3fff2e6b2f7fb3121d966c1d67fa8f3ab5a99f3f", + "function": null, + "arguments": [ + "0x2d8a0DD3aA066837fC7DD1537C0225750Fe13025", + "0x5a490CefAb14d9479ca6A63Cb9d8D17c8785fBcb", + "0xB6dA5dB7A1F97a02cdE4647810888897bc79b7D1", + "1", + "0", + "500", + "100", + "0xEd1f6473345F45b75F8179591dd5bA1888cf2FB3" + ], + "transaction": { + "from": "0xb32073560ba0a715497886d4a4dd83ee9be71390", + "gas": "0x13ef22", + "value": "0x0", + "input": "0x61018060405234801562000011575f80fd5b506040516200145f3803806200145f8339810160408190526200003491620000b9565b6001600160a01b0397881660805295871660a05293861660c052600f92830b60e052910b6101005262ffffff908116610120521661014052166101605262000168565b6001600160a01b03811681146200008c575f80fd5b50565b8051600f81900b8114620000a1575f80fd5b919050565b805162ffffff81168114620000a1575f80fd5b5f805f805f805f80610100898b031215620000d2575f80fd5b8851620000df8162000077565b60208a0151909850620000f28162000077565b60408a0151909750620001058162000077565b95506200011560608a016200008f565b94506200012560808a016200008f565b93506200013560a08a01620000a6565b92506200014560c08a01620000a6565b915060e0890151620001578162000077565b809150509295985092959890939650565b60805160a05160c05160e0516101005161012051610140516101605161114c620003135f395f8181610144015281816103e0015281816107a801528181610a9e0152610caf01525f81816101920152818161031f0152818161074b01528181610a410152610bee01525f8181610109015281816102b4015281816103610152818161067e0152818161070901528181610974015281816109ff01528181610b830152610c3001525f818161022d0152818161049501528181610551015281816108720152610d3901525f81816101e00152818161046a0152818161057c015281816108470152610d6401525f818160a4015281816104c8015281816105ad015281816108a30152610d9701525f81816101b90152818161026401528181610293015281816103400152818161062e0152818161069f0152818161072a015281816109240152818161099501528181610a2001528181610b3301528181610b620152610c0f01525f818161016b015281816102d5015281816103820152818161065d015281816106e801528181610953015281816109de01528181610ba40152610c51015261114c5ff3fe608060405234801561000f575f80fd5b506004361061009b575f3560e01c806390a7e01f1161006357806390a7e01f1461018d578063ad5c4648146101b4578063c38cff9a146101db578063dad1ef3514610215578063e8f2751114610228575f80fd5b8063218751b21461009f57806325b1445c146100e357806347398d4b14610104578063795674ff1461013f57806389a3027114610166575b5f80fd5b6100c67f000000000000000000000000000000000000000000000000000000000000000081565b6040516001600160a01b0390911681526020015b60405180910390f35b6100f66100f1366004610e29565b61024f565b6040519081526020016100da565b61012b7f000000000000000000000000000000000000000000000000000000000000000081565b60405162ffffff90911681526020016100da565b6100c67f000000000000000000000000000000000000000000000000000000000000000081565b6100c67f000000000000000000000000000000000000000000000000000000000000000081565b61012b7f000000000000000000000000000000000000000000000000000000000000000081565b6100c67f000000000000000000000000000000000000000000000000000000000000000081565b6102027f000000000000000000000000000000000000000000000000000000000000000081565b604051600f9190910b81526020016100da565b6100f6610223366004610e29565b610831565b6102027f000000000000000000000000000000000000000000000000000000000000000081565b5f8215610542576060826001600160a01b03167f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03160361031c577f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f000000000000000000000000000000000000000000000000000000000000000060405160200161030693929190610e6c565b60405160208183030381529060405290506103c7565b827f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000006040516020016103b5959493929190610ea7565b60405160208183030381529060405290505b60405163cdca175360e01b81525f906001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000169063cdca1753906104179085908a90600401610ef9565b5f604051808303815f875af1158015610432573d5f803e3d5ffd5b505050506040513d5f823e601f3d908101601f191682016040526104599190810190611030565b5050604051635e0d443f60e01b81527f0000000000000000000000000000000000000000000000000000000000000000600f90810b60048301527f0000000000000000000000000000000000000000000000000000000000000000900b602482015260448101839052919250507f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031690635e0d443f90606401602060405180830381865afa158015610515573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061053991906110ff565b9250505061082a565b604051635e0d443f60e01b81527f0000000000000000000000000000000000000000000000000000000000000000600f90810b60048301527f0000000000000000000000000000000000000000000000000000000000000000900b6024820152604481018590525f907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031690635e0d443f90606401602060405180830381865afa1580156105fa573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061061e91906110ff565b90506060836001600160a01b03167f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316036106e6577f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000006040516020016106d093929190610e6c565b6040516020818303038152906040529050610791565b7f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000008760405160200161077f959493929190610ea7565b60405160208183030381529060405290505b60405163cdca175360e01b81526001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000169063cdca1753906107df9084908690600401610ef9565b5f604051808303815f875af11580156107fa573d5f803e3d5ffd5b505050506040513d5f823e601f3d908101601f191682016040526108219190810190611030565b50919450505050505b9392505050565b5f8215610b25576040516333ef816560e11b81527f0000000000000000000000000000000000000000000000000000000000000000600f90810b60048301527f0000000000000000000000000000000000000000000000000000000000000000900b6024820152604481018590525f907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906367df02ca90606401602060405180830381865afa1580156108f0573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061091491906110ff565b90506060836001600160a01b03167f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316036109dc577f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000006040516020016109c693929190610e6c565b6040516020818303038152906040529050610a87565b7f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f000000000000000000000000000000000000000000000000000000000000000087604051602001610a75959493929190610ea7565b60405160208183030381529060405290505b604051632f80bb1d60e01b81526001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001690632f80bb1d90610ad59084908690600401610ef9565b5f604051808303815f875af1158015610af0573d5f803e3d5ffd5b505050506040513d5f823e601f3d908101601f19168201604052610b179190810190611030565b5091945061082a9350505050565b6060826001600160a01b03167f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031603610beb577f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f0000000000000000000000000000000000000000000000000000000000000000604051602001610bd593929190610e6c565b6040516020818303038152906040529050610c96565b827f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f0000000000000000000000000000000000000000000000000000000000000000604051602001610c84959493929190610ea7565b60405160208183030381529060405290505b604051632f80bb1d60e01b81525f906001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001690632f80bb1d90610ce69085908a90600401610ef9565b5f604051808303815f875af1158015610d01573d5f803e3d5ffd5b505050506040513d5f823e601f3d908101601f19168201604052610d289190810190611030565b50506040516333ef816560e11b81527f0000000000000000000000000000000000000000000000000000000000000000600f90810b60048301527f0000000000000000000000000000000000000000000000000000000000000000900b602482015260448101839052919250507f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906367df02ca90606401602060405180830381865afa158015610de4573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610e0891906110ff565b9695505050505050565b6001600160a01b0381168114610e26575f80fd5b50565b5f805f60608486031215610e3b575f80fd5b8335925060208401358015158114610e51575f80fd5b91506040840135610e6181610e12565b809150509250925092565b606093841b6bffffffffffffffffffffffff19908116825260e89390931b6001600160e81b0319166014820152921b166017820152602b0190565b6bffffffffffffffffffffffff19606096871b811682526001600160e81b031960e896871b8116601484015294871b811660178301529290941b909216602b840152921b909116602e82015260420190565b604081525f83518060408401525f5b81811015610f255760208187018101516060868401015201610f08565b505f606082850101526060601f19601f8301168401019150508260208301529392505050565b634e487b7160e01b5f52604160045260245ffd5b604051601f8201601f1916810167ffffffffffffffff81118282101715610f8857610f88610f4b565b604052919050565b5f67ffffffffffffffff821115610fa957610fa9610f4b565b5060051b60200190565b5f82601f830112610fc2575f80fd5b81516020610fd7610fd283610f90565b610f5f565b8083825260208201915060208460051b870101935086841115610ff8575f80fd5b602086015b8481101561102557805163ffffffff81168114611018575f80fd5b8352918301918301610ffd565b509695505050505050565b5f805f8060808587031215611043575f80fd5b8451935060208086015167ffffffffffffffff80821115611062575f80fd5b818801915088601f830112611075575f80fd5b8151611083610fd282610f90565b81815260059190911b8301840190848101908b8311156110a1575f80fd5b938501935b828510156110c85784516110b981610e12565b825293850193908501906110a6565b60408b015190985094505050808311156110e0575f80fd5b50506110ee87828801610fb3565b606096909601519497939650505050565b5f6020828403121561110f575f80fd5b505191905056fea26469706673582212203cdf613b5ac039ea5b2bbe6511db3b085cffa3a45fc2e457ca4f5a889ea3befa64736f6c634300081800330000000000000000000000002d8a0dd3aa066837fc7dd1537c0225750fe130250000000000000000000000005a490cefab14d9479ca6a63cb9d8d17c8785fbcb000000000000000000000000b6da5db7a1f97a02cde4647810888897bc79b7d10000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001f40000000000000000000000000000000000000000000000000000000000000064000000000000000000000000ed1f6473345f45b75f8179591dd5ba1888cf2fb3", + "nonce": "0x1e6", + "chainId": "0xaa36a7" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "status": "0x1", + "cumulativeGasUsed": "0xcb9d3b", + "logs": [], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "type": "0x2", + "transactionHash": "0xad0d3f10302f23d87edabd80672e9a3daaf6a15cff3c7da11ef7ec063e2f752c", + "transactionIndex": "0x8d", + "blockHash": "0x55f7e2a3c8e8452cadd6598e8a3f7d4b4a4e83db11ec5b9e16cdbfba16e893fb", + "blockNumber": "0x8c2b80", + "gasUsed": "0xf557d", + "effectiveGasPrice": "0xf426f", + "from": "0xb32073560ba0a715497886d4a4dd83ee9be71390", + "to": null, + "contractAddress": "0x3fff2e6b2f7fb3121d966c1d67fa8f3ab5a99f3f" + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1757656902589, + "chain": 11155111, + "commit": "5cebde67" +} \ No newline at end of file diff --git a/contracts/broadcast/DeployOnlyExchangeHelpersV2.s.sol/11155111/run-latest.json b/contracts/broadcast/DeployOnlyExchangeHelpersV2.s.sol/11155111/run-latest.json new file mode 100644 index 00000000..825e1eaf --- /dev/null +++ b/contracts/broadcast/DeployOnlyExchangeHelpersV2.s.sol/11155111/run-latest.json @@ -0,0 +1,55 @@ +{ + "transactions": [ + { + "hash": "0xad0d3f10302f23d87edabd80672e9a3daaf6a15cff3c7da11ef7ec063e2f752c", + "transactionType": "CREATE", + "contractName": "HybridCurveUniV3ExchangeHelpersV2", + "contractAddress": "0x3fff2e6b2f7fb3121d966c1d67fa8f3ab5a99f3f", + "function": null, + "arguments": [ + "0x2d8a0DD3aA066837fC7DD1537C0225750Fe13025", + "0x5a490CefAb14d9479ca6A63Cb9d8D17c8785fBcb", + "0xB6dA5dB7A1F97a02cdE4647810888897bc79b7D1", + "1", + "0", + "500", + "100", + "0xEd1f6473345F45b75F8179591dd5bA1888cf2FB3" + ], + "transaction": { + "from": "0xb32073560ba0a715497886d4a4dd83ee9be71390", + "gas": "0x13ef22", + "value": "0x0", + "input": "0x61018060405234801562000011575f80fd5b506040516200145f3803806200145f8339810160408190526200003491620000b9565b6001600160a01b0397881660805295871660a05293861660c052600f92830b60e052910b6101005262ffffff908116610120521661014052166101605262000168565b6001600160a01b03811681146200008c575f80fd5b50565b8051600f81900b8114620000a1575f80fd5b919050565b805162ffffff81168114620000a1575f80fd5b5f805f805f805f80610100898b031215620000d2575f80fd5b8851620000df8162000077565b60208a0151909850620000f28162000077565b60408a0151909750620001058162000077565b95506200011560608a016200008f565b94506200012560808a016200008f565b93506200013560a08a01620000a6565b92506200014560c08a01620000a6565b915060e0890151620001578162000077565b809150509295985092959890939650565b60805160a05160c05160e0516101005161012051610140516101605161114c620003135f395f8181610144015281816103e0015281816107a801528181610a9e0152610caf01525f81816101920152818161031f0152818161074b01528181610a410152610bee01525f8181610109015281816102b4015281816103610152818161067e0152818161070901528181610974015281816109ff01528181610b830152610c3001525f818161022d0152818161049501528181610551015281816108720152610d3901525f81816101e00152818161046a0152818161057c015281816108470152610d6401525f818160a4015281816104c8015281816105ad015281816108a30152610d9701525f81816101b90152818161026401528181610293015281816103400152818161062e0152818161069f0152818161072a015281816109240152818161099501528181610a2001528181610b3301528181610b620152610c0f01525f818161016b015281816102d5015281816103820152818161065d015281816106e801528181610953015281816109de01528181610ba40152610c51015261114c5ff3fe608060405234801561000f575f80fd5b506004361061009b575f3560e01c806390a7e01f1161006357806390a7e01f1461018d578063ad5c4648146101b4578063c38cff9a146101db578063dad1ef3514610215578063e8f2751114610228575f80fd5b8063218751b21461009f57806325b1445c146100e357806347398d4b14610104578063795674ff1461013f57806389a3027114610166575b5f80fd5b6100c67f000000000000000000000000000000000000000000000000000000000000000081565b6040516001600160a01b0390911681526020015b60405180910390f35b6100f66100f1366004610e29565b61024f565b6040519081526020016100da565b61012b7f000000000000000000000000000000000000000000000000000000000000000081565b60405162ffffff90911681526020016100da565b6100c67f000000000000000000000000000000000000000000000000000000000000000081565b6100c67f000000000000000000000000000000000000000000000000000000000000000081565b61012b7f000000000000000000000000000000000000000000000000000000000000000081565b6100c67f000000000000000000000000000000000000000000000000000000000000000081565b6102027f000000000000000000000000000000000000000000000000000000000000000081565b604051600f9190910b81526020016100da565b6100f6610223366004610e29565b610831565b6102027f000000000000000000000000000000000000000000000000000000000000000081565b5f8215610542576060826001600160a01b03167f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03160361031c577f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f000000000000000000000000000000000000000000000000000000000000000060405160200161030693929190610e6c565b60405160208183030381529060405290506103c7565b827f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000006040516020016103b5959493929190610ea7565b60405160208183030381529060405290505b60405163cdca175360e01b81525f906001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000169063cdca1753906104179085908a90600401610ef9565b5f604051808303815f875af1158015610432573d5f803e3d5ffd5b505050506040513d5f823e601f3d908101601f191682016040526104599190810190611030565b5050604051635e0d443f60e01b81527f0000000000000000000000000000000000000000000000000000000000000000600f90810b60048301527f0000000000000000000000000000000000000000000000000000000000000000900b602482015260448101839052919250507f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031690635e0d443f90606401602060405180830381865afa158015610515573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061053991906110ff565b9250505061082a565b604051635e0d443f60e01b81527f0000000000000000000000000000000000000000000000000000000000000000600f90810b60048301527f0000000000000000000000000000000000000000000000000000000000000000900b6024820152604481018590525f907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031690635e0d443f90606401602060405180830381865afa1580156105fa573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061061e91906110ff565b90506060836001600160a01b03167f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316036106e6577f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000006040516020016106d093929190610e6c565b6040516020818303038152906040529050610791565b7f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000008760405160200161077f959493929190610ea7565b60405160208183030381529060405290505b60405163cdca175360e01b81526001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000169063cdca1753906107df9084908690600401610ef9565b5f604051808303815f875af11580156107fa573d5f803e3d5ffd5b505050506040513d5f823e601f3d908101601f191682016040526108219190810190611030565b50919450505050505b9392505050565b5f8215610b25576040516333ef816560e11b81527f0000000000000000000000000000000000000000000000000000000000000000600f90810b60048301527f0000000000000000000000000000000000000000000000000000000000000000900b6024820152604481018590525f907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906367df02ca90606401602060405180830381865afa1580156108f0573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061091491906110ff565b90506060836001600160a01b03167f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316036109dc577f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000006040516020016109c693929190610e6c565b6040516020818303038152906040529050610a87565b7f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f000000000000000000000000000000000000000000000000000000000000000087604051602001610a75959493929190610ea7565b60405160208183030381529060405290505b604051632f80bb1d60e01b81526001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001690632f80bb1d90610ad59084908690600401610ef9565b5f604051808303815f875af1158015610af0573d5f803e3d5ffd5b505050506040513d5f823e601f3d908101601f19168201604052610b179190810190611030565b5091945061082a9350505050565b6060826001600160a01b03167f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031603610beb577f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f0000000000000000000000000000000000000000000000000000000000000000604051602001610bd593929190610e6c565b6040516020818303038152906040529050610c96565b827f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f0000000000000000000000000000000000000000000000000000000000000000604051602001610c84959493929190610ea7565b60405160208183030381529060405290505b604051632f80bb1d60e01b81525f906001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001690632f80bb1d90610ce69085908a90600401610ef9565b5f604051808303815f875af1158015610d01573d5f803e3d5ffd5b505050506040513d5f823e601f3d908101601f19168201604052610d289190810190611030565b50506040516333ef816560e11b81527f0000000000000000000000000000000000000000000000000000000000000000600f90810b60048301527f0000000000000000000000000000000000000000000000000000000000000000900b602482015260448101839052919250507f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906367df02ca90606401602060405180830381865afa158015610de4573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610e0891906110ff565b9695505050505050565b6001600160a01b0381168114610e26575f80fd5b50565b5f805f60608486031215610e3b575f80fd5b8335925060208401358015158114610e51575f80fd5b91506040840135610e6181610e12565b809150509250925092565b606093841b6bffffffffffffffffffffffff19908116825260e89390931b6001600160e81b0319166014820152921b166017820152602b0190565b6bffffffffffffffffffffffff19606096871b811682526001600160e81b031960e896871b8116601484015294871b811660178301529290941b909216602b840152921b909116602e82015260420190565b604081525f83518060408401525f5b81811015610f255760208187018101516060868401015201610f08565b505f606082850101526060601f19601f8301168401019150508260208301529392505050565b634e487b7160e01b5f52604160045260245ffd5b604051601f8201601f1916810167ffffffffffffffff81118282101715610f8857610f88610f4b565b604052919050565b5f67ffffffffffffffff821115610fa957610fa9610f4b565b5060051b60200190565b5f82601f830112610fc2575f80fd5b81516020610fd7610fd283610f90565b610f5f565b8083825260208201915060208460051b870101935086841115610ff8575f80fd5b602086015b8481101561102557805163ffffffff81168114611018575f80fd5b8352918301918301610ffd565b509695505050505050565b5f805f8060808587031215611043575f80fd5b8451935060208086015167ffffffffffffffff80821115611062575f80fd5b818801915088601f830112611075575f80fd5b8151611083610fd282610f90565b81815260059190911b8301840190848101908b8311156110a1575f80fd5b938501935b828510156110c85784516110b981610e12565b825293850193908501906110a6565b60408b015190985094505050808311156110e0575f80fd5b50506110ee87828801610fb3565b606096909601519497939650505050565b5f6020828403121561110f575f80fd5b505191905056fea26469706673582212203cdf613b5ac039ea5b2bbe6511db3b085cffa3a45fc2e457ca4f5a889ea3befa64736f6c634300081800330000000000000000000000002d8a0dd3aa066837fc7dd1537c0225750fe130250000000000000000000000005a490cefab14d9479ca6a63cb9d8d17c8785fbcb000000000000000000000000b6da5db7a1f97a02cde4647810888897bc79b7d10000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001f40000000000000000000000000000000000000000000000000000000000000064000000000000000000000000ed1f6473345f45b75f8179591dd5ba1888cf2fb3", + "nonce": "0x1e6", + "chainId": "0xaa36a7" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "status": "0x1", + "cumulativeGasUsed": "0xcb9d3b", + "logs": [], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "type": "0x2", + "transactionHash": "0xad0d3f10302f23d87edabd80672e9a3daaf6a15cff3c7da11ef7ec063e2f752c", + "transactionIndex": "0x8d", + "blockHash": "0x55f7e2a3c8e8452cadd6598e8a3f7d4b4a4e83db11ec5b9e16cdbfba16e893fb", + "blockNumber": "0x8c2b80", + "gasUsed": "0xf557d", + "effectiveGasPrice": "0xf426f", + "from": "0xb32073560ba0a715497886d4a4dd83ee9be71390", + "to": null, + "contractAddress": "0x3fff2e6b2f7fb3121d966c1d67fa8f3ab5a99f3f" + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1757656902589, + "chain": 11155111, + "commit": "5cebde67" +} \ No newline at end of file diff --git a/contracts/broadcast/DeployRedemptionHelper.s.sol/1/run-1762510760071.json b/contracts/broadcast/DeployRedemptionHelper.s.sol/1/run-1762510760071.json new file mode 100644 index 00000000..6048bf2f --- /dev/null +++ b/contracts/broadcast/DeployRedemptionHelper.s.sol/1/run-1762510760071.json @@ -0,0 +1,49 @@ +{ + "transactions": [ + { + "hash": "0xda46524ec386c797a2bf995ee65a1ce8c8c1f826cc8488dda7bbcaa588ef935c", + "transactionType": "CREATE", + "contractName": "RedemptionHelper", + "contractAddress": "0xb366256d033ae7e4f7bddec822a5adec9df07b80", + "function": null, + "arguments": [ + "0xf949982B91C8c61e952B3bA942cbbfaef5386684", + "[0x20F7C9ad66983F6523a0881d0f82406541417526, 0x8d733F7ea7c23Cbea7C613B6eBd845d46d3aAc54, 0x6106046F031a22713697e04C08B330dDaf3e8789]" + ], + "transaction": { + "from": "0x83cfa33a2ee969f8add9a2acdcdc0d7e556e5ed0", + "gas": "0x2597af", + "value": "0x0", + "input": "0x60e060405234801562000010575f80fd5b50604051620024343803806200243483398101604081905262000033916200035e565b816001600160a01b03166330504b6f6040518163ffffffff1660e01b8152600401602060405180830381865afa15801562000070573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019062000096919062000444565b815114620000eb5760405162461bcd60e51b815260206004820152601a60248201527f57726f6e67206e756d626572206f66207265676973747269657300000000000060448201526064015b60405180910390fd5b80516080526001600160a01b03821660a08190526040805163630afce560e01b8152905163630afce5916004808201926020929091908290030181865afa15801562000139573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906200015f91906200045c565b6001600160a01b031660c0525f5b815181101562000317578181815181106200018c576200018c62000481565b60200260200101516001600160a01b0316633d83908a6040518163ffffffff1660e01b8152600401602060405180830381865afa158015620001d0573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190620001f691906200045c565b604051630bc17feb60e01b8152600481018390526001600160a01b0391821691851690630bc17feb90602401602060405180830381865afa1580156200023e573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906200026491906200045c565b6001600160a01b031614620002bc5760405162461bcd60e51b815260206004820152601560248201527f54726f76654d616e61676572206d69736d6174636800000000000000000000006044820152606401620000e2565b5f828281518110620002d257620002d262000481565b6020908102919091018101518254600180820185555f9485529290932090920180546001600160a01b0319166001600160a01b0390931692909217909155016200016d565b50505062000495565b6001600160a01b038116811462000335575f80fd5b50565b634e487b7160e01b5f52604160045260245ffd5b8051620003598162000320565b919050565b5f806040838503121562000370575f80fd5b82516200037d8162000320565b602084810151919350906001600160401b03808211156200039c575f80fd5b818601915086601f830112620003b0575f80fd5b815181811115620003c557620003c562000338565b8060051b604051601f19603f83011681018181108582111715620003ed57620003ed62000338565b6040529182528482019250838101850191898311156200040b575f80fd5b938501935b82851015620004345762000424856200034c565b8452938501939285019262000410565b8096505050505050509250929050565b5f6020828403121562000455575f80fd5b5051919050565b5f602082840312156200046d575f80fd5b81516200047a8162000320565b9392505050565b634e487b7160e01b5f52603260045260245ffd5b60805160a05160c051611ef86200053c5f395f818160ae015281816108ae0152818161094401528181610be80152610c7f01525f8181610137015281816102ae0152818161074c01526109db01525f818160ed015281816101bc01528181610323015281816103a50152818161061d0152818161068e0152818161071001528181610a3e01528181610d0101528181610dc4015281816110ae01526111d50152611ef85ff3fe608060405234801561000f575f80fd5b506004361061007a575f3560e01c8063c8c52fb711610058578063c8c52fb71461011d578063d330fadd14610132578063dfa397d714610159578063edf26d9b1461017a575f80fd5b80632b9f137c1461007e578063630afce5146100a95780636c088de8146100e8575b5f80fd5b61009161008c3660046119f1565b61018d565b6040516100a093929190611a11565b60405180910390f35b6100d07f000000000000000000000000000000000000000000000000000000000000000081565b6040516001600160a01b0390911681526020016100a0565b61010f7f000000000000000000000000000000000000000000000000000000000000000081565b6040519081526020016100a0565b61013061012b366004611ae1565b6105c7565b005b6100d07f000000000000000000000000000000000000000000000000000000000000000081565b61016c6101673660046119f1565b610cfc565b6040516100a0929190611b9e565b6100d0610188366004611c42565b6116fd565b5f8060605f8061019d8787610cfc565b91509150805f036101b5575f809450945050506105c0565b8694505f5b7f0000000000000000000000000000000000000000000000000000000000000000811015610297578281815181106101f4576101f4611c59565b602002602001015160400151801561022857505f83828151811061021a5761021a611c59565b602002602001015160800151115b1561028f575f83828151811061024057610240611c59565b6020026020010151608001518385848151811061025f5761025f611c59565b602002602001015160c001516102759190611c81565b61027f9190611c98565b90508681101561028d578096505b505b6001016101ba565b506040516331f889a760e11b8152600481018690527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906363f1134e90602401602060405180830381865afa1580156102fb573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061031f9190611cb7565b93507f000000000000000000000000000000000000000000000000000000000000000067ffffffffffffffff81111561035a5761035a611a72565b60405190808252806020026020018201604052801561039e57816020015b604080518082019091525f80825260208201528152602001906001900390816103785790505b5092505f5b7f00000000000000000000000000000000000000000000000000000000000000008110156105bc578281815181106103dd576103dd611c59565b602002602001015160400151801561041157505f83828151811061040357610403611c59565b602002602001015160800151115b156105b4575f80828154811061042957610429611c59565b5f918252602091829020015460408051633a0df78d60e11b815290516001600160a01b039092169263741bef1a926004808401938290030181865afa158015610474573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906104989190611ce5565b6001600160a01b031663b5b65cef6040518163ffffffff1660e01b815260040160408051808303815f875af11580156104d3573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906104f79190611d1b565b5090508284838151811061050d5761050d611c59565b602002602001015160800151886105249190611c81565b61052e9190611c98565b85838151811061054057610540611c59565b6020908102919091010151528061055f87670de0b6b3a7640000611d45565b86848151811061057157610571611c59565b60200260200101515f01516105869190611c81565b6105909190611c98565b8583815181106105a2576105a2611c59565b60200260200101516020018181525050505b6001016103a3565b5050505b9250925092565b5f841161061b5760405162461bcd60e51b815260206004820181905260248201527f52656465656d656420616d6f756e74206d757374206265206e6f6e2d7a65726f60448201526064015b60405180910390fd5b7f000000000000000000000000000000000000000000000000000000000000000081511461068b5760405162461bcd60e51b815260206004820152601d60248201527f57726f6e67205f6d696e436f6c6c52656465656d6564206c656e6774680000006044820152606401610612565b5f7f000000000000000000000000000000000000000000000000000000000000000067ffffffffffffffff8111156106c5576106c5611a72565b60405190808252806020026020018201604052801561070957816020015b604080518082019091525f80825260208201528152602001906001900390816106e35790505b5090505f5b7f000000000000000000000000000000000000000000000000000000000000000081101561089657604051631c96a19760e31b8152600481018290527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063e4b50cb890602401602060405180830381865afa158015610799573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906107bd9190611ce5565b8282815181106107cf576107cf611c59565b60209081029190910101516001600160a01b03909116905281518290829081106107fb576107fb611c59565b6020908102919091010151516040516370a0823160e01b81523060048201526001600160a01b03909116906370a0823190602401602060405180830381865afa15801561084a573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061086e9190611cb7565b82828151811061088057610880611c59565b602090810291909101810151015260010161070e565b506040516370a0823160e01b81523060048201525f907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906370a0823190602401602060405180830381865afa1580156108fb573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061091f9190611cb7565b6040516323b872dd60e01b8152336004820152306024820152604481018890529091507f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906323b872dd906064016020604051808303815f875af1158015610992573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906109b69190611d58565b5060405163ab6d53bd60e01b81526004810187905260248101869052604481018590527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063ab6d53bd906064015f604051808303815f87803b158015610a24575f80fd5b505af1158015610a36573d5f803e3d5ffd5b505050505f5b7f0000000000000000000000000000000000000000000000000000000000000000811015610bc6575f838281518110610a7757610a77611c59565b602002602001015160200151848381518110610a9557610a95611c59565b6020908102919091010151516040516370a0823160e01b81523060048201526001600160a01b03909116906370a0823190602401602060405180830381865afa158015610ae4573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610b089190611cb7565b610b129190611d45565b9050848281518110610b2657610b26611c59565b6020026020010151811015610b7d5760405162461bcd60e51b815260206004820181905260248201527f496e73756666696369656e7420636f6c6c61746572616c2072656465656d65646044820152606401610612565b8015610bbd57610bbd3382868581518110610b9a57610b9a611c59565b60200260200101515f01516001600160a01b03166117249092919063ffffffff16565b50600101610a3c565b506040516370a0823160e01b81523060048201525f9082906001600160a01b037f000000000000000000000000000000000000000000000000000000000000000016906370a0823190602401602060405180830381865afa158015610c2d573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610c519190611cb7565b610c5b9190611d45565b90508015610cf35760405163a9059cbb60e01b8152336004820152602481018290527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063a9059cbb906044016020604051808303815f875af1158015610ccd573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610cf19190611d58565b505b50505050505050565b60605f7f000000000000000000000000000000000000000000000000000000000000000067ffffffffffffffff811115610d3857610d38611a72565b604051908082528060200260200182016040528015610dbd57816020015b610daa6040518061010001604052805f6001600160a01b031681526020015f6001600160a01b031681526020015f151581526020015f81526020015f81526020015f81526020015f81526020015f81525090565b815260200190600190039081610d565790505b5091505f5b7f000000000000000000000000000000000000000000000000000000000000000081101561108b575f8181548110610dfc57610dfc611c59565b5f918252602091829020015460408051631ec1c84560e11b815290516001600160a01b0390921692633d83908a926004808401938290030181865afa158015610e47573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610e6b9190611ce5565b838281518110610e7d57610e7d611c59565b60209081029190910101516001600160a01b0390911690525f805482908110610ea857610ea8611c59565b5f918252602091829020015460408051632ba461d560e21b815290516001600160a01b039092169263ae918754926004808401938290030181865afa158015610ef3573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610f179190611ce5565b838281518110610f2957610f29611c59565b6020026020010151602001906001600160a01b031690816001600160a01b031681525050828181518110610f5f57610f5f611c59565b60200260200101515f01516001600160a01b0316634ea15f376040518163ffffffff1660e01b81526004016060604051808303815f875af1158015610fa6573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610fca9190611d71565b858481518110610fdc57610fdc611c59565b6020026020010151608001868581518110610ff957610ff9611c59565b602002602001015160600187868151811061101657611016611c59565b60200260200101516040018315151515815250838152508381525050505082818151811061104657611046611c59565b602002602001015160400151156110835782818151811061106957611069611c59565b602002602001015160800151826110809190611da3565b91505b600101610dc2565b50805f10801561109a57508381105b156110a3578093505b805f036111cb575f5b7f00000000000000000000000000000000000000000000000000000000000000008110156111c9578281815181106110e6576110e6611c59565b60200260200101515f01516001600160a01b031663105b403b6040518163ffffffff1660e01b8152600401602060405180830381865afa15801561112c573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906111509190611cb7565b83828151811061116257611162611c59565b6020026020010151608001818152505082818151811061118457611184611c59565b602002602001015160400151156111c1578281815181106111a7576111a7611c59565b602002602001015160800151826111be9190611da3565b91505b6001016110ac565b505b80156116f6575f5b7f00000000000000000000000000000000000000000000000000000000000000008110156116f45782818151811061120d5761120d611c59565b602002602001015160400151156116ec578183828151811061123157611231611c59565b602002602001015160800151866112489190611c81565b6112529190611c98565b83828151811061126457611264611c59565b602002602001015160a001818152505082818151811061128657611286611c59565b602002602001015160a001515f03156116ec575f8382815181106112ac576112ac611c59565b60200260200101515f01516001600160a01b031663015402876040518163ffffffff1660e01b8152600401602060405180830381865afa1580156112f2573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906113169190611cb7565b90505f84838151811061132b5761132b611c59565b6020026020010151602001516001600160a01b0316634d6228316040518163ffffffff1660e01b8152600401602060405180830381865afa158015611372573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906113969190611cb7565b90505f80835f0361143157828786815181106113b4576113b4611c59565b6020026020010151602001516001600160a01b0316631037a5f4856040518263ffffffff1660e01b81526004016113ed91815260200190565b602060405180830381865afa158015611408573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061142c9190611cb7565b611434565b83835b915091505f87868151811061144b5761144b611c59565b602002602001015160e00181815250505b8787868151811061146f5761146f611c59565b602002602001015160e001511080611485575087155b156116e75786858151811061149c5761149c611c59565b602002602001015160a001518786815181106114ba576114ba611c59565b602002602001015160c0015114806114d0575081155b6116e7575f8786815181106114e7576114e7611c59565b60200260200101515f01516001600160a01b031663aad3f404846040518263ffffffff1660e01b815260040161151f91815260200190565b61014060405180830381865afa15801561153b573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061155f9190611db6565b9050670de0b6b3a7640000815f015189888151811061158057611580611c59565b602002602001015160600151836020015161159b9190611c81565b6115a59190611c98565b10611628576115fa8887815181106115bf576115bf611c59565b602002602001015160c001518988815181106115dd576115dd611c59565b602002602001015160a001516115f39190611d45565b825161177b565b88878151811061160c5761160c611c59565b602002602001015160c0018181516116249190611da3565b9052505b81925087868151811061163d5761163d611c59565b6020026020010151602001516001600160a01b0316631037a5f4836040518263ffffffff1660e01b815260040161167691815260200190565b602060405180830381865afa158015611691573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906116b59190611cb7565b9150508685815181106116ca576116ca611c59565b602002602001015160e00180516116e090611e3b565b905261145c565b505050505b6001016111d3565b505b9250929050565b5f818154811061170b575f80fd5b5f918252602090912001546001600160a01b0316905081565b604080516001600160a01b038416602482015260448082018490528251808303909101815260649091019091526020810180516001600160e01b031663a9059cbb60e01b179052611776908490611794565b505050565b5f818310611789578161178b565b825b90505b92915050565b5f6117e8826040518060400160405280602081526020017f5361666545524332303a206c6f772d6c6576656c2063616c6c206661696c6564815250856001600160a01b03166118679092919063ffffffff16565b905080515f14806118085750808060200190518101906118089190611d58565b6117765760405162461bcd60e51b815260206004820152602a60248201527f5361666545524332303a204552433230206f7065726174696f6e20646964206e6044820152691bdd081cdd58d8d9595960b21b6064820152608401610612565b606061187584845f8561187d565b949350505050565b6060824710156118de5760405162461bcd60e51b815260206004820152602660248201527f416464726573733a20696e73756666696369656e742062616c616e636520666f6044820152651c8818d85b1b60d21b6064820152608401610612565b5f80866001600160a01b031685876040516118f99190611e75565b5f6040518083038185875af1925050503d805f8114611933576040519150601f19603f3d011682016040523d82523d5f602084013e611938565b606091505b509150915061194987838387611954565b979650505050505050565b606083156119c25782515f036119bb576001600160a01b0385163b6119bb5760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e74726163740000006044820152606401610612565b5081611875565b61187583838151156119d75781518083602001fd5b8060405162461bcd60e51b81526004016106129190611e90565b5f8060408385031215611a02575f80fd5b50508035926020909101359150565b5f6060820185835260208560208501526040606060408601528286518085526080870191506020880194505f5b81811015611a6357855180518452850151858401529484019491830191600101611a3e565b50909998505050505050505050565b634e487b7160e01b5f52604160045260245ffd5b604051610140810167ffffffffffffffff81118282101715611aaa57611aaa611a72565b60405290565b604051601f8201601f1916810167ffffffffffffffff81118282101715611ad957611ad9611a72565b604052919050565b5f805f8060808587031215611af4575f80fd5b84359350602080860135935060408601359250606086013567ffffffffffffffff80821115611b21575f80fd5b818801915088601f830112611b34575f80fd5b813581811115611b4657611b46611a72565b8060051b9150611b57848301611ab0565b818152918301840191848101908b841115611b70575f80fd5b938501935b83851015611b8e57843582529385019390850190611b75565b989b979a50959850505050505050565b604080825283518282018190525f9190606090818501906020808901865b83811015611c2b57815180516001600160a01b0390811687528482015116848701528781015115158887015286810151878701526080808201519087015260a0808201519087015260c0808201519087015260e090810151908601526101009094019390820190600101611bbc565b505050508093505050508260208301529392505050565b5f60208284031215611c52575f80fd5b5035919050565b634e487b7160e01b5f52603260045260245ffd5b634e487b7160e01b5f52601160045260245ffd5b808202811582820484141761178e5761178e611c6d565b5f82611cb257634e487b7160e01b5f52601260045260245ffd5b500490565b5f60208284031215611cc7575f80fd5b5051919050565b6001600160a01b0381168114611ce2575f80fd5b50565b5f60208284031215611cf5575f80fd5b8151611d0081611cce565b9392505050565b80518015158114611d16575f80fd5b919050565b5f8060408385031215611d2c575f80fd5b82519150611d3c60208401611d07565b90509250929050565b8181038181111561178e5761178e611c6d565b5f60208284031215611d68575f80fd5b61178b82611d07565b5f805f60608486031215611d83575f80fd5b8351925060208401519150611d9a60408501611d07565b90509250925092565b8082018082111561178e5761178e611c6d565b5f6101408284031215611dc7575f80fd5b611dcf611a86565b825181526020830151602082015260408301516040820152606083015160608201526080830151608082015260a083015160a082015260c083015160c082015260e083015160e08201526101008084015181830152506101208084015181830152508091505092915050565b5f60018201611e4c57611e4c611c6d565b5060010190565b5f5b83811015611e6d578181015183820152602001611e55565b50505f910152565b5f8251611e86818460208701611e53565b9190910192915050565b602081525f8251806020840152611eae816040850160208701611e53565b601f01601f1916919091016040019291505056fea264697066735822122003eb4c9aef9f87887838b06a3da359c8b884fbbaaac8334fcaad45fd78facaca64736f6c63430008180033000000000000000000000000f949982b91c8c61e952b3ba942cbbfaef53866840000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000300000000000000000000000020f7c9ad66983f6523a0881d0f824065414175260000000000000000000000008d733f7ea7c23cbea7c613b6ebd845d46d3aac540000000000000000000000006106046f031a22713697e04c08b330ddaf3e8789", + "nonce": "0x61", + "chainId": "0x1" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "status": "0x1", + "cumulativeGasUsed": "0x1140117", + "logs": [], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "type": "0x2", + "transactionHash": "0xda46524ec386c797a2bf995ee65a1ce8c8c1f826cc8488dda7bbcaa588ef935c", + "transactionIndex": "0x9c", + "blockHash": "0x96082aa7c26acf552f8aef100b7b86d9dd039871f8091ba1970ece99ac4c300d", + "blockNumber": "0x16a58bf", + "gasUsed": "0x1cead6", + "effectiveGasPrice": "0x5047091e", + "from": "0x83cfa33a2ee969f8add9a2acdcdc0d7e556e5ed0", + "to": null, + "contractAddress": "0xb366256d033ae7e4f7bddec822a5adec9df07b80" + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1762510760071, + "chain": 1, + "commit": "a17891ec" +} \ No newline at end of file diff --git a/contracts/broadcast/DeployRedemptionHelper.s.sol/1/run-latest.json b/contracts/broadcast/DeployRedemptionHelper.s.sol/1/run-latest.json new file mode 100644 index 00000000..6048bf2f --- /dev/null +++ b/contracts/broadcast/DeployRedemptionHelper.s.sol/1/run-latest.json @@ -0,0 +1,49 @@ +{ + "transactions": [ + { + "hash": "0xda46524ec386c797a2bf995ee65a1ce8c8c1f826cc8488dda7bbcaa588ef935c", + "transactionType": "CREATE", + "contractName": "RedemptionHelper", + "contractAddress": "0xb366256d033ae7e4f7bddec822a5adec9df07b80", + "function": null, + "arguments": [ + "0xf949982B91C8c61e952B3bA942cbbfaef5386684", + "[0x20F7C9ad66983F6523a0881d0f82406541417526, 0x8d733F7ea7c23Cbea7C613B6eBd845d46d3aAc54, 0x6106046F031a22713697e04C08B330dDaf3e8789]" + ], + "transaction": { + "from": "0x83cfa33a2ee969f8add9a2acdcdc0d7e556e5ed0", + "gas": "0x2597af", + "value": "0x0", + "input": "0x60e060405234801562000010575f80fd5b50604051620024343803806200243483398101604081905262000033916200035e565b816001600160a01b03166330504b6f6040518163ffffffff1660e01b8152600401602060405180830381865afa15801562000070573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019062000096919062000444565b815114620000eb5760405162461bcd60e51b815260206004820152601a60248201527f57726f6e67206e756d626572206f66207265676973747269657300000000000060448201526064015b60405180910390fd5b80516080526001600160a01b03821660a08190526040805163630afce560e01b8152905163630afce5916004808201926020929091908290030181865afa15801562000139573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906200015f91906200045c565b6001600160a01b031660c0525f5b815181101562000317578181815181106200018c576200018c62000481565b60200260200101516001600160a01b0316633d83908a6040518163ffffffff1660e01b8152600401602060405180830381865afa158015620001d0573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190620001f691906200045c565b604051630bc17feb60e01b8152600481018390526001600160a01b0391821691851690630bc17feb90602401602060405180830381865afa1580156200023e573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906200026491906200045c565b6001600160a01b031614620002bc5760405162461bcd60e51b815260206004820152601560248201527f54726f76654d616e61676572206d69736d6174636800000000000000000000006044820152606401620000e2565b5f828281518110620002d257620002d262000481565b6020908102919091018101518254600180820185555f9485529290932090920180546001600160a01b0319166001600160a01b0390931692909217909155016200016d565b50505062000495565b6001600160a01b038116811462000335575f80fd5b50565b634e487b7160e01b5f52604160045260245ffd5b8051620003598162000320565b919050565b5f806040838503121562000370575f80fd5b82516200037d8162000320565b602084810151919350906001600160401b03808211156200039c575f80fd5b818601915086601f830112620003b0575f80fd5b815181811115620003c557620003c562000338565b8060051b604051601f19603f83011681018181108582111715620003ed57620003ed62000338565b6040529182528482019250838101850191898311156200040b575f80fd5b938501935b82851015620004345762000424856200034c565b8452938501939285019262000410565b8096505050505050509250929050565b5f6020828403121562000455575f80fd5b5051919050565b5f602082840312156200046d575f80fd5b81516200047a8162000320565b9392505050565b634e487b7160e01b5f52603260045260245ffd5b60805160a05160c051611ef86200053c5f395f818160ae015281816108ae0152818161094401528181610be80152610c7f01525f8181610137015281816102ae0152818161074c01526109db01525f818160ed015281816101bc01528181610323015281816103a50152818161061d0152818161068e0152818161071001528181610a3e01528181610d0101528181610dc4015281816110ae01526111d50152611ef85ff3fe608060405234801561000f575f80fd5b506004361061007a575f3560e01c8063c8c52fb711610058578063c8c52fb71461011d578063d330fadd14610132578063dfa397d714610159578063edf26d9b1461017a575f80fd5b80632b9f137c1461007e578063630afce5146100a95780636c088de8146100e8575b5f80fd5b61009161008c3660046119f1565b61018d565b6040516100a093929190611a11565b60405180910390f35b6100d07f000000000000000000000000000000000000000000000000000000000000000081565b6040516001600160a01b0390911681526020016100a0565b61010f7f000000000000000000000000000000000000000000000000000000000000000081565b6040519081526020016100a0565b61013061012b366004611ae1565b6105c7565b005b6100d07f000000000000000000000000000000000000000000000000000000000000000081565b61016c6101673660046119f1565b610cfc565b6040516100a0929190611b9e565b6100d0610188366004611c42565b6116fd565b5f8060605f8061019d8787610cfc565b91509150805f036101b5575f809450945050506105c0565b8694505f5b7f0000000000000000000000000000000000000000000000000000000000000000811015610297578281815181106101f4576101f4611c59565b602002602001015160400151801561022857505f83828151811061021a5761021a611c59565b602002602001015160800151115b1561028f575f83828151811061024057610240611c59565b6020026020010151608001518385848151811061025f5761025f611c59565b602002602001015160c001516102759190611c81565b61027f9190611c98565b90508681101561028d578096505b505b6001016101ba565b506040516331f889a760e11b8152600481018690527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906363f1134e90602401602060405180830381865afa1580156102fb573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061031f9190611cb7565b93507f000000000000000000000000000000000000000000000000000000000000000067ffffffffffffffff81111561035a5761035a611a72565b60405190808252806020026020018201604052801561039e57816020015b604080518082019091525f80825260208201528152602001906001900390816103785790505b5092505f5b7f00000000000000000000000000000000000000000000000000000000000000008110156105bc578281815181106103dd576103dd611c59565b602002602001015160400151801561041157505f83828151811061040357610403611c59565b602002602001015160800151115b156105b4575f80828154811061042957610429611c59565b5f918252602091829020015460408051633a0df78d60e11b815290516001600160a01b039092169263741bef1a926004808401938290030181865afa158015610474573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906104989190611ce5565b6001600160a01b031663b5b65cef6040518163ffffffff1660e01b815260040160408051808303815f875af11580156104d3573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906104f79190611d1b565b5090508284838151811061050d5761050d611c59565b602002602001015160800151886105249190611c81565b61052e9190611c98565b85838151811061054057610540611c59565b6020908102919091010151528061055f87670de0b6b3a7640000611d45565b86848151811061057157610571611c59565b60200260200101515f01516105869190611c81565b6105909190611c98565b8583815181106105a2576105a2611c59565b60200260200101516020018181525050505b6001016103a3565b5050505b9250925092565b5f841161061b5760405162461bcd60e51b815260206004820181905260248201527f52656465656d656420616d6f756e74206d757374206265206e6f6e2d7a65726f60448201526064015b60405180910390fd5b7f000000000000000000000000000000000000000000000000000000000000000081511461068b5760405162461bcd60e51b815260206004820152601d60248201527f57726f6e67205f6d696e436f6c6c52656465656d6564206c656e6774680000006044820152606401610612565b5f7f000000000000000000000000000000000000000000000000000000000000000067ffffffffffffffff8111156106c5576106c5611a72565b60405190808252806020026020018201604052801561070957816020015b604080518082019091525f80825260208201528152602001906001900390816106e35790505b5090505f5b7f000000000000000000000000000000000000000000000000000000000000000081101561089657604051631c96a19760e31b8152600481018290527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063e4b50cb890602401602060405180830381865afa158015610799573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906107bd9190611ce5565b8282815181106107cf576107cf611c59565b60209081029190910101516001600160a01b03909116905281518290829081106107fb576107fb611c59565b6020908102919091010151516040516370a0823160e01b81523060048201526001600160a01b03909116906370a0823190602401602060405180830381865afa15801561084a573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061086e9190611cb7565b82828151811061088057610880611c59565b602090810291909101810151015260010161070e565b506040516370a0823160e01b81523060048201525f907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906370a0823190602401602060405180830381865afa1580156108fb573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061091f9190611cb7565b6040516323b872dd60e01b8152336004820152306024820152604481018890529091507f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906323b872dd906064016020604051808303815f875af1158015610992573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906109b69190611d58565b5060405163ab6d53bd60e01b81526004810187905260248101869052604481018590527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063ab6d53bd906064015f604051808303815f87803b158015610a24575f80fd5b505af1158015610a36573d5f803e3d5ffd5b505050505f5b7f0000000000000000000000000000000000000000000000000000000000000000811015610bc6575f838281518110610a7757610a77611c59565b602002602001015160200151848381518110610a9557610a95611c59565b6020908102919091010151516040516370a0823160e01b81523060048201526001600160a01b03909116906370a0823190602401602060405180830381865afa158015610ae4573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610b089190611cb7565b610b129190611d45565b9050848281518110610b2657610b26611c59565b6020026020010151811015610b7d5760405162461bcd60e51b815260206004820181905260248201527f496e73756666696369656e7420636f6c6c61746572616c2072656465656d65646044820152606401610612565b8015610bbd57610bbd3382868581518110610b9a57610b9a611c59565b60200260200101515f01516001600160a01b03166117249092919063ffffffff16565b50600101610a3c565b506040516370a0823160e01b81523060048201525f9082906001600160a01b037f000000000000000000000000000000000000000000000000000000000000000016906370a0823190602401602060405180830381865afa158015610c2d573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610c519190611cb7565b610c5b9190611d45565b90508015610cf35760405163a9059cbb60e01b8152336004820152602481018290527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063a9059cbb906044016020604051808303815f875af1158015610ccd573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610cf19190611d58565b505b50505050505050565b60605f7f000000000000000000000000000000000000000000000000000000000000000067ffffffffffffffff811115610d3857610d38611a72565b604051908082528060200260200182016040528015610dbd57816020015b610daa6040518061010001604052805f6001600160a01b031681526020015f6001600160a01b031681526020015f151581526020015f81526020015f81526020015f81526020015f81526020015f81525090565b815260200190600190039081610d565790505b5091505f5b7f000000000000000000000000000000000000000000000000000000000000000081101561108b575f8181548110610dfc57610dfc611c59565b5f918252602091829020015460408051631ec1c84560e11b815290516001600160a01b0390921692633d83908a926004808401938290030181865afa158015610e47573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610e6b9190611ce5565b838281518110610e7d57610e7d611c59565b60209081029190910101516001600160a01b0390911690525f805482908110610ea857610ea8611c59565b5f918252602091829020015460408051632ba461d560e21b815290516001600160a01b039092169263ae918754926004808401938290030181865afa158015610ef3573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610f179190611ce5565b838281518110610f2957610f29611c59565b6020026020010151602001906001600160a01b031690816001600160a01b031681525050828181518110610f5f57610f5f611c59565b60200260200101515f01516001600160a01b0316634ea15f376040518163ffffffff1660e01b81526004016060604051808303815f875af1158015610fa6573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610fca9190611d71565b858481518110610fdc57610fdc611c59565b6020026020010151608001868581518110610ff957610ff9611c59565b602002602001015160600187868151811061101657611016611c59565b60200260200101516040018315151515815250838152508381525050505082818151811061104657611046611c59565b602002602001015160400151156110835782818151811061106957611069611c59565b602002602001015160800151826110809190611da3565b91505b600101610dc2565b50805f10801561109a57508381105b156110a3578093505b805f036111cb575f5b7f00000000000000000000000000000000000000000000000000000000000000008110156111c9578281815181106110e6576110e6611c59565b60200260200101515f01516001600160a01b031663105b403b6040518163ffffffff1660e01b8152600401602060405180830381865afa15801561112c573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906111509190611cb7565b83828151811061116257611162611c59565b6020026020010151608001818152505082818151811061118457611184611c59565b602002602001015160400151156111c1578281815181106111a7576111a7611c59565b602002602001015160800151826111be9190611da3565b91505b6001016110ac565b505b80156116f6575f5b7f00000000000000000000000000000000000000000000000000000000000000008110156116f45782818151811061120d5761120d611c59565b602002602001015160400151156116ec578183828151811061123157611231611c59565b602002602001015160800151866112489190611c81565b6112529190611c98565b83828151811061126457611264611c59565b602002602001015160a001818152505082818151811061128657611286611c59565b602002602001015160a001515f03156116ec575f8382815181106112ac576112ac611c59565b60200260200101515f01516001600160a01b031663015402876040518163ffffffff1660e01b8152600401602060405180830381865afa1580156112f2573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906113169190611cb7565b90505f84838151811061132b5761132b611c59565b6020026020010151602001516001600160a01b0316634d6228316040518163ffffffff1660e01b8152600401602060405180830381865afa158015611372573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906113969190611cb7565b90505f80835f0361143157828786815181106113b4576113b4611c59565b6020026020010151602001516001600160a01b0316631037a5f4856040518263ffffffff1660e01b81526004016113ed91815260200190565b602060405180830381865afa158015611408573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061142c9190611cb7565b611434565b83835b915091505f87868151811061144b5761144b611c59565b602002602001015160e00181815250505b8787868151811061146f5761146f611c59565b602002602001015160e001511080611485575087155b156116e75786858151811061149c5761149c611c59565b602002602001015160a001518786815181106114ba576114ba611c59565b602002602001015160c0015114806114d0575081155b6116e7575f8786815181106114e7576114e7611c59565b60200260200101515f01516001600160a01b031663aad3f404846040518263ffffffff1660e01b815260040161151f91815260200190565b61014060405180830381865afa15801561153b573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061155f9190611db6565b9050670de0b6b3a7640000815f015189888151811061158057611580611c59565b602002602001015160600151836020015161159b9190611c81565b6115a59190611c98565b10611628576115fa8887815181106115bf576115bf611c59565b602002602001015160c001518988815181106115dd576115dd611c59565b602002602001015160a001516115f39190611d45565b825161177b565b88878151811061160c5761160c611c59565b602002602001015160c0018181516116249190611da3565b9052505b81925087868151811061163d5761163d611c59565b6020026020010151602001516001600160a01b0316631037a5f4836040518263ffffffff1660e01b815260040161167691815260200190565b602060405180830381865afa158015611691573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906116b59190611cb7565b9150508685815181106116ca576116ca611c59565b602002602001015160e00180516116e090611e3b565b905261145c565b505050505b6001016111d3565b505b9250929050565b5f818154811061170b575f80fd5b5f918252602090912001546001600160a01b0316905081565b604080516001600160a01b038416602482015260448082018490528251808303909101815260649091019091526020810180516001600160e01b031663a9059cbb60e01b179052611776908490611794565b505050565b5f818310611789578161178b565b825b90505b92915050565b5f6117e8826040518060400160405280602081526020017f5361666545524332303a206c6f772d6c6576656c2063616c6c206661696c6564815250856001600160a01b03166118679092919063ffffffff16565b905080515f14806118085750808060200190518101906118089190611d58565b6117765760405162461bcd60e51b815260206004820152602a60248201527f5361666545524332303a204552433230206f7065726174696f6e20646964206e6044820152691bdd081cdd58d8d9595960b21b6064820152608401610612565b606061187584845f8561187d565b949350505050565b6060824710156118de5760405162461bcd60e51b815260206004820152602660248201527f416464726573733a20696e73756666696369656e742062616c616e636520666f6044820152651c8818d85b1b60d21b6064820152608401610612565b5f80866001600160a01b031685876040516118f99190611e75565b5f6040518083038185875af1925050503d805f8114611933576040519150601f19603f3d011682016040523d82523d5f602084013e611938565b606091505b509150915061194987838387611954565b979650505050505050565b606083156119c25782515f036119bb576001600160a01b0385163b6119bb5760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e74726163740000006044820152606401610612565b5081611875565b61187583838151156119d75781518083602001fd5b8060405162461bcd60e51b81526004016106129190611e90565b5f8060408385031215611a02575f80fd5b50508035926020909101359150565b5f6060820185835260208560208501526040606060408601528286518085526080870191506020880194505f5b81811015611a6357855180518452850151858401529484019491830191600101611a3e565b50909998505050505050505050565b634e487b7160e01b5f52604160045260245ffd5b604051610140810167ffffffffffffffff81118282101715611aaa57611aaa611a72565b60405290565b604051601f8201601f1916810167ffffffffffffffff81118282101715611ad957611ad9611a72565b604052919050565b5f805f8060808587031215611af4575f80fd5b84359350602080860135935060408601359250606086013567ffffffffffffffff80821115611b21575f80fd5b818801915088601f830112611b34575f80fd5b813581811115611b4657611b46611a72565b8060051b9150611b57848301611ab0565b818152918301840191848101908b841115611b70575f80fd5b938501935b83851015611b8e57843582529385019390850190611b75565b989b979a50959850505050505050565b604080825283518282018190525f9190606090818501906020808901865b83811015611c2b57815180516001600160a01b0390811687528482015116848701528781015115158887015286810151878701526080808201519087015260a0808201519087015260c0808201519087015260e090810151908601526101009094019390820190600101611bbc565b505050508093505050508260208301529392505050565b5f60208284031215611c52575f80fd5b5035919050565b634e487b7160e01b5f52603260045260245ffd5b634e487b7160e01b5f52601160045260245ffd5b808202811582820484141761178e5761178e611c6d565b5f82611cb257634e487b7160e01b5f52601260045260245ffd5b500490565b5f60208284031215611cc7575f80fd5b5051919050565b6001600160a01b0381168114611ce2575f80fd5b50565b5f60208284031215611cf5575f80fd5b8151611d0081611cce565b9392505050565b80518015158114611d16575f80fd5b919050565b5f8060408385031215611d2c575f80fd5b82519150611d3c60208401611d07565b90509250929050565b8181038181111561178e5761178e611c6d565b5f60208284031215611d68575f80fd5b61178b82611d07565b5f805f60608486031215611d83575f80fd5b8351925060208401519150611d9a60408501611d07565b90509250925092565b8082018082111561178e5761178e611c6d565b5f6101408284031215611dc7575f80fd5b611dcf611a86565b825181526020830151602082015260408301516040820152606083015160608201526080830151608082015260a083015160a082015260c083015160c082015260e083015160e08201526101008084015181830152506101208084015181830152508091505092915050565b5f60018201611e4c57611e4c611c6d565b5060010190565b5f5b83811015611e6d578181015183820152602001611e55565b50505f910152565b5f8251611e86818460208701611e53565b9190910192915050565b602081525f8251806020840152611eae816040850160208701611e53565b601f01601f1916919091016040019291505056fea264697066735822122003eb4c9aef9f87887838b06a3da359c8b884fbbaaac8334fcaad45fd78facaca64736f6c63430008180033000000000000000000000000f949982b91c8c61e952b3ba942cbbfaef53866840000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000300000000000000000000000020f7c9ad66983f6523a0881d0f824065414175260000000000000000000000008d733f7ea7c23cbea7c613b6ebd845d46d3aac540000000000000000000000006106046f031a22713697e04c08b330ddaf3e8789", + "nonce": "0x61", + "chainId": "0x1" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "status": "0x1", + "cumulativeGasUsed": "0x1140117", + "logs": [], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "type": "0x2", + "transactionHash": "0xda46524ec386c797a2bf995ee65a1ce8c8c1f826cc8488dda7bbcaa588ef935c", + "transactionIndex": "0x9c", + "blockHash": "0x96082aa7c26acf552f8aef100b7b86d9dd039871f8091ba1970ece99ac4c300d", + "blockNumber": "0x16a58bf", + "gasUsed": "0x1cead6", + "effectiveGasPrice": "0x5047091e", + "from": "0x83cfa33a2ee969f8add9a2acdcdc0d7e556e5ed0", + "to": null, + "contractAddress": "0xb366256d033ae7e4f7bddec822a5adec9df07b80" + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1762510760071, + "chain": 1, + "commit": "a17891ec" +} \ No newline at end of file diff --git a/contracts/broadcast/DeployRedemptionHelper.s.sol/11155111/run-1761890347448.json b/contracts/broadcast/DeployRedemptionHelper.s.sol/11155111/run-1761890347448.json new file mode 100644 index 00000000..17f31972 --- /dev/null +++ b/contracts/broadcast/DeployRedemptionHelper.s.sol/11155111/run-1761890347448.json @@ -0,0 +1,49 @@ +{ + "transactions": [ + { + "hash": "0x4fa985964bb99247035251efeaa94e88697c47c410fb5d23b3db4b58b96223fd", + "transactionType": "CREATE", + "contractName": "RedemptionHelper", + "contractAddress": "0xf299f3c504904c5f0b67f0ea0caf745d8912dc45", + "function": null, + "arguments": [ + "0xFAe08e80e0598EBeCb8a3aCf0eb8f85922e7Cae1", + "[0x0dBCB1229B43b1D2FE0B63235bF64e7c8034B98e, 0x839017Ef9366c7A1b094e8CCe1bF45FCcCA5C55a, 0xb847b61a58B103dC3163091a294c8F270cD6df0a]" + ], + "transaction": { + "from": "0xb32073560ba0a715497886d4a4dd83ee9be71390", + "gas": "0x2597af", + "value": "0x0", + "input": "0x60e060405234801562000010575f80fd5b50604051620024343803806200243483398101604081905262000033916200035e565b816001600160a01b03166330504b6f6040518163ffffffff1660e01b8152600401602060405180830381865afa15801562000070573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019062000096919062000444565b815114620000eb5760405162461bcd60e51b815260206004820152601a60248201527f57726f6e67206e756d626572206f66207265676973747269657300000000000060448201526064015b60405180910390fd5b80516080526001600160a01b03821660a08190526040805163630afce560e01b8152905163630afce5916004808201926020929091908290030181865afa15801562000139573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906200015f91906200045c565b6001600160a01b031660c0525f5b815181101562000317578181815181106200018c576200018c62000481565b60200260200101516001600160a01b0316633d83908a6040518163ffffffff1660e01b8152600401602060405180830381865afa158015620001d0573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190620001f691906200045c565b604051630bc17feb60e01b8152600481018390526001600160a01b0391821691851690630bc17feb90602401602060405180830381865afa1580156200023e573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906200026491906200045c565b6001600160a01b031614620002bc5760405162461bcd60e51b815260206004820152601560248201527f54726f76654d616e61676572206d69736d6174636800000000000000000000006044820152606401620000e2565b5f828281518110620002d257620002d262000481565b6020908102919091018101518254600180820185555f9485529290932090920180546001600160a01b0319166001600160a01b0390931692909217909155016200016d565b50505062000495565b6001600160a01b038116811462000335575f80fd5b50565b634e487b7160e01b5f52604160045260245ffd5b8051620003598162000320565b919050565b5f806040838503121562000370575f80fd5b82516200037d8162000320565b602084810151919350906001600160401b03808211156200039c575f80fd5b818601915086601f830112620003b0575f80fd5b815181811115620003c557620003c562000338565b8060051b604051601f19603f83011681018181108582111715620003ed57620003ed62000338565b6040529182528482019250838101850191898311156200040b575f80fd5b938501935b82851015620004345762000424856200034c565b8452938501939285019262000410565b8096505050505050509250929050565b5f6020828403121562000455575f80fd5b5051919050565b5f602082840312156200046d575f80fd5b81516200047a8162000320565b9392505050565b634e487b7160e01b5f52603260045260245ffd5b60805160a05160c051611ef86200053c5f395f818160ae015281816108ae0152818161094401528181610be80152610c7f01525f8181610137015281816102ae0152818161074c01526109db01525f818160ed015281816101bc01528181610323015281816103a50152818161061d0152818161068e0152818161071001528181610a3e01528181610d0101528181610dc4015281816110ae01526111d50152611ef85ff3fe608060405234801561000f575f80fd5b506004361061007a575f3560e01c8063c8c52fb711610058578063c8c52fb71461011d578063d330fadd14610132578063dfa397d714610159578063edf26d9b1461017a575f80fd5b80632b9f137c1461007e578063630afce5146100a95780636c088de8146100e8575b5f80fd5b61009161008c3660046119f1565b61018d565b6040516100a093929190611a11565b60405180910390f35b6100d07f000000000000000000000000000000000000000000000000000000000000000081565b6040516001600160a01b0390911681526020016100a0565b61010f7f000000000000000000000000000000000000000000000000000000000000000081565b6040519081526020016100a0565b61013061012b366004611ae1565b6105c7565b005b6100d07f000000000000000000000000000000000000000000000000000000000000000081565b61016c6101673660046119f1565b610cfc565b6040516100a0929190611b9e565b6100d0610188366004611c42565b6116fd565b5f8060605f8061019d8787610cfc565b91509150805f036101b5575f809450945050506105c0565b8694505f5b7f0000000000000000000000000000000000000000000000000000000000000000811015610297578281815181106101f4576101f4611c59565b602002602001015160400151801561022857505f83828151811061021a5761021a611c59565b602002602001015160800151115b1561028f575f83828151811061024057610240611c59565b6020026020010151608001518385848151811061025f5761025f611c59565b602002602001015160c001516102759190611c81565b61027f9190611c98565b90508681101561028d578096505b505b6001016101ba565b506040516331f889a760e11b8152600481018690527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906363f1134e90602401602060405180830381865afa1580156102fb573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061031f9190611cb7565b93507f000000000000000000000000000000000000000000000000000000000000000067ffffffffffffffff81111561035a5761035a611a72565b60405190808252806020026020018201604052801561039e57816020015b604080518082019091525f80825260208201528152602001906001900390816103785790505b5092505f5b7f00000000000000000000000000000000000000000000000000000000000000008110156105bc578281815181106103dd576103dd611c59565b602002602001015160400151801561041157505f83828151811061040357610403611c59565b602002602001015160800151115b156105b4575f80828154811061042957610429611c59565b5f918252602091829020015460408051633a0df78d60e11b815290516001600160a01b039092169263741bef1a926004808401938290030181865afa158015610474573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906104989190611ce5565b6001600160a01b031663b5b65cef6040518163ffffffff1660e01b815260040160408051808303815f875af11580156104d3573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906104f79190611d1b565b5090508284838151811061050d5761050d611c59565b602002602001015160800151886105249190611c81565b61052e9190611c98565b85838151811061054057610540611c59565b6020908102919091010151528061055f87670de0b6b3a7640000611d45565b86848151811061057157610571611c59565b60200260200101515f01516105869190611c81565b6105909190611c98565b8583815181106105a2576105a2611c59565b60200260200101516020018181525050505b6001016103a3565b5050505b9250925092565b5f841161061b5760405162461bcd60e51b815260206004820181905260248201527f52656465656d656420616d6f756e74206d757374206265206e6f6e2d7a65726f60448201526064015b60405180910390fd5b7f000000000000000000000000000000000000000000000000000000000000000081511461068b5760405162461bcd60e51b815260206004820152601d60248201527f57726f6e67205f6d696e436f6c6c52656465656d6564206c656e6774680000006044820152606401610612565b5f7f000000000000000000000000000000000000000000000000000000000000000067ffffffffffffffff8111156106c5576106c5611a72565b60405190808252806020026020018201604052801561070957816020015b604080518082019091525f80825260208201528152602001906001900390816106e35790505b5090505f5b7f000000000000000000000000000000000000000000000000000000000000000081101561089657604051631c96a19760e31b8152600481018290527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063e4b50cb890602401602060405180830381865afa158015610799573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906107bd9190611ce5565b8282815181106107cf576107cf611c59565b60209081029190910101516001600160a01b03909116905281518290829081106107fb576107fb611c59565b6020908102919091010151516040516370a0823160e01b81523060048201526001600160a01b03909116906370a0823190602401602060405180830381865afa15801561084a573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061086e9190611cb7565b82828151811061088057610880611c59565b602090810291909101810151015260010161070e565b506040516370a0823160e01b81523060048201525f907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906370a0823190602401602060405180830381865afa1580156108fb573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061091f9190611cb7565b6040516323b872dd60e01b8152336004820152306024820152604481018890529091507f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906323b872dd906064016020604051808303815f875af1158015610992573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906109b69190611d58565b5060405163ab6d53bd60e01b81526004810187905260248101869052604481018590527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063ab6d53bd906064015f604051808303815f87803b158015610a24575f80fd5b505af1158015610a36573d5f803e3d5ffd5b505050505f5b7f0000000000000000000000000000000000000000000000000000000000000000811015610bc6575f838281518110610a7757610a77611c59565b602002602001015160200151848381518110610a9557610a95611c59565b6020908102919091010151516040516370a0823160e01b81523060048201526001600160a01b03909116906370a0823190602401602060405180830381865afa158015610ae4573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610b089190611cb7565b610b129190611d45565b9050848281518110610b2657610b26611c59565b6020026020010151811015610b7d5760405162461bcd60e51b815260206004820181905260248201527f496e73756666696369656e7420636f6c6c61746572616c2072656465656d65646044820152606401610612565b8015610bbd57610bbd3382868581518110610b9a57610b9a611c59565b60200260200101515f01516001600160a01b03166117249092919063ffffffff16565b50600101610a3c565b506040516370a0823160e01b81523060048201525f9082906001600160a01b037f000000000000000000000000000000000000000000000000000000000000000016906370a0823190602401602060405180830381865afa158015610c2d573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610c519190611cb7565b610c5b9190611d45565b90508015610cf35760405163a9059cbb60e01b8152336004820152602481018290527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063a9059cbb906044016020604051808303815f875af1158015610ccd573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610cf19190611d58565b505b50505050505050565b60605f7f000000000000000000000000000000000000000000000000000000000000000067ffffffffffffffff811115610d3857610d38611a72565b604051908082528060200260200182016040528015610dbd57816020015b610daa6040518061010001604052805f6001600160a01b031681526020015f6001600160a01b031681526020015f151581526020015f81526020015f81526020015f81526020015f81526020015f81525090565b815260200190600190039081610d565790505b5091505f5b7f000000000000000000000000000000000000000000000000000000000000000081101561108b575f8181548110610dfc57610dfc611c59565b5f918252602091829020015460408051631ec1c84560e11b815290516001600160a01b0390921692633d83908a926004808401938290030181865afa158015610e47573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610e6b9190611ce5565b838281518110610e7d57610e7d611c59565b60209081029190910101516001600160a01b0390911690525f805482908110610ea857610ea8611c59565b5f918252602091829020015460408051632ba461d560e21b815290516001600160a01b039092169263ae918754926004808401938290030181865afa158015610ef3573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610f179190611ce5565b838281518110610f2957610f29611c59565b6020026020010151602001906001600160a01b031690816001600160a01b031681525050828181518110610f5f57610f5f611c59565b60200260200101515f01516001600160a01b0316634ea15f376040518163ffffffff1660e01b81526004016060604051808303815f875af1158015610fa6573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610fca9190611d71565b858481518110610fdc57610fdc611c59565b6020026020010151608001868581518110610ff957610ff9611c59565b602002602001015160600187868151811061101657611016611c59565b60200260200101516040018315151515815250838152508381525050505082818151811061104657611046611c59565b602002602001015160400151156110835782818151811061106957611069611c59565b602002602001015160800151826110809190611da3565b91505b600101610dc2565b50805f10801561109a57508381105b156110a3578093505b805f036111cb575f5b7f00000000000000000000000000000000000000000000000000000000000000008110156111c9578281815181106110e6576110e6611c59565b60200260200101515f01516001600160a01b031663105b403b6040518163ffffffff1660e01b8152600401602060405180830381865afa15801561112c573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906111509190611cb7565b83828151811061116257611162611c59565b6020026020010151608001818152505082818151811061118457611184611c59565b602002602001015160400151156111c1578281815181106111a7576111a7611c59565b602002602001015160800151826111be9190611da3565b91505b6001016110ac565b505b80156116f6575f5b7f00000000000000000000000000000000000000000000000000000000000000008110156116f45782818151811061120d5761120d611c59565b602002602001015160400151156116ec578183828151811061123157611231611c59565b602002602001015160800151866112489190611c81565b6112529190611c98565b83828151811061126457611264611c59565b602002602001015160a001818152505082818151811061128657611286611c59565b602002602001015160a001515f03156116ec575f8382815181106112ac576112ac611c59565b60200260200101515f01516001600160a01b031663015402876040518163ffffffff1660e01b8152600401602060405180830381865afa1580156112f2573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906113169190611cb7565b90505f84838151811061132b5761132b611c59565b6020026020010151602001516001600160a01b0316634d6228316040518163ffffffff1660e01b8152600401602060405180830381865afa158015611372573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906113969190611cb7565b90505f80835f0361143157828786815181106113b4576113b4611c59565b6020026020010151602001516001600160a01b0316631037a5f4856040518263ffffffff1660e01b81526004016113ed91815260200190565b602060405180830381865afa158015611408573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061142c9190611cb7565b611434565b83835b915091505f87868151811061144b5761144b611c59565b602002602001015160e00181815250505b8787868151811061146f5761146f611c59565b602002602001015160e001511080611485575087155b156116e75786858151811061149c5761149c611c59565b602002602001015160a001518786815181106114ba576114ba611c59565b602002602001015160c0015114806114d0575081155b6116e7575f8786815181106114e7576114e7611c59565b60200260200101515f01516001600160a01b031663aad3f404846040518263ffffffff1660e01b815260040161151f91815260200190565b61014060405180830381865afa15801561153b573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061155f9190611db6565b9050670de0b6b3a7640000815f015189888151811061158057611580611c59565b602002602001015160600151836020015161159b9190611c81565b6115a59190611c98565b10611628576115fa8887815181106115bf576115bf611c59565b602002602001015160c001518988815181106115dd576115dd611c59565b602002602001015160a001516115f39190611d45565b825161177b565b88878151811061160c5761160c611c59565b602002602001015160c0018181516116249190611da3565b9052505b81925087868151811061163d5761163d611c59565b6020026020010151602001516001600160a01b0316631037a5f4836040518263ffffffff1660e01b815260040161167691815260200190565b602060405180830381865afa158015611691573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906116b59190611cb7565b9150508685815181106116ca576116ca611c59565b602002602001015160e00180516116e090611e3b565b905261145c565b505050505b6001016111d3565b505b9250929050565b5f818154811061170b575f80fd5b5f918252602090912001546001600160a01b0316905081565b604080516001600160a01b038416602482015260448082018490528251808303909101815260649091019091526020810180516001600160e01b031663a9059cbb60e01b179052611776908490611794565b505050565b5f818310611789578161178b565b825b90505b92915050565b5f6117e8826040518060400160405280602081526020017f5361666545524332303a206c6f772d6c6576656c2063616c6c206661696c6564815250856001600160a01b03166118679092919063ffffffff16565b905080515f14806118085750808060200190518101906118089190611d58565b6117765760405162461bcd60e51b815260206004820152602a60248201527f5361666545524332303a204552433230206f7065726174696f6e20646964206e6044820152691bdd081cdd58d8d9595960b21b6064820152608401610612565b606061187584845f8561187d565b949350505050565b6060824710156118de5760405162461bcd60e51b815260206004820152602660248201527f416464726573733a20696e73756666696369656e742062616c616e636520666f6044820152651c8818d85b1b60d21b6064820152608401610612565b5f80866001600160a01b031685876040516118f99190611e75565b5f6040518083038185875af1925050503d805f8114611933576040519150601f19603f3d011682016040523d82523d5f602084013e611938565b606091505b509150915061194987838387611954565b979650505050505050565b606083156119c25782515f036119bb576001600160a01b0385163b6119bb5760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e74726163740000006044820152606401610612565b5081611875565b61187583838151156119d75781518083602001fd5b8060405162461bcd60e51b81526004016106129190611e90565b5f8060408385031215611a02575f80fd5b50508035926020909101359150565b5f6060820185835260208560208501526040606060408601528286518085526080870191506020880194505f5b81811015611a6357855180518452850151858401529484019491830191600101611a3e565b50909998505050505050505050565b634e487b7160e01b5f52604160045260245ffd5b604051610140810167ffffffffffffffff81118282101715611aaa57611aaa611a72565b60405290565b604051601f8201601f1916810167ffffffffffffffff81118282101715611ad957611ad9611a72565b604052919050565b5f805f8060808587031215611af4575f80fd5b84359350602080860135935060408601359250606086013567ffffffffffffffff80821115611b21575f80fd5b818801915088601f830112611b34575f80fd5b813581811115611b4657611b46611a72565b8060051b9150611b57848301611ab0565b818152918301840191848101908b841115611b70575f80fd5b938501935b83851015611b8e57843582529385019390850190611b75565b989b979a50959850505050505050565b604080825283518282018190525f9190606090818501906020808901865b83811015611c2b57815180516001600160a01b0390811687528482015116848701528781015115158887015286810151878701526080808201519087015260a0808201519087015260c0808201519087015260e090810151908601526101009094019390820190600101611bbc565b505050508093505050508260208301529392505050565b5f60208284031215611c52575f80fd5b5035919050565b634e487b7160e01b5f52603260045260245ffd5b634e487b7160e01b5f52601160045260245ffd5b808202811582820484141761178e5761178e611c6d565b5f82611cb257634e487b7160e01b5f52601260045260245ffd5b500490565b5f60208284031215611cc7575f80fd5b5051919050565b6001600160a01b0381168114611ce2575f80fd5b50565b5f60208284031215611cf5575f80fd5b8151611d0081611cce565b9392505050565b80518015158114611d16575f80fd5b919050565b5f8060408385031215611d2c575f80fd5b82519150611d3c60208401611d07565b90509250929050565b8181038181111561178e5761178e611c6d565b5f60208284031215611d68575f80fd5b61178b82611d07565b5f805f60608486031215611d83575f80fd5b8351925060208401519150611d9a60408501611d07565b90509250925092565b8082018082111561178e5761178e611c6d565b5f6101408284031215611dc7575f80fd5b611dcf611a86565b825181526020830151602082015260408301516040820152606083015160608201526080830151608082015260a083015160a082015260c083015160c082015260e083015160e08201526101008084015181830152506101208084015181830152508091505092915050565b5f60018201611e4c57611e4c611c6d565b5060010190565b5f5b83811015611e6d578181015183820152602001611e55565b50505f910152565b5f8251611e86818460208701611e53565b9190910192915050565b602081525f8251806020840152611eae816040850160208701611e53565b601f01601f1916919091016040019291505056fea264697066735822122003eb4c9aef9f87887838b06a3da359c8b884fbbaaac8334fcaad45fd78facaca64736f6c63430008180033000000000000000000000000fae08e80e0598ebecb8a3acf0eb8f85922e7cae1000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000dbcb1229b43b1d2fe0b63235bf64e7c8034b98e000000000000000000000000839017ef9366c7a1b094e8cce1bf45fccca5c55a000000000000000000000000b847b61a58b103dc3163091a294c8f270cd6df0a", + "nonce": "0x1f2", + "chainId": "0xaa36a7" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "status": "0x1", + "cumulativeGasUsed": "0x1a1c701", + "logs": [], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "type": "0x2", + "transactionHash": "0x4fa985964bb99247035251efeaa94e88697c47c410fb5d23b3db4b58b96223fd", + "transactionIndex": "0x51", + "blockHash": "0xb15949a0e95feb80a4d0a0bc70f1225f844efc78173187d87f055479fc0ff717", + "blockNumber": "0x916309", + "gasUsed": "0x1cead6", + "effectiveGasPrice": "0xf424a", + "from": "0xb32073560ba0a715497886d4a4dd83ee9be71390", + "to": null, + "contractAddress": "0xf299f3c504904c5f0b67f0ea0caf745d8912dc45" + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1761890347448, + "chain": 11155111, + "commit": "95c3b609" +} \ No newline at end of file diff --git a/contracts/broadcast/DeployRedemptionHelper.s.sol/11155111/run-latest.json b/contracts/broadcast/DeployRedemptionHelper.s.sol/11155111/run-latest.json new file mode 100644 index 00000000..17f31972 --- /dev/null +++ b/contracts/broadcast/DeployRedemptionHelper.s.sol/11155111/run-latest.json @@ -0,0 +1,49 @@ +{ + "transactions": [ + { + "hash": "0x4fa985964bb99247035251efeaa94e88697c47c410fb5d23b3db4b58b96223fd", + "transactionType": "CREATE", + "contractName": "RedemptionHelper", + "contractAddress": "0xf299f3c504904c5f0b67f0ea0caf745d8912dc45", + "function": null, + "arguments": [ + "0xFAe08e80e0598EBeCb8a3aCf0eb8f85922e7Cae1", + "[0x0dBCB1229B43b1D2FE0B63235bF64e7c8034B98e, 0x839017Ef9366c7A1b094e8CCe1bF45FCcCA5C55a, 0xb847b61a58B103dC3163091a294c8F270cD6df0a]" + ], + "transaction": { + "from": "0xb32073560ba0a715497886d4a4dd83ee9be71390", + "gas": "0x2597af", + "value": "0x0", + "input": "0x60e060405234801562000010575f80fd5b50604051620024343803806200243483398101604081905262000033916200035e565b816001600160a01b03166330504b6f6040518163ffffffff1660e01b8152600401602060405180830381865afa15801562000070573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019062000096919062000444565b815114620000eb5760405162461bcd60e51b815260206004820152601a60248201527f57726f6e67206e756d626572206f66207265676973747269657300000000000060448201526064015b60405180910390fd5b80516080526001600160a01b03821660a08190526040805163630afce560e01b8152905163630afce5916004808201926020929091908290030181865afa15801562000139573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906200015f91906200045c565b6001600160a01b031660c0525f5b815181101562000317578181815181106200018c576200018c62000481565b60200260200101516001600160a01b0316633d83908a6040518163ffffffff1660e01b8152600401602060405180830381865afa158015620001d0573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190620001f691906200045c565b604051630bc17feb60e01b8152600481018390526001600160a01b0391821691851690630bc17feb90602401602060405180830381865afa1580156200023e573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906200026491906200045c565b6001600160a01b031614620002bc5760405162461bcd60e51b815260206004820152601560248201527f54726f76654d616e61676572206d69736d6174636800000000000000000000006044820152606401620000e2565b5f828281518110620002d257620002d262000481565b6020908102919091018101518254600180820185555f9485529290932090920180546001600160a01b0319166001600160a01b0390931692909217909155016200016d565b50505062000495565b6001600160a01b038116811462000335575f80fd5b50565b634e487b7160e01b5f52604160045260245ffd5b8051620003598162000320565b919050565b5f806040838503121562000370575f80fd5b82516200037d8162000320565b602084810151919350906001600160401b03808211156200039c575f80fd5b818601915086601f830112620003b0575f80fd5b815181811115620003c557620003c562000338565b8060051b604051601f19603f83011681018181108582111715620003ed57620003ed62000338565b6040529182528482019250838101850191898311156200040b575f80fd5b938501935b82851015620004345762000424856200034c565b8452938501939285019262000410565b8096505050505050509250929050565b5f6020828403121562000455575f80fd5b5051919050565b5f602082840312156200046d575f80fd5b81516200047a8162000320565b9392505050565b634e487b7160e01b5f52603260045260245ffd5b60805160a05160c051611ef86200053c5f395f818160ae015281816108ae0152818161094401528181610be80152610c7f01525f8181610137015281816102ae0152818161074c01526109db01525f818160ed015281816101bc01528181610323015281816103a50152818161061d0152818161068e0152818161071001528181610a3e01528181610d0101528181610dc4015281816110ae01526111d50152611ef85ff3fe608060405234801561000f575f80fd5b506004361061007a575f3560e01c8063c8c52fb711610058578063c8c52fb71461011d578063d330fadd14610132578063dfa397d714610159578063edf26d9b1461017a575f80fd5b80632b9f137c1461007e578063630afce5146100a95780636c088de8146100e8575b5f80fd5b61009161008c3660046119f1565b61018d565b6040516100a093929190611a11565b60405180910390f35b6100d07f000000000000000000000000000000000000000000000000000000000000000081565b6040516001600160a01b0390911681526020016100a0565b61010f7f000000000000000000000000000000000000000000000000000000000000000081565b6040519081526020016100a0565b61013061012b366004611ae1565b6105c7565b005b6100d07f000000000000000000000000000000000000000000000000000000000000000081565b61016c6101673660046119f1565b610cfc565b6040516100a0929190611b9e565b6100d0610188366004611c42565b6116fd565b5f8060605f8061019d8787610cfc565b91509150805f036101b5575f809450945050506105c0565b8694505f5b7f0000000000000000000000000000000000000000000000000000000000000000811015610297578281815181106101f4576101f4611c59565b602002602001015160400151801561022857505f83828151811061021a5761021a611c59565b602002602001015160800151115b1561028f575f83828151811061024057610240611c59565b6020026020010151608001518385848151811061025f5761025f611c59565b602002602001015160c001516102759190611c81565b61027f9190611c98565b90508681101561028d578096505b505b6001016101ba565b506040516331f889a760e11b8152600481018690527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906363f1134e90602401602060405180830381865afa1580156102fb573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061031f9190611cb7565b93507f000000000000000000000000000000000000000000000000000000000000000067ffffffffffffffff81111561035a5761035a611a72565b60405190808252806020026020018201604052801561039e57816020015b604080518082019091525f80825260208201528152602001906001900390816103785790505b5092505f5b7f00000000000000000000000000000000000000000000000000000000000000008110156105bc578281815181106103dd576103dd611c59565b602002602001015160400151801561041157505f83828151811061040357610403611c59565b602002602001015160800151115b156105b4575f80828154811061042957610429611c59565b5f918252602091829020015460408051633a0df78d60e11b815290516001600160a01b039092169263741bef1a926004808401938290030181865afa158015610474573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906104989190611ce5565b6001600160a01b031663b5b65cef6040518163ffffffff1660e01b815260040160408051808303815f875af11580156104d3573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906104f79190611d1b565b5090508284838151811061050d5761050d611c59565b602002602001015160800151886105249190611c81565b61052e9190611c98565b85838151811061054057610540611c59565b6020908102919091010151528061055f87670de0b6b3a7640000611d45565b86848151811061057157610571611c59565b60200260200101515f01516105869190611c81565b6105909190611c98565b8583815181106105a2576105a2611c59565b60200260200101516020018181525050505b6001016103a3565b5050505b9250925092565b5f841161061b5760405162461bcd60e51b815260206004820181905260248201527f52656465656d656420616d6f756e74206d757374206265206e6f6e2d7a65726f60448201526064015b60405180910390fd5b7f000000000000000000000000000000000000000000000000000000000000000081511461068b5760405162461bcd60e51b815260206004820152601d60248201527f57726f6e67205f6d696e436f6c6c52656465656d6564206c656e6774680000006044820152606401610612565b5f7f000000000000000000000000000000000000000000000000000000000000000067ffffffffffffffff8111156106c5576106c5611a72565b60405190808252806020026020018201604052801561070957816020015b604080518082019091525f80825260208201528152602001906001900390816106e35790505b5090505f5b7f000000000000000000000000000000000000000000000000000000000000000081101561089657604051631c96a19760e31b8152600481018290527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063e4b50cb890602401602060405180830381865afa158015610799573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906107bd9190611ce5565b8282815181106107cf576107cf611c59565b60209081029190910101516001600160a01b03909116905281518290829081106107fb576107fb611c59565b6020908102919091010151516040516370a0823160e01b81523060048201526001600160a01b03909116906370a0823190602401602060405180830381865afa15801561084a573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061086e9190611cb7565b82828151811061088057610880611c59565b602090810291909101810151015260010161070e565b506040516370a0823160e01b81523060048201525f907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906370a0823190602401602060405180830381865afa1580156108fb573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061091f9190611cb7565b6040516323b872dd60e01b8152336004820152306024820152604481018890529091507f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906323b872dd906064016020604051808303815f875af1158015610992573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906109b69190611d58565b5060405163ab6d53bd60e01b81526004810187905260248101869052604481018590527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063ab6d53bd906064015f604051808303815f87803b158015610a24575f80fd5b505af1158015610a36573d5f803e3d5ffd5b505050505f5b7f0000000000000000000000000000000000000000000000000000000000000000811015610bc6575f838281518110610a7757610a77611c59565b602002602001015160200151848381518110610a9557610a95611c59565b6020908102919091010151516040516370a0823160e01b81523060048201526001600160a01b03909116906370a0823190602401602060405180830381865afa158015610ae4573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610b089190611cb7565b610b129190611d45565b9050848281518110610b2657610b26611c59565b6020026020010151811015610b7d5760405162461bcd60e51b815260206004820181905260248201527f496e73756666696369656e7420636f6c6c61746572616c2072656465656d65646044820152606401610612565b8015610bbd57610bbd3382868581518110610b9a57610b9a611c59565b60200260200101515f01516001600160a01b03166117249092919063ffffffff16565b50600101610a3c565b506040516370a0823160e01b81523060048201525f9082906001600160a01b037f000000000000000000000000000000000000000000000000000000000000000016906370a0823190602401602060405180830381865afa158015610c2d573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610c519190611cb7565b610c5b9190611d45565b90508015610cf35760405163a9059cbb60e01b8152336004820152602481018290527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063a9059cbb906044016020604051808303815f875af1158015610ccd573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610cf19190611d58565b505b50505050505050565b60605f7f000000000000000000000000000000000000000000000000000000000000000067ffffffffffffffff811115610d3857610d38611a72565b604051908082528060200260200182016040528015610dbd57816020015b610daa6040518061010001604052805f6001600160a01b031681526020015f6001600160a01b031681526020015f151581526020015f81526020015f81526020015f81526020015f81526020015f81525090565b815260200190600190039081610d565790505b5091505f5b7f000000000000000000000000000000000000000000000000000000000000000081101561108b575f8181548110610dfc57610dfc611c59565b5f918252602091829020015460408051631ec1c84560e11b815290516001600160a01b0390921692633d83908a926004808401938290030181865afa158015610e47573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610e6b9190611ce5565b838281518110610e7d57610e7d611c59565b60209081029190910101516001600160a01b0390911690525f805482908110610ea857610ea8611c59565b5f918252602091829020015460408051632ba461d560e21b815290516001600160a01b039092169263ae918754926004808401938290030181865afa158015610ef3573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610f179190611ce5565b838281518110610f2957610f29611c59565b6020026020010151602001906001600160a01b031690816001600160a01b031681525050828181518110610f5f57610f5f611c59565b60200260200101515f01516001600160a01b0316634ea15f376040518163ffffffff1660e01b81526004016060604051808303815f875af1158015610fa6573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610fca9190611d71565b858481518110610fdc57610fdc611c59565b6020026020010151608001868581518110610ff957610ff9611c59565b602002602001015160600187868151811061101657611016611c59565b60200260200101516040018315151515815250838152508381525050505082818151811061104657611046611c59565b602002602001015160400151156110835782818151811061106957611069611c59565b602002602001015160800151826110809190611da3565b91505b600101610dc2565b50805f10801561109a57508381105b156110a3578093505b805f036111cb575f5b7f00000000000000000000000000000000000000000000000000000000000000008110156111c9578281815181106110e6576110e6611c59565b60200260200101515f01516001600160a01b031663105b403b6040518163ffffffff1660e01b8152600401602060405180830381865afa15801561112c573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906111509190611cb7565b83828151811061116257611162611c59565b6020026020010151608001818152505082818151811061118457611184611c59565b602002602001015160400151156111c1578281815181106111a7576111a7611c59565b602002602001015160800151826111be9190611da3565b91505b6001016110ac565b505b80156116f6575f5b7f00000000000000000000000000000000000000000000000000000000000000008110156116f45782818151811061120d5761120d611c59565b602002602001015160400151156116ec578183828151811061123157611231611c59565b602002602001015160800151866112489190611c81565b6112529190611c98565b83828151811061126457611264611c59565b602002602001015160a001818152505082818151811061128657611286611c59565b602002602001015160a001515f03156116ec575f8382815181106112ac576112ac611c59565b60200260200101515f01516001600160a01b031663015402876040518163ffffffff1660e01b8152600401602060405180830381865afa1580156112f2573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906113169190611cb7565b90505f84838151811061132b5761132b611c59565b6020026020010151602001516001600160a01b0316634d6228316040518163ffffffff1660e01b8152600401602060405180830381865afa158015611372573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906113969190611cb7565b90505f80835f0361143157828786815181106113b4576113b4611c59565b6020026020010151602001516001600160a01b0316631037a5f4856040518263ffffffff1660e01b81526004016113ed91815260200190565b602060405180830381865afa158015611408573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061142c9190611cb7565b611434565b83835b915091505f87868151811061144b5761144b611c59565b602002602001015160e00181815250505b8787868151811061146f5761146f611c59565b602002602001015160e001511080611485575087155b156116e75786858151811061149c5761149c611c59565b602002602001015160a001518786815181106114ba576114ba611c59565b602002602001015160c0015114806114d0575081155b6116e7575f8786815181106114e7576114e7611c59565b60200260200101515f01516001600160a01b031663aad3f404846040518263ffffffff1660e01b815260040161151f91815260200190565b61014060405180830381865afa15801561153b573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061155f9190611db6565b9050670de0b6b3a7640000815f015189888151811061158057611580611c59565b602002602001015160600151836020015161159b9190611c81565b6115a59190611c98565b10611628576115fa8887815181106115bf576115bf611c59565b602002602001015160c001518988815181106115dd576115dd611c59565b602002602001015160a001516115f39190611d45565b825161177b565b88878151811061160c5761160c611c59565b602002602001015160c0018181516116249190611da3565b9052505b81925087868151811061163d5761163d611c59565b6020026020010151602001516001600160a01b0316631037a5f4836040518263ffffffff1660e01b815260040161167691815260200190565b602060405180830381865afa158015611691573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906116b59190611cb7565b9150508685815181106116ca576116ca611c59565b602002602001015160e00180516116e090611e3b565b905261145c565b505050505b6001016111d3565b505b9250929050565b5f818154811061170b575f80fd5b5f918252602090912001546001600160a01b0316905081565b604080516001600160a01b038416602482015260448082018490528251808303909101815260649091019091526020810180516001600160e01b031663a9059cbb60e01b179052611776908490611794565b505050565b5f818310611789578161178b565b825b90505b92915050565b5f6117e8826040518060400160405280602081526020017f5361666545524332303a206c6f772d6c6576656c2063616c6c206661696c6564815250856001600160a01b03166118679092919063ffffffff16565b905080515f14806118085750808060200190518101906118089190611d58565b6117765760405162461bcd60e51b815260206004820152602a60248201527f5361666545524332303a204552433230206f7065726174696f6e20646964206e6044820152691bdd081cdd58d8d9595960b21b6064820152608401610612565b606061187584845f8561187d565b949350505050565b6060824710156118de5760405162461bcd60e51b815260206004820152602660248201527f416464726573733a20696e73756666696369656e742062616c616e636520666f6044820152651c8818d85b1b60d21b6064820152608401610612565b5f80866001600160a01b031685876040516118f99190611e75565b5f6040518083038185875af1925050503d805f8114611933576040519150601f19603f3d011682016040523d82523d5f602084013e611938565b606091505b509150915061194987838387611954565b979650505050505050565b606083156119c25782515f036119bb576001600160a01b0385163b6119bb5760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e74726163740000006044820152606401610612565b5081611875565b61187583838151156119d75781518083602001fd5b8060405162461bcd60e51b81526004016106129190611e90565b5f8060408385031215611a02575f80fd5b50508035926020909101359150565b5f6060820185835260208560208501526040606060408601528286518085526080870191506020880194505f5b81811015611a6357855180518452850151858401529484019491830191600101611a3e565b50909998505050505050505050565b634e487b7160e01b5f52604160045260245ffd5b604051610140810167ffffffffffffffff81118282101715611aaa57611aaa611a72565b60405290565b604051601f8201601f1916810167ffffffffffffffff81118282101715611ad957611ad9611a72565b604052919050565b5f805f8060808587031215611af4575f80fd5b84359350602080860135935060408601359250606086013567ffffffffffffffff80821115611b21575f80fd5b818801915088601f830112611b34575f80fd5b813581811115611b4657611b46611a72565b8060051b9150611b57848301611ab0565b818152918301840191848101908b841115611b70575f80fd5b938501935b83851015611b8e57843582529385019390850190611b75565b989b979a50959850505050505050565b604080825283518282018190525f9190606090818501906020808901865b83811015611c2b57815180516001600160a01b0390811687528482015116848701528781015115158887015286810151878701526080808201519087015260a0808201519087015260c0808201519087015260e090810151908601526101009094019390820190600101611bbc565b505050508093505050508260208301529392505050565b5f60208284031215611c52575f80fd5b5035919050565b634e487b7160e01b5f52603260045260245ffd5b634e487b7160e01b5f52601160045260245ffd5b808202811582820484141761178e5761178e611c6d565b5f82611cb257634e487b7160e01b5f52601260045260245ffd5b500490565b5f60208284031215611cc7575f80fd5b5051919050565b6001600160a01b0381168114611ce2575f80fd5b50565b5f60208284031215611cf5575f80fd5b8151611d0081611cce565b9392505050565b80518015158114611d16575f80fd5b919050565b5f8060408385031215611d2c575f80fd5b82519150611d3c60208401611d07565b90509250929050565b8181038181111561178e5761178e611c6d565b5f60208284031215611d68575f80fd5b61178b82611d07565b5f805f60608486031215611d83575f80fd5b8351925060208401519150611d9a60408501611d07565b90509250925092565b8082018082111561178e5761178e611c6d565b5f6101408284031215611dc7575f80fd5b611dcf611a86565b825181526020830151602082015260408301516040820152606083015160608201526080830151608082015260a083015160a082015260c083015160c082015260e083015160e08201526101008084015181830152506101208084015181830152508091505092915050565b5f60018201611e4c57611e4c611c6d565b5060010190565b5f5b83811015611e6d578181015183820152602001611e55565b50505f910152565b5f8251611e86818460208701611e53565b9190910192915050565b602081525f8251806020840152611eae816040850160208701611e53565b601f01601f1916919091016040019291505056fea264697066735822122003eb4c9aef9f87887838b06a3da359c8b884fbbaaac8334fcaad45fd78facaca64736f6c63430008180033000000000000000000000000fae08e80e0598ebecb8a3acf0eb8f85922e7cae1000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000dbcb1229b43b1d2fe0b63235bf64e7c8034b98e000000000000000000000000839017ef9366c7a1b094e8cce1bf45fccca5c55a000000000000000000000000b847b61a58b103dc3163091a294c8f270cd6df0a", + "nonce": "0x1f2", + "chainId": "0xaa36a7" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "status": "0x1", + "cumulativeGasUsed": "0x1a1c701", + "logs": [], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "type": "0x2", + "transactionHash": "0x4fa985964bb99247035251efeaa94e88697c47c410fb5d23b3db4b58b96223fd", + "transactionIndex": "0x51", + "blockHash": "0xb15949a0e95feb80a4d0a0bc70f1225f844efc78173187d87f055479fc0ff717", + "blockNumber": "0x916309", + "gasUsed": "0x1cead6", + "effectiveGasPrice": "0xf424a", + "from": "0xb32073560ba0a715497886d4a4dd83ee9be71390", + "to": null, + "contractAddress": "0xf299f3c504904c5f0b67f0ea0caf745d8912dc45" + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1761890347448, + "chain": 11155111, + "commit": "95c3b609" +} \ No newline at end of file diff --git a/contracts/lib/V2-gov b/contracts/lib/V2-gov index e7ed5341..b880481d 160000 --- a/contracts/lib/V2-gov +++ b/contracts/lib/V2-gov @@ -1 +1 @@ -Subproject commit e7ed5341f2f54fb9bf89497a7be294c61f21ebe3 +Subproject commit b880481d701d2b07236f2e04842c79b4fe77e5fd diff --git a/contracts/script/DeployLiquity2.s.sol b/contracts/script/DeployLiquity2.s.sol index 9af93a54..eeabbedc 100644 --- a/contracts/script/DeployLiquity2.s.sol +++ b/contracts/script/DeployLiquity2.s.sol @@ -23,6 +23,7 @@ import "src/GasPool.sol"; import "src/HintHelpers.sol"; import "src/MultiTroveGetter.sol"; import {DebtInFrontHelper, IDebtInFrontHelper} from "src/DebtInFrontHelper.sol"; +import {RedemptionHelper, IRedemptionHelper} from "src/RedemptionHelper.sol"; import "src/SortedTroves.sol"; import "src/StabilityPool.sol"; import "src/PriceFeeds/WETHPriceFeed.sol"; @@ -38,6 +39,7 @@ import "src/Zappers/GasCompZapper.sol"; import "src/Zappers/LeverageLSTZapper.sol"; import "src/Zappers/LeverageWETHZapper.sol"; import "src/Zappers/Modules/Exchanges/HybridCurveUniV3ExchangeHelpers.sol"; +import "src/Zappers/Modules/Exchanges/HybridCurveUniV3ExchangeHelpersV2.sol"; import {BalancerFlashLoan} from "src/Zappers/Modules/FlashLoans/BalancerFlashLoan.sol"; import "src/Zappers/Modules/Exchanges/Curve/ICurveStableswapNGFactory.sol"; import "src/Zappers/Modules/Exchanges/UniswapV3/ISwapRouter.sol"; @@ -225,6 +227,8 @@ contract DeployLiquity2Script is DeployGovernance, UniPriceConverter, StdCheats, MultiTroveGetter multiTroveGetter; IDebtInFrontHelper debtInFrontHelper; IExchangeHelpers exchangeHelpers; + IExchangeHelpersV2 exchangeHelpersV2; + IRedemptionHelper redemptionHelper; } function run() external { @@ -642,6 +646,19 @@ contract DeployLiquity2Script is DeployGovernance, UniPriceConverter, StdCheats, UNIV3_FEE_WETH_COLL, uniV3Quoter ); + + r.exchangeHelpersV2 = new HybridCurveUniV3ExchangeHelpersV2({ + _usdc: address(USDC), + _weth: address(WETH), + _curvePool: r.usdcCurvePool, + _usdcIndex: int128(OTHER_TOKEN_INDEX), + _boldIndex: int128(BOLD_TOKEN_INDEX), + _feeUsdcWeth: UNIV3_FEE_USDC_WETH, + _feeWethColl: UNIV3_FEE_WETH_COLL, + _uniV3Quoter: uniV3Quoter + }); + + r.redemptionHelper = new RedemptionHelper(r.collateralRegistry, vars.addressesRegistries); } function _deployAddressesRegistry(TroveManagerParams memory _troveManagerParams) @@ -1164,6 +1181,10 @@ contract DeployLiquity2Script is DeployGovernance, UniPriceConverter, StdCheats, string.concat('"multiTroveGetter":"', address(deployed.multiTroveGetter).toHexString(), '",'), string.concat('"debtInFrontHelper":"', address(deployed.debtInFrontHelper).toHexString(), '",'), string.concat('"exchangeHelpers":"', address(deployed.exchangeHelpers).toHexString(), '",'), + string.concat('"exchangeHelpersV2":"', address(deployed.exchangeHelpersV2).toHexString(), '",') + ), + string.concat( + string.concat('"redemptionHelper":"', address(deployed.redemptionHelper).toHexString(), '",'), string.concat('"branches":[', branches.join(","), "],"), string.concat('"governance":', _governanceManifest, "") // no comma ), diff --git a/contracts/script/DeployOnlyExchangeHelpersV2.s.sol b/contracts/script/DeployOnlyExchangeHelpersV2.s.sol new file mode 100644 index 00000000..661304f5 --- /dev/null +++ b/contracts/script/DeployOnlyExchangeHelpersV2.s.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.24; + +import {console} from "forge-std/console.sol"; +import {Script} from "forge-std/Script.sol"; +import {Strings} from "openzeppelin-contracts/contracts/utils/Strings.sol"; +import {ICurveStableswapNGPool} from "../src/Zappers/Modules/Exchanges/Curve/ICurveStableswapNGPool.sol"; +import {IQuoterV2} from "../src/Zappers/Modules/Exchanges/UniswapV3/IQuoterV2.sol"; +import {HybridCurveUniV3ExchangeHelpersV2} from "../src/Zappers/Modules/Exchanges/HybridCurveUniV3ExchangeHelpersV2.sol"; +import {UseDeployment} from "../test/Utils/UseDeployment.sol"; + +uint24 constant UNIV3_FEE_USDC_WETH = 500; // 0.05% +uint24 constant UNIV3_FEE_WETH_COLL = 100; // 0.01% + +IQuoterV2 constant uniV3QuoterMainnet = IQuoterV2(0x61fFE014bA17989E743c5F6cB21bF9697530B21e); +IQuoterV2 constant uniV3QuoterSepolia = IQuoterV2(0xEd1f6473345F45b75F8179591dd5bA1888cf2FB3); + +contract DeployOnlyExchangeHelpersV2 is Script, UseDeployment { + using Strings for *; + + function run() external { + if (block.chainid != 1 && block.chainid != 11155111) { + revert("Unsupported chain"); + } + + _loadDeploymentFromManifest(string.concat("addresses/", block.chainid.toString(), ".json")); + + vm.startBroadcast(); + HybridCurveUniV3ExchangeHelpersV2 exchangeHelpersV2 = new HybridCurveUniV3ExchangeHelpersV2({ + _usdc: USDC, + _weth: WETH, + _curvePool: ICurveStableswapNGPool(address(curveUsdcBold)), + _usdcIndex: int8(curveUsdcBold.coins(0) == USDC ? 0 : 1), + _boldIndex: int8(curveUsdcBold.coins(0) == BOLD ? 0 : 1), + _feeUsdcWeth: UNIV3_FEE_USDC_WETH, + _feeWethColl: UNIV3_FEE_WETH_COLL, + _uniV3Quoter: block.chainid == 1 ? uniV3QuoterMainnet : uniV3QuoterSepolia + }); + + console.log("exchangeHelpersV2:", address(exchangeHelpersV2)); + } +} diff --git a/contracts/script/DeployRedemptionHelper.s.sol b/contracts/script/DeployRedemptionHelper.s.sol new file mode 100644 index 00000000..b5bcfa2a --- /dev/null +++ b/contracts/script/DeployRedemptionHelper.s.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.24; + +import {console} from "forge-std/console.sol"; +import {Script} from "forge-std/Script.sol"; +import {Strings} from "openzeppelin-contracts/contracts/utils/Strings.sol"; +import {IAddressesRegistry} from "../src/Interfaces/IAddressesRegistry.sol"; +import {RedemptionHelper} from "../src/RedemptionHelper.sol"; +import {UseDeployment} from "../test/Utils/UseDeployment.sol"; + +contract DeployRedemptionHelper is Script, UseDeployment { + using Strings for *; + + function run() external { + if (block.chainid != 1 && block.chainid != 11155111) { + revert("Unsupported chain"); + } + + _loadDeploymentFromManifest(string.concat("addresses/", block.chainid.toString(), ".json")); + + IAddressesRegistry[] memory addresses = new IAddressesRegistry[](branches.length); + for (uint256 i = 0; i < branches.length; ++i) { + addresses[i] = branches[i].addressesRegistry; + } + + vm.startBroadcast(); + RedemptionHelper redemptionHelper = + new RedemptionHelper({_collateralRegistry: collateralRegistry, _addresses: addresses}); + + console.log("redemptionHelper:", address(redemptionHelper)); + } +} diff --git a/contracts/src/HintHelpers.sol b/contracts/src/HintHelpers.sol index 6cd6c2b9..2deaa4ff 100644 --- a/contracts/src/HintHelpers.sol +++ b/contracts/src/HintHelpers.sol @@ -22,7 +22,7 @@ contract HintHelpers is IHintHelpers { Note: The output id is worst-case O(n) positions away from the correct insert position, however, the function is probabilistic. Input can be tuned to guarantee results to a high degree of confidence, e.g: - Submitting numTrials = k * sqrt(length), with k = 15 makes it very, very likely that the ouput id will + Submitting numTrials = k * sqrt(length), with k = 15 makes it very, very likely that the output id will be <= sqrt(length) positions away from the correct insert position. */ function getApproxHint(uint256 _collIndex, uint256 _interestRate, uint256 _numTrials, uint256 _inputRandomSeed) diff --git a/contracts/src/Interfaces/IRedemptionHelper.sol b/contracts/src/Interfaces/IRedemptionHelper.sol new file mode 100644 index 00000000..c5d70131 --- /dev/null +++ b/contracts/src/Interfaces/IRedemptionHelper.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface IRedemptionHelper { + struct SimulationContext { + address troveManager; + address sortedTroves; + bool redeemable; + uint256 price; + uint256 proportion; + uint256 attemptedBold; + uint256 redeemedBold; + uint256 iterations; + } + + struct Redeemed { + uint256 bold; + uint256 coll; + } + + function simulateRedemption(uint256 _bold, uint256 _maxIterationsPerCollateral) + external + returns (SimulationContext[] memory branch, uint256 totalProportions); + + // Find the maximal amount of BOLD that can be redeemed proportionally within + // a given iteration limit. This helps prevent the redeemer from overpaying on + // the redemption fee. + // + // Also returns the expected fee that will be paid (as a percentage), and the + // expected collateral amounts that will be paid out in exchange for the + // redeemed BOLD. The latter may be used to calculate the _minCollRedeemed + // parameter passed to redeemCollateral(). + function truncateRedemption(uint256 _bold, uint256 _maxIterationsPerCollateral) + external + returns (uint256 truncatedBold, uint256 feePct, Redeemed[] memory redeemed); + + // Wrapper around CollateralRegistry's redeemCollateral() that adds slippage + // protection in the form of a minimum acceptable collateral amounts parameter. + function redeemCollateral( + uint256 _bold, + uint256 _maxIterationsPerCollateral, + uint256 _maxFeePct, + uint256[] memory _minCollRedeemed + ) external; +} diff --git a/contracts/src/PriceFeeds/MainnetPriceFeedBase.sol b/contracts/src/PriceFeeds/MainnetPriceFeedBase.sol index f2f8f241..429c9e9f 100644 --- a/contracts/src/PriceFeeds/MainnetPriceFeedBase.sol +++ b/contracts/src/PriceFeeds/MainnetPriceFeedBase.sol @@ -10,7 +10,7 @@ import "../BorrowerOperations.sol"; abstract contract MainnetPriceFeedBase is IMainnetPriceFeed { // Determines where the PriceFeed sources data from. Possible states: - // - primary: Uses the primary price calcuation, which depends on the specific feed + // - primary: Uses the primary price calculation, which depends on the specific feed // - ETHUSDxCanonical: Uses Chainlink's ETH-USD multiplied by the LST' canonical rate // - lastGoodPrice: the last good price recorded by this PriceFeed. PriceSource public priceSource; diff --git a/contracts/src/RedemptionHelper.sol b/contracts/src/RedemptionHelper.sol new file mode 100644 index 00000000..e48811a0 --- /dev/null +++ b/contracts/src/RedemptionHelper.sol @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.24; + +import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Math} from "openzeppelin-contracts/contracts/utils/math/Math.sol"; + +import {_100pct, DECIMAL_PRECISION} from "./Dependencies/Constants.sol"; +import {IAddressesRegistry} from "./Interfaces/IAddressesRegistry.sol"; +import {IBoldToken} from "./Interfaces/IBoldToken.sol"; +import {ICollateralRegistry} from "./Interfaces/ICollateralRegistry.sol"; +import {IRedemptionHelper} from "./Interfaces/IRedemptionHelper.sol"; +import {ISortedTroves} from "./Interfaces/ISortedTroves.sol"; +import {ITroveManager} from "./Interfaces/ITroveManager.sol"; +import {LatestTroveData} from "./Types/LatestTroveData.sol"; + +contract RedemptionHelper is IRedemptionHelper { + using SafeERC20 for IERC20; + + struct RedemptionContext { + IERC20 collToken; + uint256 collBalanceBefore; + } + + uint256 public immutable numBranches; + ICollateralRegistry public immutable collateralRegistry; + IBoldToken public immutable boldToken; + IAddressesRegistry[] public addresses; // only used off-chain, so we don't care about storage cost + + constructor(ICollateralRegistry _collateralRegistry, IAddressesRegistry[] memory _addresses) { + require(_addresses.length == _collateralRegistry.totalCollaterals(), "Wrong number of registries"); + numBranches = _addresses.length; + collateralRegistry = _collateralRegistry; + boldToken = _collateralRegistry.boldToken(); + + for (uint256 i = 0; i < _addresses.length; ++i) { + require(_collateralRegistry.getTroveManager(i) == _addresses[i].troveManager(), "TroveManager mismatch"); + addresses.push(_addresses[i]); + } + } + + // Meant to be called off-chain + // Not a view because price fetching has side-effects + function simulateRedemption(uint256 _bold, uint256 _maxIterationsPerCollateral) + public + returns (SimulationContext[] memory branch, uint256 totalProportions) + { + branch = new SimulationContext[](numBranches); + + // First priority: proportional to unbacked debt + for (uint256 i = 0; i < numBranches; ++i) { + branch[i].troveManager = address(addresses[i].troveManager()); + branch[i].sortedTroves = address(addresses[i].sortedTroves()); + (branch[i].proportion, branch[i].price, branch[i].redeemable) = + ITroveManager(branch[i].troveManager).getUnbackedPortionPriceAndRedeemability(); + if (branch[i].redeemable) totalProportions += branch[i].proportion; + } + + // CS-BOLD-013: truncate redemption if it would exceed total unbacked debt + if (0 < totalProportions && totalProportions < _bold) _bold = totalProportions; + + // Fallback: proportional to total debt + if (totalProportions == 0) { + for (uint256 i = 0; i < numBranches; ++i) { + branch[i].proportion = ITroveManager(branch[i].troveManager).getEntireBranchDebt(); + if (branch[i].redeemable) totalProportions += branch[i].proportion; + } + } + + if (totalProportions == 0) return (branch, totalProportions); + + for (uint256 i = 0; i < numBranches; ++i) { + if (!branch[i].redeemable) continue; + + branch[i].attemptedBold = _bold * branch[i].proportion / totalProportions; + if (branch[i].attemptedBold == 0) continue; + + uint256 lastZombieTroveId = ITroveManager(branch[i].troveManager).lastZombieTroveId(); + uint256 lastTroveId = ISortedTroves(branch[i].sortedTroves).getLast(); + + (uint256 troveId, uint256 nextTroveId) = lastZombieTroveId != 0 + ? (lastZombieTroveId, lastTroveId) + : (lastTroveId, ISortedTroves(branch[i].sortedTroves).getPrev(lastTroveId)); + + for ( + branch[i].iterations = 0; + branch[i].iterations < _maxIterationsPerCollateral || _maxIterationsPerCollateral == 0; + ++branch[i].iterations + ) { + if (branch[i].redeemedBold == branch[i].attemptedBold || troveId == 0) break; + + LatestTroveData memory trove = ITroveManager(branch[i].troveManager).getLatestTroveData(troveId); + if (trove.entireColl * branch[i].price / trove.entireDebt >= _100pct) { + branch[i].redeemedBold += + Math.min(branch[i].attemptedBold - branch[i].redeemedBold, trove.entireDebt); + } + + troveId = nextTroveId; + nextTroveId = ISortedTroves(branch[i].sortedTroves).getPrev(nextTroveId); + } + } + } + + // Meant to be called off-chain + // Not a view because price fetching has side-effects + function truncateRedemption(uint256 _bold, uint256 _maxIterationsPerCollateral) + external + returns (uint256 truncatedBold, uint256 feePct, Redeemed[] memory redeemed) + { + (SimulationContext[] memory branch, uint256 totalProportions) = + simulateRedemption(_bold, _maxIterationsPerCollateral); + + if (totalProportions == 0) return (0, 0, redeemed); + + truncatedBold = _bold; + for (uint256 i = 0; i < numBranches; ++i) { + if (branch[i].redeemable && branch[i].proportion > 0) { + // Extrapolate how much the entire redeemed BOLD would + // have been if this branch was redeemed proportionally. + uint256 extrapolatedBold = branch[i].redeemedBold * totalProportions / branch[i].proportion; + + // Normally this is no different from `_bold`, but can be less if the redemption on this branch + // terminated due to hitting the iteration limit. We're looking for the smallest extrapolated value, + // since that is the maximum amount of BOLD that can be redeemed proportionally within the given + // iteration limit. Any attempt to redeem more than this would result in a partial redemption, thus + // paying a higher redemption fee than necessary — since the fee is based on the attempted amount. + if (extrapolatedBold < truncatedBold) truncatedBold = extrapolatedBold; + } + } + + feePct = collateralRegistry.getRedemptionRateForRedeemedAmount(truncatedBold); + redeemed = new Redeemed[](numBranches); + + for (uint256 i = 0; i < numBranches; ++i) { + if (branch[i].redeemable && branch[i].proportion > 0) { + (uint256 redemptionPrice,) = addresses[i].priceFeed().fetchRedemptionPrice(); + redeemed[i].bold = truncatedBold * branch[i].proportion / totalProportions; + redeemed[i].coll = redeemed[i].bold * (DECIMAL_PRECISION - feePct) / redemptionPrice; + } + } + } + + function redeemCollateral( + uint256 _bold, + uint256 _maxIterationsPerCollateral, + uint256 _maxFeePct, + uint256[] memory _minCollRedeemed + ) external { + require(_bold > 0, "Redeemed amount must be non-zero"); + require(_minCollRedeemed.length == numBranches, "Wrong _minCollRedeemed length"); + + RedemptionContext[] memory branch = new RedemptionContext[](numBranches); + + for (uint256 i = 0; i < numBranches; ++i) { + branch[i].collToken = collateralRegistry.getToken(i); + branch[i].collBalanceBefore = branch[i].collToken.balanceOf(address(this)); + } + + uint256 boldBalanceBefore = boldToken.balanceOf(address(this)); + + boldToken.transferFrom(msg.sender, address(this), _bold); + collateralRegistry.redeemCollateral(_bold, _maxIterationsPerCollateral, _maxFeePct); + + for (uint256 i = 0; i < numBranches; ++i) { + uint256 collRedeemed = branch[i].collToken.balanceOf(address(this)) - branch[i].collBalanceBefore; + require(collRedeemed >= _minCollRedeemed[i], "Insufficient collateral redeemed"); + if (collRedeemed > 0) branch[i].collToken.safeTransfer(msg.sender, collRedeemed); + } + + uint256 boldRemaining = boldToken.balanceOf(address(this)) - boldBalanceBefore; + if (boldRemaining > 0) boldToken.transfer(msg.sender, boldRemaining); + } +} diff --git a/contracts/src/StabilityPool.sol b/contracts/src/StabilityPool.sol index 8dd1668c..db0a24a5 100644 --- a/contracts/src/StabilityPool.sol +++ b/contracts/src/StabilityPool.sol @@ -398,7 +398,7 @@ contract StabilityPool is LiquityBase, IStabilityPool, IStabilityPoolEvents { // In other words, there needs to be octillions of BOLD in the SP, which is unlikely to happen in practice. require(newP > 0, "P must never decrease to 0"); - // Overflow analyisis of scaling up P: + // Overflow analysis of scaling up P: // We know that the resulting P is <= 1e36, and it's the result of dividing numerator by totalBoldDeposits. // Thus, numerator <= 1e36 * totalBoldDeposits, so unless totalBoldDeposits is septillions of BOLD, it won’t overflow. // That holds on every iteration as an upper bound. We multiply numerator by SCALE_FACTOR, diff --git a/contracts/src/Zappers/Interfaces/IExchangeHelpersV2.sol b/contracts/src/Zappers/Interfaces/IExchangeHelpersV2.sol new file mode 100644 index 00000000..aedb2277 --- /dev/null +++ b/contracts/src/Zappers/Interfaces/IExchangeHelpersV2.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +interface IExchangeHelpersV2 { + function quoteExactInput(uint256 _inputAmount, bool _collToBold, address _collToken) + external + returns (uint256 outputAmount); + + function quoteExactOutput(uint256 _outputAmount, bool _collToBold, address _collToken) + external + returns (uint256 inputAmount); +} diff --git a/contracts/src/Zappers/Modules/Exchanges/HybridCurveUniV3ExchangeHelpersV2.sol b/contracts/src/Zappers/Modules/Exchanges/HybridCurveUniV3ExchangeHelpersV2.sol new file mode 100644 index 00000000..9b69d8ec --- /dev/null +++ b/contracts/src/Zappers/Modules/Exchanges/HybridCurveUniV3ExchangeHelpersV2.sol @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.18; + +import {ICurveStableswapNGPool} from "./Curve/ICurveStableswapNGPool.sol"; +import {IQuoterV2} from "./UniswapV3/IQuoterV2.sol"; +import {IExchangeHelpersV2} from "../../Interfaces/IExchangeHelpersV2.sol"; + +contract HybridCurveUniV3ExchangeHelpersV2 is IExchangeHelpersV2 { + address public immutable USDC; + address public immutable WETH; + + // Curve + ICurveStableswapNGPool public immutable curvePool; + int128 public immutable USDC_INDEX; + int128 public immutable BOLD_TOKEN_INDEX; + + // Uniswap + uint24 public immutable feeUsdcWeth; + uint24 public immutable feeWethColl; + IQuoterV2 public immutable uniV3Quoter; + + constructor( + address _usdc, + address _weth, + // Curve + ICurveStableswapNGPool _curvePool, + int128 _usdcIndex, + int128 _boldIndex, + // UniV3 + uint24 _feeUsdcWeth, + uint24 _feeWethColl, + IQuoterV2 _uniV3Quoter + ) { + USDC = _usdc; + WETH = _weth; + + // Curve + curvePool = _curvePool; + USDC_INDEX = _usdcIndex; + BOLD_TOKEN_INDEX = _boldIndex; + + // Uniswap + feeUsdcWeth = _feeUsdcWeth; + feeWethColl = _feeWethColl; + uniV3Quoter = _uniV3Quoter; + } + + function quoteExactInput(uint256 _inputAmount, bool _collToBold, address _collToken) + external + returns (uint256 outputAmount) + { + if (_collToBold) { + // (Coll ->) WETH -> USDC? + bytes memory path; + if (WETH == _collToken) { + path = abi.encodePacked(WETH, feeUsdcWeth, USDC); + } else { + path = abi.encodePacked(_collToken, feeWethColl, WETH, feeUsdcWeth, USDC); + } + + (uint256 intermediateAmount,,,) = uniV3Quoter.quoteExactInput(path, _inputAmount); + + // USDC -> BOLD? + outputAmount = curvePool.get_dy(USDC_INDEX, BOLD_TOKEN_INDEX, intermediateAmount); + } else { + // BOLD -> USDC? + uint256 intermediateAmount = curvePool.get_dy(BOLD_TOKEN_INDEX, USDC_INDEX, _inputAmount); + + // USDC -> WETH (-> Coll)? + bytes memory path; + if (WETH == _collToken) { + path = abi.encodePacked(USDC, feeUsdcWeth, WETH); + } else { + path = abi.encodePacked(USDC, feeUsdcWeth, WETH, feeWethColl, _collToken); + } + + (outputAmount,,,) = uniV3Quoter.quoteExactInput(path, intermediateAmount); + } + } + + function quoteExactOutput(uint256 _outputAmount, bool _collToBold, address _collToken) + external + returns (uint256 inputAmount) + { + if (_collToBold) { + // USDC? -> BOLD + uint256 intermediateAmount = curvePool.get_dx(USDC_INDEX, BOLD_TOKEN_INDEX, _outputAmount); + + // Uniswap expects path to be reversed when quoting exact output + // USDC <- WETH (<- Coll)? + bytes memory path; + if (WETH == _collToken) { + path = abi.encodePacked(USDC, feeUsdcWeth, WETH); + } else { + path = abi.encodePacked(USDC, feeUsdcWeth, WETH, feeWethColl, _collToken); + } + + (inputAmount,,,) = uniV3Quoter.quoteExactOutput(path, intermediateAmount); + } else { + // Uniswap expects path to be reversed when quoting exact output + // (Coll <-) WETH <- USDC? + bytes memory path; + if (WETH == _collToken) { + path = abi.encodePacked(WETH, feeUsdcWeth, USDC); + } else { + path = abi.encodePacked(_collToken, feeWethColl, WETH, feeUsdcWeth, USDC); + } + + (uint256 intermediateAmount,,,) = uniV3Quoter.quoteExactOutput(path, _outputAmount); + + // BOLD? -> USDC + inputAmount = curvePool.get_dx(BOLD_TOKEN_INDEX, USDC_INDEX, intermediateAmount); + } + } +} diff --git a/contracts/src/Zappers/Modules/Exchanges/UniswapV3/INonfungiblePositionManager.sol b/contracts/src/Zappers/Modules/Exchanges/UniswapV3/INonfungiblePositionManager.sol index 251c8802..0739f801 100644 --- a/contracts/src/Zappers/Modules/Exchanges/UniswapV3/INonfungiblePositionManager.sol +++ b/contracts/src/Zappers/Modules/Exchanges/UniswapV3/INonfungiblePositionManager.sol @@ -106,8 +106,8 @@ interface INonfungiblePositionManager is IPoolInitializer { /// amount1Min The minimum amount of token1 to spend, which serves as a slippage check, /// deadline The time by which the transaction must be included to effect the change /// @return liquidity The new liquidity amount as a result of the increase - /// @return amount0 The amount of token0 to acheive resulting liquidity - /// @return amount1 The amount of token1 to acheive resulting liquidity + /// @return amount0 The amount of token0 to achieve resulting liquidity + /// @return amount1 The amount of token1 to achieve resulting liquidity function increaseLiquidity(IncreaseLiquidityParams calldata params) external payable diff --git a/contracts/test/ExchangeHelpers.t.sol b/contracts/test/ExchangeHelpers.t.sol new file mode 100644 index 00000000..a77b176e --- /dev/null +++ b/contracts/test/ExchangeHelpers.t.sol @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {stdMath} from "forge-std/StdMath.sol"; +import {IERC20Metadata as IERC20} from "openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {ICurveStableswapNGPool} from "../src/Zappers/Modules/Exchanges/Curve/ICurveStableswapNGPool.sol"; +import {IQuoterV2} from "../src/Zappers/Modules/Exchanges/UniswapV3/IQuoterV2.sol"; +import {ISwapRouter} from "../src/Zappers/Modules/Exchanges/UniswapV3/ISwapRouter.sol"; +import {HybridCurveUniV3ExchangeHelpersV2} from "../src/Zappers/Modules/Exchanges/HybridCurveUniV3ExchangeHelpersV2.sol"; +import {IExchange} from "../src/Zappers/Interfaces/IExchange.sol"; +import {IExchangeHelpersV2} from "../src/Zappers/Interfaces/IExchangeHelpersV2.sol"; +import {UseDeployment} from "./Utils/UseDeployment.sol"; + +library Bytes { + function slice(bytes memory array, uint256 start) internal pure returns (bytes memory sliced) { + sliced = new bytes(array.length - start); + + for (uint256 i = 0; i < sliced.length; ++i) { + sliced[i] = array[start + i]; + } + } +} + +library BytesArray { + function clone(bytes[] memory array) internal pure returns (bytes[] memory cloned) { + cloned = new bytes[](array.length); + + for (uint256 i = 0; i < array.length; ++i) { + cloned[i] = array[i]; + } + } + + function reverse(bytes[] memory array) internal pure returns (bytes[] memory) { + for ((uint256 i, uint256 j) = (0, array.length - 1); i < j; (++i, --j)) { + (array[i], array[j]) = (array[j], array[i]); + } + + return array; + } + + function join(bytes[] memory array) internal pure returns (bytes memory joined) { + for (uint256 i = 0; i < array.length; ++i) { + joined = bytes.concat(joined, array[i]); + } + } +} + +contract ExchangeHelpersTest is Test, UseDeployment { + using Bytes for bytes; + using BytesArray for bytes[]; + + uint24 constant UNIV3_FEE_USDC_WETH = 500; // 0.05% + uint24 constant UNIV3_FEE_WETH_COLL = 100; // 0.01% + + IQuoterV2 constant uniV3Quoter = IQuoterV2(0x61fFE014bA17989E743c5F6cB21bF9697530B21e); + ISwapRouter constant uniV3Router = ISwapRouter(0xE592427A0AEce92De3Edee1F18E0157C05861564); + + mapping(address collToken => IExchange) exchange; + IExchangeHelpersV2 exchangeHelpersV2; + + error QuoteResult(uint256 amount); + + function setUp() external { + string memory rpcUrl = vm.envOr("MAINNET_RPC_URL", string("")); + if (bytes(rpcUrl).length == 0) vm.skip(true); + + uint256 forkBlock = vm.envOr("FORK_BLOCK", uint256(0)); + if (forkBlock != 0) { + vm.createSelectFork(rpcUrl, forkBlock); + } else { + vm.createSelectFork(rpcUrl); + } + + _loadDeploymentFromManifest("addresses/1.json"); + + for (uint256 i = 0; i < branches.length; ++i) { + address collToken = address(branches[i].collToken); + exchange[collToken] = branches[i].zapper.exchange(); + } + + exchangeHelpersV2 = new HybridCurveUniV3ExchangeHelpersV2({ + _usdc: USDC, + _weth: WETH, + _curvePool: ICurveStableswapNGPool(address(curveUsdcBold)), + _usdcIndex: int8(curveUsdcBold.coins(0) == USDC ? 0 : 1), + _boldIndex: int8(curveUsdcBold.coins(0) == BOLD ? 0 : 1), + _feeUsdcWeth: UNIV3_FEE_USDC_WETH, + _feeWethColl: UNIV3_FEE_WETH_COLL, + _uniV3Quoter: uniV3Quoter + }); + } + + function test_Curve_CanQuoteApproxDx(bool zeroToOne, uint256 dyExpected) external { + (int128 i, int128 j) = zeroToOne ? (int128(0), int128(1)) : (int128(1), int128(0)); + (address inputToken, address outputToken) = (curveUsdcBold.coins(uint128(i)), curveUsdcBold.coins(uint128(j))); + uint256 dyDecimals = IERC20(outputToken).decimals(); + uint256 dyDiv = 10 ** (18 - dyDecimals); + dyExpected = bound(dyExpected, 1 ether / dyDiv, 1_000_000 ether / dyDiv); + + uint256 dx = curveUsdcBold.get_dx(i, j, dyExpected); + vm.assume(dx > 0); // For some reason Curve sometimes says you can get >0 output tokens in exchange for 0 input + + uint256 balance0 = IERC20(outputToken).balanceOf(address(this)); + deal(inputToken, address(this), dx); + IERC20(inputToken).approve(address(curveUsdcBold), dx); + uint256 dy = curveUsdcBold.exchange(i, j, dx, 0); + + assertEqDecimal(IERC20(outputToken).balanceOf(address(this)) - balance0, dy, dyDecimals, "balance != dy"); + assertApproxEqRelDecimal(dy, dyExpected, 1e-5 ether, dyDecimals, "dy !~= expected dy"); + } + + function test_UniV3_CanQuoteApproxDx(bool collToUsdc, uint256 collIndex, uint256 dyExpected) external { + collIndex = bound(collIndex, 0, branches.length - 1); + address collToken = address(branches[collIndex].collToken); + (address inputToken, address outputToken) = collToUsdc ? (collToken, USDC) : (USDC, collToken); + uint256 dyDecimals = IERC20(outputToken).decimals(); + uint256 dyDiv = 10 ** (18 - dyDecimals); + + if (collToUsdc) { + dyExpected = bound(dyExpected, 1 ether / dyDiv, 10_000 ether / dyDiv); + } else { + dyExpected = bound(dyExpected, 0.001 ether / dyDiv, 10 ether / dyDiv); + } + + bytes[] memory pathUsdcToColl = new bytes[](collToken == WETH ? 3 : 5); + pathUsdcToColl[0] = abi.encodePacked(USDC); + pathUsdcToColl[1] = abi.encodePacked(UNIV3_FEE_USDC_WETH); + pathUsdcToColl[2] = abi.encodePacked(WETH); + if (collToken != WETH) { + pathUsdcToColl[3] = abi.encodePacked(UNIV3_FEE_WETH_COLL); + pathUsdcToColl[4] = abi.encodePacked(collToken); + } + + bytes[] memory pathCollToUsdc = pathUsdcToColl.clone().reverse(); + (bytes memory swapPath, bytes memory quotePath) = + collToUsdc ? (pathCollToUsdc.join(), pathUsdcToColl.join()) : (pathUsdcToColl.join(), pathCollToUsdc.join()); + + uint256 dx = uniV3Quoter_quoteExactOutput(quotePath, dyExpected); + uint256 balance0 = IERC20(outputToken).balanceOf(address(this)); + deal(inputToken, address(this), dx); + IERC20(inputToken).approve(address(uniV3Router), dx); + + uint256 dy = uniV3Router.exactInput( + ISwapRouter.ExactInputParams({ + path: swapPath, + recipient: address(this), + deadline: block.timestamp, + amountIn: dx, + amountOutMinimum: 0 + }) + ); + + assertEqDecimal(IERC20(outputToken).balanceOf(address(this)) - balance0, dy, dyDecimals, "balance != dy"); + assertApproxEqAbsDecimal(dy, dyExpected, 4e-10 ether / dyDiv, dyDecimals, "dy !~= expected dy"); + } + + function test_ExchangeHelpersV2_CanQuoteApproxDx(bool collToBold, uint256 collIndex, uint256 dyExpected) external { + collIndex = bound(collIndex, 0, branches.length - 1); + address collToken = address(branches[collIndex].collToken); + (address inputToken, address outputToken) = collToBold ? (collToken, BOLD) : (BOLD, collToken); + + if (collToBold) { + dyExpected = bound(dyExpected, 1 ether, 10_000 ether); + } else { + dyExpected = bound(dyExpected, 0.001 ether, 10 ether); + } + + uint256 dx = exchangeHelpersV2_quoteExactOutput(dyExpected, collToBold, collToken); + uint256 balance0 = IERC20(outputToken).balanceOf(address(this)); + deal(inputToken, address(this), dx); + IERC20(inputToken).approve(address(exchange[collToken]), dx); + + if (collToBold) { + exchange[collToken].swapToBold(dx, 0); + } else { + exchange[collToken].swapFromBold(dx, 0); + } + + uint256 dy = IERC20(outputToken).balanceOf(address(this)) - balance0; + assertApproxEqRelDecimal(dy, dyExpected, 1e-5 ether, 18, "dy !~= expected dy"); + } + + function test_ExchangeHelpersV2_CanQuoteExactDy(bool collToBold, uint256 collIndex, uint256 dx) external { + collIndex = bound(collIndex, 0, branches.length - 1); + address collToken = address(branches[collIndex].collToken); + (address inputToken, address outputToken) = collToBold ? (collToken, BOLD) : (BOLD, collToken); + + if (collToBold) { + dx = bound(dx, 0.001 ether, 10 ether); + } else { + dx = bound(dx, 1 ether, 10_000 ether); + } + + uint256 dyExpected = exchangeHelpersV2_quoteExactInput(dx, collToBold, collToken); + uint256 balance0 = IERC20(outputToken).balanceOf(address(this)); + deal(inputToken, address(this), dx); + IERC20(inputToken).approve(address(exchange[collToken]), dx); + + if (collToBold) { + exchange[collToken].swapToBold(dx, 0); + } else { + exchange[collToken].swapFromBold(dx, 0); + } + + uint256 dy = IERC20(outputToken).balanceOf(address(this)) - balance0; + assertEqDecimal(dy, dyExpected, 18, "dy != expected dy"); + } + + function _revert(bytes memory revertData) private pure { + assembly { + revert(add(32, revertData), mload(revertData)) + } + } + + function _decodeQuoteResult(bytes memory revertData) private pure returns (uint256) { + bytes4 selector = bytes4(revertData); + if (selector == QuoteResult.selector && revertData.length == 4 + 32) { + return uint256(bytes32(revertData.slice(4))); + } else { + _revert(revertData); // bubble + } + } + + function uniV3Quoter_quoteExactOutput_throw(bytes memory path, uint256 amountOut) external { + (uint256 amountIn,,,) = uniV3Quoter.quoteExactOutput(path, amountOut); + revert QuoteResult(amountIn); + } + + function uniV3Quoter_quoteExactOutput(bytes memory path, uint256 amountOut) internal returns (uint256) { + try this.uniV3Quoter_quoteExactOutput_throw(path, amountOut) { + revert("Should have reverted"); + } catch (bytes memory revertData) { + return _decodeQuoteResult(revertData); + } + } + + function exchangeHelpersV2_quoteExactOutput_throw(uint256 dy, bool collToBold, address collToken) external { + revert QuoteResult(exchangeHelpersV2.quoteExactOutput(dy, collToBold, collToken)); + } + + function exchangeHelpersV2_quoteExactOutput(uint256 dy, bool collToBold, address collToken) + internal + returns (uint256) + { + try this.exchangeHelpersV2_quoteExactOutput_throw(dy, collToBold, collToken) { + revert("Should have reverted"); + } catch (bytes memory revertData) { + return _decodeQuoteResult(revertData); + } + } + + function exchangeHelpersV2_quoteExactInput_throw(uint256 dx, bool collToBold, address collToken) external { + revert QuoteResult(exchangeHelpersV2.quoteExactInput(dx, collToBold, collToken)); + } + + function exchangeHelpersV2_quoteExactInput(uint256 dx, bool collToBold, address collToken) + internal + returns (uint256) + { + try this.exchangeHelpersV2_quoteExactInput_throw(dx, collToBold, collToken) { + revert("Should have reverted"); + } catch (bytes memory revertData) { + return _decodeQuoteResult(revertData); + } + } + + // function assertApproxEqAbsRelDecimal( + // uint256 a, + // uint256 b, + // uint256 maxAbs, + // uint256 maxRel, + // uint256 decimals, + // string memory err + // ) internal pure { + // uint256 abs = stdMath.delta(a, b); + // uint256 rel = stdMath.percentDelta(a, b); + + // if (abs > maxAbs && rel > maxRel) { + // if (rel > maxRel) { + // assertApproxEqRelDecimal(a, b, maxRel, decimals, err); + // } else { + // assertApproxEqAbsDecimal(a, b, maxAbs, decimals, err); + // } + + // revert("Assertion should have failed"); + // } + // } +} diff --git a/contracts/test/InitiativeUniV4Merkl.t.sol b/contracts/test/InitiativeUniV4Merkl.t.sol new file mode 100644 index 00000000..1c57c706 --- /dev/null +++ b/contracts/test/InitiativeUniV4Merkl.t.sol @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {UniV4MerklRewards, IDistributionCreator} from "V2-gov/src/UniV4MerklRewards.sol"; +import "./Utils/E2EHelpers.sol"; +import "forge-std/console2.sol"; + +// Start an anvil node or set up a proper RPC URL +// FOUNDRY_PROFILE=e2e E2E_RPC_URL="http://localhost:8545" forge test --mc InitiativeUniV4Merkl -vvv + +contract InitiativeUniV4Merkl is E2EHelpers { + address constant GOVERNANCE_WHALE = 0xF30da4E4e7e20Dbf5fBE9adCD8699075D62C60A4; + address constant NEW_LQTY_WHALE = 0xF977814e90dA44bFA03b6295A0616a897441aceC; + UniV4MerklRewards constant uniV4MerklRewardsInitiative = UniV4MerklRewards(0xB42448852A1BFc99d66ed53C65e2B49cF954f615); + IDistributionCreator constant merklDistributionCreator = + IDistributionCreator(0x8BB4C975Ff3c250e0ceEA271728547f3802B36Fd); + + function setUp() public override { + super.setUp(); + vm.label(NEW_LQTY_WHALE, "LQTY_WHALE"); + providerOf[LQTY] = NEW_LQTY_WHALE; + } + + function testInitiativeRegistrationAndClaim() external { + address borrower = makeAddr("borrower"); + address registrant = GOVERNANCE_WHALE; + + // Open trove and transfer BOLD to registrant + uint256 donationAmount = 10_000 ether; + _openTrove(0, borrower, 0, Math.max(REGISTRATION_FEE, MIN_DEBT) + donationAmount); + vm.startPrank(borrower); + boldToken.transfer(registrant, REGISTRATION_FEE); + vm.stopPrank(); + + // Stake LQTY and accumulate voting power + address staker = makeAddr("staker"); + uint256 lqtyStake = 3_000_000 ether; + _depositLQTY(staker, lqtyStake); + + skip(28 days); + + assertEq(governance.registeredInitiatives(address(uniV4MerklRewardsInitiative)), 0, "Initiative should not be registered"); + + // Register initiative + vm.startPrank(registrant); + boldToken.approve(address(governance), REGISTRATION_FEE); + governance.registerInitiative(address(uniV4MerklRewardsInitiative)); + vm.stopPrank(); + + assertGt(governance.registeredInitiatives(address(uniV4MerklRewardsInitiative)), 0, "Initiative should be registered"); + + skip(7 days); + + // Allocate to initiative + _allocateLQTY_begin(staker); + _allocateLQTY_vote(address(uniV4MerklRewardsInitiative), int256(lqtyStake)); + _allocateLQTY_end(); + + // Donate + vm.startPrank(borrower); + boldToken.transfer(address(governance), donationAmount); + vm.stopPrank(); + + skip(7 days); + + /* + console2.log(boldToken.balanceOf(address(governance)), "boldToken.balanceOf(address(governance))"); + console2.log(governance.getLatestVotingThreshold(), "voting threshold"); + (Governance.VoteSnapshot memory voteSnapshot, Governance.InitiativeVoteSnapshot memory initiativeVoteSnapshot) = + governance.snapshotVotesForInitiative(address(uniV4MerklRewardsInitiative)); + console2.log(initiativeVoteSnapshot.votes, "initiative Votes"); + (Governance.InitiativeStatus status, uint256 lastEpochClaim, uint256 claimableAmount) = + governance.getInitiativeState(address(uniV4MerklRewardsInitiative)); + console2.log(uint256(status), "uint(status)"); + console2.log(lastEpochClaim, "lastEpochClaim"); + console2.log(claimableAmount, "claimableAmount"); + */ + + // Claim for initiative + uint256 initialMerklDistributorBoldBalance = boldToken.balanceOf(address(merklDistributionCreator.distributor())); + (,, uint256 claimableAmount) = governance.getInitiativeState(address(uniV4MerklRewardsInitiative)); + // Creating a campaign is expensive, and uses more than the allowed by Gorvernace contract, so we need a wrapper + // governance.claimForInitiative(address(uniV4MerklRewardsInitiative)); + uniV4MerklRewardsInitiative.claimForInitiative(); + //console2.log(boldToken.balanceOf(address(uniV4MerklRewardsInitiative)), "boldToken.balanceOf(address(uniV4MerklRewardsInitiative))"); + assertEq(boldToken.balanceOf(address(uniV4MerklRewardsInitiative)), 0, "Initiative should have sent incentives to campaign"); + // Check campaign + uint256 epochEnd = EPOCH_START + (governance.epoch() - 1) * EPOCH_DURATION; + IDistributionCreator.CampaignParameters memory params = IDistributionCreator.CampaignParameters({ + campaignId: bytes32(0), + creator: address(uniV4MerklRewardsInitiative), + rewardToken: address(boldToken), + amount: claimableAmount, + campaignType: uniV4MerklRewardsInitiative.CAMPAIGN_TYPE(), + startTimestamp: uint32(epochEnd), + duration: uint32(EPOCH_DURATION), + campaignData: uniV4MerklRewardsInitiative.getCampaignData() + }); + bytes32 campaignId = merklDistributionCreator.campaignId(params); + uint256 campaignAmount = params.amount * 97 / 100; + IDistributionCreator.CampaignParameters memory campaign = merklDistributionCreator.campaign(campaignId); + assertEq(campaign.creator, params.creator, "creator"); + assertEq(campaign.rewardToken, params.rewardToken, "rewardToken"); + assertEq(campaign.amount, campaignAmount, "amount minus fees"); + assertEq(campaign.campaignType, params.campaignType, "campaignType"); + assertEq(campaign.startTimestamp, params.startTimestamp, "startTimestamp"); + assertEq(campaign.duration, params.duration, "duration"); + assertEq(campaign.campaignData, params.campaignData, "campaignData"); + + assertEq( + boldToken.balanceOf(address(merklDistributionCreator.distributor())) - initialMerklDistributorBoldBalance, + campaignAmount, + "Merkl Distributor should have campaign amount BOLD" + ); + + } +} diff --git a/contracts/test/RedemptionHelper.t.sol b/contracts/test/RedemptionHelper.t.sol new file mode 100644 index 00000000..ad2f4c05 --- /dev/null +++ b/contracts/test/RedemptionHelper.t.sol @@ -0,0 +1,366 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {Math} from "openzeppelin-contracts/contracts/utils/math/Math.sol"; +import {Strings} from "openzeppelin-contracts/contracts/utils/Strings.sol"; +import {MIN_DEBT} from "../src/Dependencies/Constants.sol"; +import {IAddressesRegistry} from "../src/Interfaces/IAddressesRegistry.sol"; +import {IRedemptionHelper} from "../src/Interfaces/IRedemptionHelper.sol"; +import {TroveChange} from "../src/Types/TroveChange.sol"; +import {RedemptionHelper} from "../src/RedemptionHelper.sol"; +import {Accounts} from "./TestContracts/Accounts.sol"; +import {TestDeployer} from "./TestContracts/Deployment.t.sol"; +import {DevTestSetup} from "./TestContracts/DevTestSetup.sol"; + +uint256 constant NUM_BRANCHES = 3; +uint256 constant NUM_TROVES = 20; + +contract RedemptionHelperTest is DevTestSetup { + using Strings for *; + + struct TroveParams { + uint256 branchIdx; + uint256 collRatio; + uint256 debt; + } + + TestDeployer.TroveManagerParams[] params; + TestDeployer.LiquityContractsDev[] branch; + IRedemptionHelper redemptionHelper; + + function setUp() public override { + // Start tests at a non-zero timestamp + vm.warp(block.timestamp + 600); + + accounts = new Accounts(); + createAccounts(); + + (A, B, C, D, E, F, G) = ( + accountsList[0], + accountsList[1], + accountsList[2], + accountsList[3], + accountsList[4], + accountsList[5], + accountsList[6] + ); + + params.push(TestDeployer.TroveManagerParams(1.5 ether, 1.1 ether, 0.1 ether, 1.1 ether, 0.05 ether, 0.1 ether)); + params.push(TestDeployer.TroveManagerParams(1.6 ether, 1.2 ether, 0.1 ether, 1.2 ether, 0.05 ether, 0.2 ether)); + params.push(TestDeployer.TroveManagerParams(1.6 ether, 1.2 ether, 0.1 ether, 1.2 ether, 0.05 ether, 0.2 ether)); + assertEq(NUM_BRANCHES, 3, "Must update params"); + + TestDeployer.LiquityContractsDev[] memory tmpBranch; + TestDeployer deployer = new TestDeployer(); + (tmpBranch, collateralRegistry, boldToken, hintHelpers,, WETH,) = + deployer.deployAndConnectContractsMultiColl(params); + + for (uint256 i = 0; i < tmpBranch.length; ++i) { + branch.push(tmpBranch[i]); + } + + branch[0].priceFeed.setPrice(2000e18); + branch[1].priceFeed.setPrice(3000e18); + branch[2].priceFeed.setPrice(4000e18); + assertEq(NUM_BRANCHES, 3, "Must update initial prices"); + + for (uint256 i = 0; i < branch.length; ++i) { + for (uint256 j = 0; j < accountsList.length; ++j) { + // Give some Collateral to test accounts, and approve it to BorrowerOperations + giveAndApproveCollateral( + branch[i].collToken, accountsList[j], 10_000 ether, address(branch[i].borrowerOperations) + ); + + // Approve WETH for gas compensation in all branches + vm.prank(accountsList[j]); + WETH.approve(address(branch[i].borrowerOperations), type(uint256).max); + } + } + + IAddressesRegistry[] memory addresses = new IAddressesRegistry[](branch.length); + for (uint256 i = 0; i < branch.length; ++i) { + addresses[i] = branch[i].addressesRegistry; + } + + redemptionHelper = new RedemptionHelper(collateralRegistry, addresses); + } + + function findAmountToBorrow(uint256 branchIdx, uint256 targetDebt, uint256 interestRate) + internal + view + returns (uint256 borrow, uint256 upfrontFee) + { + uint256 borrowRight = targetDebt; + upfrontFee = hintHelpers.predictOpenTroveUpfrontFee(branchIdx, borrowRight, interestRate); + uint256 borrowLeft = borrowRight - upfrontFee; + + for (uint256 i = 0; i < 256; ++i) { + borrow = (borrowLeft + borrowRight) / 2; + upfrontFee = hintHelpers.predictOpenTroveUpfrontFee(branchIdx, borrow, interestRate); + uint256 actualDebt = borrow + upfrontFee; + + if (actualDebt == targetDebt) { + break; + } else if (actualDebt < targetDebt) { + borrowLeft = borrow; + } else { + borrowRight = borrow; + } + } + } + + function openTrove( + uint256 branchIdx, + address owner, + uint256 ownerIdx, + uint256 collRatio, + uint256 debt, + uint256 interestRate + ) internal { + (uint256 borrow, uint256 upfrontFee) = findAmountToBorrow(branchIdx, debt, interestRate); + uint256 coll = Math.ceilDiv(debt * collRatio, branch[branchIdx].priceFeed.getPrice()); + + vm.prank(owner); + branch[branchIdx].borrowerOperations.openTrove({ + _owner: owner, + _ownerIndex: ownerIdx, + _ETHAmount: coll, + _boldAmount: borrow, + _upperHint: 0, + _lowerHint: 0, + _annualInterestRate: interestRate, + _maxUpfrontFee: upfrontFee, + _addManager: address(0), + _removeManager: address(0), + _receiver: address(0) + }); + } + + function openTroves(address owner, TroveParams[NUM_TROVES] memory trove) internal { + for (uint256 i = 0; i < trove.length; ++i) { + trove[i].branchIdx = _bound(trove[i].branchIdx, 0, branch.length - 1); + trove[i].collRatio = _bound(trove[i].collRatio, params[trove[i].branchIdx].CCR, 3 ether); + trove[i].debt = _bound(trove[i].debt, MIN_DEBT, 100 * MIN_DEBT); + openTrove(trove[i].branchIdx, owner, i, trove[i].collRatio, trove[i].debt, 0.05 ether); + } + } + + function provideToSP(uint256 branchIdx, address account, uint256 bold) public { + vm.prank(account); + branch[branchIdx].stabilityPool.provideToSP(bold, true); + } + + function provideToSPs(address account, uint256[NUM_BRANCHES] memory bold) public { + for (uint256 i = 0; i < bold.length; ++i) { + bold[i] = _bound(bold[i], 0, boldToken.balanceOf(account) - 1); + if (bold[i] > 0) provideToSP(i, account, bold[i]); + } + } + + function setTotalCollRatio(uint256[NUM_BRANCHES] memory totalCollRatio) internal { + for (uint256 i = 0; i < totalCollRatio.length; ++i) { + totalCollRatio[i] = _bound(totalCollRatio[i], 0.9 ether, 3 ether); + uint256 totalColl = branch[i].troveManager.getEntireBranchColl(); + uint256 totalDebt = branch[i].troveManager.getEntireBranchDebt(); + if (totalColl > 0) branch[i].priceFeed.setPrice(totalCollRatio[i] * totalDebt / totalColl); + } + } + + function test_SimulateRedemption( + uint256 delay, + TroveParams[NUM_TROVES] memory troves, + uint256[NUM_BRANCHES] memory spBold, + uint256[NUM_BRANCHES] memory totalCollRatio, + uint256 attemptedRedeemedBold, + uint256 maxIterations + ) external { + skip(_bound(delay, 0, 30 days)); // decay the baserate + + openTroves(A, troves); + provideToSPs(A, spBold); + setTotalCollRatio(totalCollRatio); + + attemptedRedeemedBold = _bound(attemptedRedeemedBold, 1, boldToken.balanceOf(A)); + maxIterations = _bound(maxIterations, 0, NUM_TROVES); + + (IRedemptionHelper.SimulationContext[] memory sim,) = + redemptionHelper.simulateRedemption(attemptedRedeemedBold, maxIterations); + + uint256 expectedRedeemedBold = 0; + uint256 expectedMaxIterations = 0; + + for (uint256 i = 0; i < sim.length; ++i) { + expectedRedeemedBold += sim[i].redeemedBold; + expectedMaxIterations = Math.max(expectedMaxIterations, sim[i].iterations); + } + + assertLeDecimal(expectedRedeemedBold, attemptedRedeemedBold, 18, "expectedRedeemedBold > attemptedRedeemedBold"); + if (maxIterations != 0) assertLe(expectedMaxIterations, maxIterations, "expectedMaxIterations > maxIterations"); + + uint256 boldBalanceBefore = boldToken.balanceOf(A); + vm.prank(A); + collateralRegistry.redeemCollateral(attemptedRedeemedBold, expectedMaxIterations, 1 ether); + uint256 actualRedeemedBold = boldBalanceBefore - boldToken.balanceOf(A); + + // There can be a tiny difference between the simulated and actually redeemed BOLD amounts, + // since RedemptionHelper doesn't implement error feedback similar to what CollateralRegistry + // does when proportionally splitting the redeemed amount. + // + // We deem this acceptable, since the frontend will eventually apply significantly larger + // slippage tolerance margins to the corresponding min collateral amounts anyway. + assertApproxEqAbsDecimal( + actualRedeemedBold, expectedRedeemedBold, 2, 18, "actualRedeemedBold != expectedRedeemedBold" + ); + } + + function test_TruncateRedemption( + uint256 delay, + TroveParams[NUM_TROVES] memory troves, + uint256[NUM_BRANCHES] memory spBold, + uint256[NUM_BRANCHES] memory totalCollRatio, + uint256 attemptedRedeemedBold, + uint256 maxIterations + ) external { + skip(_bound(delay, 0, 30 days)); // decay the baserate + + openTroves(A, troves); + provideToSPs(A, spBold); + setTotalCollRatio(totalCollRatio); + + attemptedRedeemedBold = _bound(attemptedRedeemedBold, 1, boldToken.balanceOf(A)); + maxIterations = _bound(maxIterations, 0, NUM_TROVES); + + (uint256 truncatedRedeemedBold, uint256 feePct, IRedemptionHelper.Redeemed[] memory expectedRedeemed) = + redemptionHelper.truncateRedemption(attemptedRedeemedBold, maxIterations); + vm.assume(truncatedRedeemedBold > 0); + + assertLeDecimal( + truncatedRedeemedBold, attemptedRedeemedBold, 18, "truncatedRedeemedBold > attemptedRedeemedBold" + ); + + uint256 boldBalanceBefore = boldToken.balanceOf(A); + uint256[] memory collBalanceBefore = new uint256[](branch.length); + for (uint256 i = 0; i < branch.length; ++i) { + collBalanceBefore[i] = branch[i].collToken.balanceOf(A); + } + + vm.prank(A); + collateralRegistry.redeemCollateral(truncatedRedeemedBold, maxIterations, feePct); + + uint256 actualRedeemedBold = boldBalanceBefore - boldToken.balanceOf(A); + assertApproxEqAbsDecimal( + actualRedeemedBold, truncatedRedeemedBold, 1, 18, "actualRedeemedBold != truncatedRedeemedBold" + ); + + for (uint256 i = 0; i < branch.length; ++i) { + uint256 actualRedeemedColl = branch[i].collToken.balanceOf(A) - collBalanceBefore[i]; + + assertApproxEqAbsDecimal( + actualRedeemedColl, + expectedRedeemed[i].coll, + 10, + 18, + string.concat("actualRedeemedColl != expectedRedeemed[", i.toString(), "].coll") + ); + } + } + + function test_RedeemCollateral_RefundsRemainingBold() external { + skip(100 days); + + for (uint256 i = 0; i < branch.length - 1; ++i) { + openTrove(i, A, 0, 2 ether, 10_000 ether, 0.05 ether); + } + + // All branches have the same unbacked portions, + // but on the last branch only 2K can be redeemed in 1 iteration + openTrove(branch.length - 1, A, 0, 2 ether, 2_000 ether, 0.05 ether); + openTrove(branch.length - 1, A, 1, 2 ether, 8_000 ether, 0.06 ether); + + uint256 boldBalanceBefore = boldToken.balanceOf(A); + uint256 attemptedRedeemedBold = branch.length * 3_000 ether; + + vm.startPrank(A); + boldToken.approve(address(redemptionHelper), attemptedRedeemedBold); + redemptionHelper.redeemCollateral(attemptedRedeemedBold, 1, 1 ether, new uint256[](branch.length)); + vm.stopPrank(); + + uint256 actualRedeemedBold = boldBalanceBefore - boldToken.balanceOf(A); + assertEqDecimal(actualRedeemedBold, attemptedRedeemedBold - 1_000 ether, 18, "actualRedeemedBold"); + assertEqDecimal(boldToken.balanceOf(address(redemptionHelper)), 0, 18, "boldToken.balanceOf(redemptionHelper)"); + } + + function test_RedeemCollateral_ForwardsRedeemedColl() external { + skip(100 days); + + for (uint256 i = 0; i < branch.length; ++i) { + openTrove(i, A, 0, 2 ether, 10_000 ether, 0.05 ether); + } + + uint256[] memory collBalanceBefore = new uint256[](branch.length); + for (uint256 i = 0; i < branch.length; ++i) { + collBalanceBefore[i] = branch[i].collToken.balanceOf(A); + } + + uint256 redeemedBold = branch.length * 1_000 ether; // a tenth of the supply + + vm.startPrank(A); + boldToken.approve(address(redemptionHelper), redeemedBold); + redemptionHelper.redeemCollateral(redeemedBold, 1, 1 ether, new uint256[](branch.length)); + vm.stopPrank(); + + for (uint256 i = 0; i < branch.length; ++i) { + uint256 actualRedeemedColl = branch[i].collToken.balanceOf(A) - collBalanceBefore[i]; + + assertApproxEqAbsDecimal( + actualRedeemedColl, + 1_000 ether * 0.895 ether / branch[i].priceFeed.getPrice(), + 1, + 18, + "actualRedeemedColl" + ); + + assertEqDecimal( + branch[i].collToken.balanceOf(address(redemptionHelper)), 0, 18, "collToken.balanceOf(redemptionHelper)" + ); + } + } + + function test_RedeemCollateral_RevertsWhenRedeemedCollLtMin() external { + skip(100 days); + + for (uint256 i = 0; i < branch.length; ++i) { + // Use the same price on each branch for simplicity + branch[i].priceFeed.setPrice(1_000 ether); + openTrove(i, A, 0, 2 ether, 10_000 ether, 0.05 ether); + } + + uint256 redeemedBold = branch.length * 1_000 ether; // a tenth of the supply + + uint256[] memory minCollRedeemed = new uint256[](branch.length); + for (uint256 i = 0; i < branch.length; ++i) { + minCollRedeemed[i] = 0.895 ether; + } + + vm.startPrank(A); + { + boldToken.approve(address(redemptionHelper), redeemedBold); + + for (uint256 i = 0; i < branch.length; ++i) { + // Make one of the parameters too high + ++minCollRedeemed[i]; + + // This should cause the redemption to revert + vm.expectRevert("Insufficient collateral redeemed"); + redemptionHelper.redeemCollateral(redeemedBold, 1, 1 ether, minCollRedeemed); + + // Fix the parameter that was made too high + --minCollRedeemed[i]; + } + + // Should succeed now that all parameters are fixed + redemptionHelper.redeemCollateral(redeemedBold, 1, 1 ether, minCollRedeemed); + } + vm.stopPrank(); + } +} diff --git a/contracts/test/Utils/UseDeployment.sol b/contracts/test/Utils/UseDeployment.sol index 2c924f13..7cb918c3 100644 --- a/contracts/test/Utils/UseDeployment.sol +++ b/contracts/test/Utils/UseDeployment.sol @@ -8,6 +8,7 @@ import {Strings} from "openzeppelin-contracts/contracts/utils/Strings.sol"; import {IUserProxy} from "V2-gov/src/interfaces/IUserProxy.sol"; import {CurveV2GaugeRewards} from "V2-gov/src/CurveV2GaugeRewards.sol"; import {Governance} from "V2-gov/src/Governance.sol"; +import {IExchangeHelpers} from "src/Zappers/Interfaces/IExchangeHelpers.sol"; import {ILeverageZapper} from "src/Zappers/Interfaces/ILeverageZapper.sol"; import {IZapper} from "src/Zappers/Interfaces/IZapper.sol"; import {IActivePool} from "src/Interfaces/IActivePool.sol"; @@ -73,6 +74,7 @@ contract UseDeployment is CommonBase { ICollateralRegistry collateralRegistry; IBoldToken boldToken; IHintHelpers hintHelpers; + IExchangeHelpers exchangeHelpers; Governance governance; ICurveStableSwapNG curveUsdcBold; ILiquidityGaugeV6 curveUsdcBoldGauge; @@ -89,6 +91,7 @@ contract UseDeployment is CommonBase { collateralRegistry = ICollateralRegistry(json.readAddress(".collateralRegistry")); boldToken = IBoldToken(BOLD = json.readAddress(".boldToken")); hintHelpers = IHintHelpers(json.readAddress(".hintHelpers")); + exchangeHelpers = IExchangeHelpers(json.readAddress(".exchangeHelpers")); governance = Governance(json.readAddress(".governance.governance")); curveUsdcBold = ICurveStableSwapNG(json.readAddress(".governance.curveUsdcBoldPool")); curveUsdcBoldGauge = ILiquidityGaugeV6(json.readAddress(".governance.curveUsdcBoldGauge")); @@ -101,10 +104,14 @@ contract UseDeployment is CommonBase { vm.label(address(collateralRegistry), "CollateralRegistry"); vm.label(address(hintHelpers), "HintHelpers"); + vm.label(address(exchangeHelpers), "ExchangeHelpers"); vm.label(address(governance), "Governance"); vm.label(address(curveUsdcBold), "CurveStableSwapNG"); vm.label(address(curveUsdcBoldGauge), "LiquidityGaugeV6"); vm.label(address(curveUsdcBoldInitiative), "CurveV2GaugeRewards"); + vm.label(address(curveLusdBold), "CurveStableSwapNG"); + vm.label(address(curveLusdBoldGauge), "LiquidityGaugeV6"); + vm.label(address(curveLusdBoldInitiative), "CurveV2GaugeRewards"); ETH_GAS_COMPENSATION = json.readUint(".constants.ETH_GAS_COMPENSATION"); MIN_DEBT = json.readUint(".constants.MIN_DEBT"); diff --git a/contracts/test/redemptions.t.sol b/contracts/test/redemptions.t.sol index 498a194c..5cc4e625 100644 --- a/contracts/test/redemptions.t.sol +++ b/contracts/test/redemptions.t.sol @@ -1000,6 +1000,46 @@ contract Redemptions is DevTestSetup { assertEq(uint8(troveManager.getTroveStatus(troveIDs.B)), uint8(ITroveManager.Status.closedByLiquidation)); } + function testZombieTroveCanActAsLastTrove() public { + (,, ABCDEF memory troveIDs) = _setupForRedemptionAscendingInterest(); + + _redeemAndCreateEmptyZombieTrovesAAndB(troveIDs); + + // Check A, B removed from sorted list + assertFalse(sortedTroves.contains(troveIDs.A)); + assertFalse(sortedTroves.contains(troveIDs.B)); + + // Check A, B zombie (already checked in helper above) + //assertEq(uint8(troveManager.getTroveStatus(troveIDs.A)), uint8(ITroveManager.Status.zombie)); + //assertEq(uint8(troveManager.getTroveStatus(troveIDs.B)), uint8(ITroveManager.Status.zombie)); + + // Check A, B empty (already checked in helper above) + //assertEq(troveManager.getTroveEntireDebt(troveIDs.A), 0); + //assertEq(troveManager.getTroveEntireDebt(troveIDs.B), 0); + + // Check last Zombie trove pointer + assertEq(troveManager.lastZombieTroveId(), 0, "Wrong last zombie trove pointer"); + + // Zombie troves are not in the array + assertEq(troveManager.getTroveIdsCount(), 4, "Wrong number of troves"); + // We need some extra Bold for closing, due to interest + deal(address(boldToken), E, boldToken.balanceOf(E) + 300e18); + + // Close troves B to F + closeTrove(B, troveIDs.B); + transferBold(E, C, boldToken.balanceOf(E)); + closeTrove(C, troveIDs.C); + transferBold(C, D, boldToken.balanceOf(C)); + closeTrove(D, troveIDs.D); + assertEq(troveManager.getTroveIdsCount(), 1, "Wrong number of troves"); + + // A cannot be closed, as it is the last one + vm.startPrank(A); + vm.expectRevert(TroveManager.OnlyOneTroveLeft.selector); + borrowerOperations.closeTrove(troveIDs.A); + vm.stopPrank(); + } + // -- Redemption after redistribution function testRedemptionAfterRedistributionInBatch() public { diff --git a/contracts/utils/deployment-manifest-to-app-env.ts b/contracts/utils/deployment-manifest-to-app-env.ts index de8e2762..ed431ddc 100644 --- a/contracts/utils/deployment-manifest-to-app-env.ts +++ b/contracts/utils/deployment-manifest-to-app-env.ts @@ -30,8 +30,6 @@ const argv = minimist(process.argv.slice(2), { ], }); -const ZERO_ADDRESS = "0x" + "0".repeat(40); - const ZAddress = z.string().regex(/^0x[0-9a-fA-F]{40}$/); const ZDeploymentManifest = z.object({ collateralRegistry: ZAddress, @@ -40,6 +38,7 @@ const ZDeploymentManifest = z.object({ multiTroveGetter: ZAddress, debtInFrontHelper: ZAddress, exchangeHelpers: ZAddress, + exchangeHelpersV2: ZAddress, governance: z.object({ LUSDToken: ZAddress, @@ -188,6 +187,8 @@ function contractNameToAppEnvVariable(contractName: string, prefix: string = "") return `${prefix}_DEBT_IN_FRONT_HELPER`; case "exchangeHelpers": return `${prefix}_EXCHANGE_HELPERS`; + case "exchangeHelpersV2": + return `${prefix}_EXCHANGE_HELPERS_V2`; // collateral contracts case "activePool": diff --git a/frontend/app/.env.example b/frontend/app/.env.example index 402e5767..23d9eb06 100644 --- a/frontend/app/.env.example +++ b/frontend/app/.env.example @@ -21,4 +21,10 @@ NEXT_PUBLIC_CONTRACT_LQTY_TOKEN=0x0000000000000000000000000000000000000000 NEXT_PUBLIC_CONTRACT_LUSD_TOKEN=0x0000000000000000000000000000000000000000 NEXT_PUBLIC_CONTRACT_MULTI_TROVE_GETTER=0x0000000000000000000000000000000000000000 NEXT_PUBLIC_CONTRACT_WETH=0x0000000000000000000000000000000000000000 -NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID= \ No newline at end of file +NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID= + + + +NEXT_PUBLIC_CONTRACT_EXCHANGE_HELPERS_V2=0xe453b864d3841469763bda2437e3dd0e38dca222 +NEXT_PUBLIC_CONTRACT_REDEMPTION_HELPER=0xb366256d033ae7e4f7bddec822a5adec9df07b80 +NEXT_PUBLIC_CONTRACT_V1_STABILITY_POOL=0x66017D22b0f8556afDd19FC67041899Eb65a21bb \ No newline at end of file diff --git a/frontend/app/README.md b/frontend/app/README.md index 201f99bb..8e159264 100644 --- a/frontend/app/README.md +++ b/frontend/app/README.md @@ -236,10 +236,24 @@ Indicates a specific deployment variant (e.g., "preview"). This will be displaye NEXT_PUBLIC_DEPLOYMENT_FLAVOR=preview ``` +### `NEXT_PUBLIC_KNOWN_DELEGATES_URL` + +URL to fetch known interest rate delegates from (optional). + +```dosini +# Default +NEXT_PUBLIC_KNOWN_DELEGATES_URL=https://api.liquity.org/v2/known-delegates/ethereum.json +``` + ### `NEXT_PUBLIC_KNOWN_INITIATIVES_URL` URL for fetching known initiatives data (optional). +```dosini +# Example +NEXT_PUBLIC_KNOWN_INITIATIVES_URL=https://api.liquity.org/v2/known-initiatives/ethereum.json +``` + ### `NEXT_PUBLIC_LIQUITY_STATS_URL` URL for fetching Liquity protocol statistics. @@ -260,10 +274,10 @@ NEXT_PUBLIC_LIQUITY_GOVERNANCE_URL=https://api.liquity.org/v2/governance ### `NEXT_PUBLIC_SAFE_API_URL` -URL for the Safe transaction service API. +URL for the Safe transaction service API (optional). Can be disabled by passing an empty string, for example during local development via Anvil. ```dosini -# Example +# Default NEXT_PUBLIC_SAFE_API_URL=https://safe-transaction-mainnet.safe.global/api ``` @@ -276,6 +290,15 @@ URL for The Graph protocol subgraph queries. NEXT_PUBLIC_SUBGRAPH_URL=https://api.studio.thegraph.com/query/… ``` +### `NEXT_PUBLIC_SUBGRAPH_ORIGIN` + +When using a subgraph URL that's restricted to set of domains which are allowed to execute queries, this must be set to one of the allowed domains. When fetching the schema of the subgraph during build, this domain will be sent as HTTP origin. Otherwise, the build will fail. + +```dosini +# Example +NEXT_PUBLIC_SUBGRAPH_ORIGIN=https://example.com +``` + ### `NEXT_PUBLIC_VERCEL_ANALYTICS` Enable or disable Vercel Analytics for tracking application metrics. @@ -296,6 +319,7 @@ An optional set of names and URLs (of the form `|`) of external apps Currently, only the indices `_0` and `_1` are supported. Defaults to the following values: + ```dosini NEXT_PUBLIC_TROVE_EXPLORER_0=DeFi Explore|https://liquityv2.defiexplore.com/trove/{branch}/{troveId} NEXT_PUBLIC_TROVE_EXPLORER_1=Rails|https://rails.finance/explorer/trove/{troveId}/{branch} diff --git a/frontend/app/graphql-codegen.ts b/frontend/app/graphql-codegen.ts index 82ce8636..f843eec9 100644 --- a/frontend/app/graphql-codegen.ts +++ b/frontend/app/graphql-codegen.ts @@ -1,30 +1,11 @@ import type { CodegenConfig } from "@graphql-codegen/cli"; +import { loadEnvConfig } from "@next/env"; -function findSubgraphUrl(envFile: string) { - const fs = require("fs"); - const path = require("path"); - const envPath = path.resolve(process.cwd(), envFile); - - // Check if file exists before trying to read it - if (!fs.existsSync(envPath)) { - return null; - } - - try { - const envContent = fs.readFileSync(envPath, "utf-8"); - for (const line of envContent.split("\n")) { - if (line.trim().startsWith("NEXT_PUBLIC_SUBGRAPH_URL=")) { - return line.slice("NEXT_PUBLIC_SUBGRAPH_URL=".length); - } - } - } catch (error) { - console.warn(`Could not read ${envFile}:`, (error as Error).message); - } - - return null; -} +const projectDir = process.cwd(); +loadEnvConfig(projectDir); -const subgraphUrl = findSubgraphUrl(".env.local") ?? findSubgraphUrl(".env"); +const subgraphUrl = process.env.NEXT_PUBLIC_SUBGRAPH_URL; +const subgraphOrigin = process.env.NEXT_PUBLIC_SUBGRAPH_ORIGIN; if (!subgraphUrl) { throw new Error( @@ -32,9 +13,23 @@ if (!subgraphUrl) { ); } -console.log("Using subgraph URL:", subgraphUrl, "\n"); +console.log("Using subgraph URL:", subgraphUrl); +if (subgraphOrigin) console.log("Impersonating origin:", subgraphOrigin); +console.log(); const config: CodegenConfig = { + customFetch: subgraphOrigin + ? (url, options) => { + return fetch(url, { + ...options, + headers: { + ...options?.headers, + "Origin": subgraphOrigin, + }, + }); + } + : undefined, + schema: subgraphUrl, documents: "src/**/*.{ts,tsx}", ignoreNoDocuments: true, diff --git a/frontend/app/package.json b/frontend/app/package.json index 5e0435d4..0387abaa 100644 --- a/frontend/app/package.json +++ b/frontend/app/package.json @@ -3,14 +3,14 @@ "private": true, "author": "Liquity AG", "license": "MIT", - "version": "1.7.0", + "version": "1.9.0", "type": "module", "scripts": { "start": "next start", "build": "pnpm build-deps && pnpm clean && next build", "build-analyze": "ANALYZE=true pnpm build", "build-banner": "test -f ./Banner.tsx || cp ./Banner.example.tsx ./Banner.tsx", - "build-deps": "pnpm build-graphql && pnpm build-uikit && pnpm build-banner && pnpm build-panda", + "build-deps": "pnpm build-uikit && pnpm build-banner && pnpm build-panda", "build-graphql": "pnpm graphql-codegen --config graphql-codegen.ts", "build-panda": "panda codegen --silent", "build-uikit": "cd ../uikit && pnpm build", @@ -49,6 +49,7 @@ "@babel/plugin-transform-private-methods": "^7.27.1", "@graphql-codegen/cli": "^5.0.7", "@graphql-codegen/schema-ast": "^4.1.0", + "@next/env": "^15.5.2", "@pandacss/dev": "^0.54.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", diff --git a/frontend/app/scripts/update-liquity-abis.ts b/frontend/app/scripts/update-liquity-abis.ts index a7f4fecd..f92cc6c9 100644 --- a/frontend/app/scripts/update-liquity-abis.ts +++ b/frontend/app/scripts/update-liquity-abis.ts @@ -16,6 +16,8 @@ const ABIS = [ ["HintHelpers"], ["MultiTroveGetter"], ["DebtInFrontHelper"], + ["IExchangeHelpersV2"], + ["RedemptionHelper"], // Governance (V2-gov lib) ["Governance"], diff --git a/frontend/app/src/abi/BribeInitiative.ts b/frontend/app/src/abi/BribeInitiative.ts index e6cae79a..24dd5bff 100644 --- a/frontend/app/src/abi/BribeInitiative.ts +++ b/frontend/app/src/abi/BribeInitiative.ts @@ -106,11 +106,12 @@ export const BribeInitiative = [ "type": "uint256", "internalType": "uint256", }], - "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }, { - "name": "", - "type": "uint256", - "internalType": "uint256", - }], + "outputs": [ + { "name": "", "type": "uint256", "internalType": "uint256" }, + { "name": "", "type": "uint256", "internalType": "uint256" }, + { "name": "", "type": "uint256", "internalType": "uint256" }, + { "name": "", "type": "uint256", "internalType": "uint256" }, + ], "stateMutability": "view", }, { @@ -185,11 +186,12 @@ export const BribeInitiative = [ "type": "function", "name": "totalLQTYAllocatedByEpoch", "inputs": [{ "name": "_epoch", "type": "uint256", "internalType": "uint256" }], - "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }, { - "name": "", - "type": "uint256", - "internalType": "uint256", - }], + "outputs": [ + { "name": "", "type": "uint256", "internalType": "uint256" }, + { "name": "", "type": "uint256", "internalType": "uint256" }, + { "name": "", "type": "uint256", "internalType": "uint256" }, + { "name": "", "type": "uint256", "internalType": "uint256" }, + ], "stateMutability": "view", }, { diff --git a/frontend/app/src/abi/IExchangeHelpersV2.ts b/frontend/app/src/abi/IExchangeHelpersV2.ts new file mode 100644 index 00000000..fb0695b7 --- /dev/null +++ b/frontend/app/src/abi/IExchangeHelpersV2.ts @@ -0,0 +1,23 @@ +// this file was generated by scripts/update-liquity-abis.ts +// please do not edit it manually +export const IExchangeHelpersV2 = [{ + "type": "function", + "name": "quoteExactInput", + "inputs": [{ "name": "_inputAmount", "type": "uint256", "internalType": "uint256" }, { + "name": "_collToBold", + "type": "bool", + "internalType": "bool", + }, { "name": "_collToken", "type": "address", "internalType": "address" }], + "outputs": [{ "name": "outputAmount", "type": "uint256", "internalType": "uint256" }], + "stateMutability": "nonpayable", +}, { + "type": "function", + "name": "quoteExactOutput", + "inputs": [{ "name": "_outputAmount", "type": "uint256", "internalType": "uint256" }, { + "name": "_collToBold", + "type": "bool", + "internalType": "bool", + }, { "name": "_collToken", "type": "address", "internalType": "address" }], + "outputs": [{ "name": "inputAmount", "type": "uint256", "internalType": "uint256" }], + "stateMutability": "nonpayable", +}] as const; diff --git a/frontend/app/src/abi/RedemptionHelper.ts b/frontend/app/src/abi/RedemptionHelper.ts new file mode 100644 index 00000000..e10aaba7 --- /dev/null +++ b/frontend/app/src/abi/RedemptionHelper.ts @@ -0,0 +1,93 @@ +// this file was generated by scripts/update-liquity-abis.ts +// please do not edit it manually +export const RedemptionHelper = [{ + "type": "constructor", + "inputs": [{ "name": "_collateralRegistry", "type": "address", "internalType": "contract ICollateralRegistry" }, { + "name": "_addresses", + "type": "address[]", + "internalType": "contract IAddressesRegistry[]", + }], + "stateMutability": "nonpayable", +}, { + "type": "function", + "name": "addresses", + "inputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], + "outputs": [{ "name": "", "type": "address", "internalType": "contract IAddressesRegistry" }], + "stateMutability": "view", +}, { + "type": "function", + "name": "boldToken", + "inputs": [], + "outputs": [{ "name": "", "type": "address", "internalType": "contract IBoldToken" }], + "stateMutability": "view", +}, { + "type": "function", + "name": "collateralRegistry", + "inputs": [], + "outputs": [{ "name": "", "type": "address", "internalType": "contract ICollateralRegistry" }], + "stateMutability": "view", +}, { + "type": "function", + "name": "numBranches", + "inputs": [], + "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], + "stateMutability": "view", +}, { + "type": "function", + "name": "redeemCollateral", + "inputs": [ + { "name": "_bold", "type": "uint256", "internalType": "uint256" }, + { "name": "_maxIterationsPerCollateral", "type": "uint256", "internalType": "uint256" }, + { "name": "_maxFeePct", "type": "uint256", "internalType": "uint256" }, + { "name": "_minCollRedeemed", "type": "uint256[]", "internalType": "uint256[]" }, + ], + "outputs": [], + "stateMutability": "nonpayable", +}, { + "type": "function", + "name": "simulateRedemption", + "inputs": [{ "name": "_bold", "type": "uint256", "internalType": "uint256" }, { + "name": "_maxIterationsPerCollateral", + "type": "uint256", + "internalType": "uint256", + }], + "outputs": [{ + "name": "branch", + "type": "tuple[]", + "internalType": "struct IRedemptionHelper.SimulationContext[]", + "components": [ + { "name": "troveManager", "type": "address", "internalType": "address" }, + { "name": "sortedTroves", "type": "address", "internalType": "address" }, + { "name": "redeemable", "type": "bool", "internalType": "bool" }, + { "name": "price", "type": "uint256", "internalType": "uint256" }, + { "name": "proportion", "type": "uint256", "internalType": "uint256" }, + { "name": "attemptedBold", "type": "uint256", "internalType": "uint256" }, + { "name": "redeemedBold", "type": "uint256", "internalType": "uint256" }, + { "name": "iterations", "type": "uint256", "internalType": "uint256" }, + ], + }, { "name": "totalProportions", "type": "uint256", "internalType": "uint256" }], + "stateMutability": "nonpayable", +}, { + "type": "function", + "name": "truncateRedemption", + "inputs": [{ "name": "_bold", "type": "uint256", "internalType": "uint256" }, { + "name": "_maxIterationsPerCollateral", + "type": "uint256", + "internalType": "uint256", + }], + "outputs": [{ "name": "truncatedBold", "type": "uint256", "internalType": "uint256" }, { + "name": "feePct", + "type": "uint256", + "internalType": "uint256", + }, { + "name": "redeemed", + "type": "tuple[]", + "internalType": "struct IRedemptionHelper.Redeemed[]", + "components": [{ "name": "bold", "type": "uint256", "internalType": "uint256" }, { + "name": "coll", + "type": "uint256", + "internalType": "uint256", + }], + }], + "stateMutability": "nonpayable", +}] as const; diff --git a/frontend/app/src/abi/V1StabilityPool.ts b/frontend/app/src/abi/V1StabilityPool.ts new file mode 100644 index 00000000..b4f812f3 --- /dev/null +++ b/frontend/app/src/abi/V1StabilityPool.ts @@ -0,0 +1,542 @@ +export const V1StabilityPoolAbi = [{ + "anonymous": false, + "inputs": [{ "indexed": false, "internalType": "address", "name": "_newActivePoolAddress", "type": "address" }], + "name": "ActivePoolAddressChanged", + "type": "event", +}, { + "anonymous": false, + "inputs": [{ + "indexed": false, + "internalType": "address", + "name": "_newBorrowerOperationsAddress", + "type": "address", + }], + "name": "BorrowerOperationsAddressChanged", + "type": "event", +}, { + "anonymous": false, + "inputs": [{ + "indexed": false, + "internalType": "address", + "name": "_newCommunityIssuanceAddress", + "type": "address", + }], + "name": "CommunityIssuanceAddressChanged", + "type": "event", +}, { + "anonymous": false, + "inputs": [{ "indexed": false, "internalType": "address", "name": "_newDefaultPoolAddress", "type": "address" }], + "name": "DefaultPoolAddressChanged", + "type": "event", +}, { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "address", "name": "_depositor", "type": "address" }, + { "indexed": false, "internalType": "uint256", "name": "_P", "type": "uint256" }, + { "indexed": false, "internalType": "uint256", "name": "_S", "type": "uint256" }, + { "indexed": false, "internalType": "uint256", "name": "_G", "type": "uint256" }, + ], + "name": "DepositSnapshotUpdated", + "type": "event", +}, { + "anonymous": false, + "inputs": [{ "indexed": true, "internalType": "address", "name": "_depositor", "type": "address" }, { + "indexed": false, + "internalType": "uint256", + "name": "_ETH", + "type": "uint256", + }, { "indexed": false, "internalType": "uint256", "name": "_LUSDLoss", "type": "uint256" }], + "name": "ETHGainWithdrawn", + "type": "event", +}, { + "anonymous": false, + "inputs": [{ "indexed": false, "internalType": "uint128", "name": "_currentEpoch", "type": "uint128" }], + "name": "EpochUpdated", + "type": "event", +}, { + "anonymous": false, + "inputs": [{ "indexed": false, "internalType": "address", "name": "_to", "type": "address" }, { + "indexed": false, + "internalType": "uint256", + "name": "_amount", + "type": "uint256", + }], + "name": "EtherSent", + "type": "event", +}, { + "anonymous": false, + "inputs": [{ "indexed": true, "internalType": "address", "name": "_frontEnd", "type": "address" }, { + "indexed": false, + "internalType": "uint256", + "name": "_kickbackRate", + "type": "uint256", + }], + "name": "FrontEndRegistered", + "type": "event", +}, { + "anonymous": false, + "inputs": [{ "indexed": true, "internalType": "address", "name": "_frontEnd", "type": "address" }, { + "indexed": false, + "internalType": "uint256", + "name": "_P", + "type": "uint256", + }, { "indexed": false, "internalType": "uint256", "name": "_G", "type": "uint256" }], + "name": "FrontEndSnapshotUpdated", + "type": "event", +}, { + "anonymous": false, + "inputs": [{ "indexed": true, "internalType": "address", "name": "_frontEnd", "type": "address" }, { + "indexed": false, + "internalType": "uint256", + "name": "_newFrontEndStake", + "type": "uint256", + }, { "indexed": false, "internalType": "address", "name": "_depositor", "type": "address" }], + "name": "FrontEndStakeChanged", + "type": "event", +}, { + "anonymous": false, + "inputs": [{ "indexed": true, "internalType": "address", "name": "_depositor", "type": "address" }, { + "indexed": true, + "internalType": "address", + "name": "_frontEnd", + "type": "address", + }], + "name": "FrontEndTagSet", + "type": "event", +}, { + "anonymous": false, + "inputs": [{ "indexed": false, "internalType": "uint256", "name": "_G", "type": "uint256" }, { + "indexed": false, + "internalType": "uint128", + "name": "_epoch", + "type": "uint128", + }, { "indexed": false, "internalType": "uint128", "name": "_scale", "type": "uint128" }], + "name": "G_Updated", + "type": "event", +}, { + "anonymous": false, + "inputs": [{ "indexed": true, "internalType": "address", "name": "_depositor", "type": "address" }, { + "indexed": false, + "internalType": "uint256", + "name": "_LQTY", + "type": "uint256", + }], + "name": "LQTYPaidToDepositor", + "type": "event", +}, { + "anonymous": false, + "inputs": [{ "indexed": true, "internalType": "address", "name": "_frontEnd", "type": "address" }, { + "indexed": false, + "internalType": "uint256", + "name": "_LQTY", + "type": "uint256", + }], + "name": "LQTYPaidToFrontEnd", + "type": "event", +}, { + "anonymous": false, + "inputs": [{ "indexed": false, "internalType": "address", "name": "_newLUSDTokenAddress", "type": "address" }], + "name": "LUSDTokenAddressChanged", + "type": "event", +}, { + "anonymous": false, + "inputs": [{ "indexed": true, "internalType": "address", "name": "previousOwner", "type": "address" }, { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address", + }], + "name": "OwnershipTransferred", + "type": "event", +}, { + "anonymous": false, + "inputs": [{ "indexed": false, "internalType": "uint256", "name": "_P", "type": "uint256" }], + "name": "P_Updated", + "type": "event", +}, { + "anonymous": false, + "inputs": [{ "indexed": false, "internalType": "address", "name": "_newPriceFeedAddress", "type": "address" }], + "name": "PriceFeedAddressChanged", + "type": "event", +}, { + "anonymous": false, + "inputs": [{ "indexed": false, "internalType": "uint256", "name": "_S", "type": "uint256" }, { + "indexed": false, + "internalType": "uint128", + "name": "_epoch", + "type": "uint128", + }, { "indexed": false, "internalType": "uint128", "name": "_scale", "type": "uint128" }], + "name": "S_Updated", + "type": "event", +}, { + "anonymous": false, + "inputs": [{ "indexed": false, "internalType": "uint128", "name": "_currentScale", "type": "uint128" }], + "name": "ScaleUpdated", + "type": "event", +}, { + "anonymous": false, + "inputs": [{ "indexed": false, "internalType": "address", "name": "_newSortedTrovesAddress", "type": "address" }], + "name": "SortedTrovesAddressChanged", + "type": "event", +}, { + "anonymous": false, + "inputs": [{ "indexed": false, "internalType": "uint256", "name": "_newBalance", "type": "uint256" }], + "name": "StabilityPoolETHBalanceUpdated", + "type": "event", +}, { + "anonymous": false, + "inputs": [{ "indexed": false, "internalType": "uint256", "name": "_newBalance", "type": "uint256" }], + "name": "StabilityPoolLUSDBalanceUpdated", + "type": "event", +}, { + "anonymous": false, + "inputs": [{ "indexed": false, "internalType": "address", "name": "_newTroveManagerAddress", "type": "address" }], + "name": "TroveManagerAddressChanged", + "type": "event", +}, { + "anonymous": false, + "inputs": [{ "indexed": true, "internalType": "address", "name": "_depositor", "type": "address" }, { + "indexed": false, + "internalType": "uint256", + "name": "_newDeposit", + "type": "uint256", + }], + "name": "UserDepositChanged", + "type": "event", +}, { + "inputs": [], + "name": "BORROWING_FEE_FLOOR", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [], + "name": "CCR", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [], + "name": "DECIMAL_PRECISION", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [], + "name": "LUSD_GAS_COMPENSATION", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [], + "name": "MCR", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [], + "name": "MIN_NET_DEBT", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [], + "name": "NAME", + "outputs": [{ "internalType": "string", "name": "", "type": "string" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [], + "name": "P", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [], + "name": "PERCENT_DIVISOR", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [], + "name": "SCALE_FACTOR", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [], + "name": "_100pct", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [], + "name": "activePool", + "outputs": [{ "internalType": "contract IActivePool", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [], + "name": "borrowerOperations", + "outputs": [{ "internalType": "contract IBorrowerOperations", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [], + "name": "communityIssuance", + "outputs": [{ "internalType": "contract ICommunityIssuance", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [], + "name": "currentEpoch", + "outputs": [{ "internalType": "uint128", "name": "", "type": "uint128" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [], + "name": "currentScale", + "outputs": [{ "internalType": "uint128", "name": "", "type": "uint128" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [], + "name": "defaultPool", + "outputs": [{ "internalType": "contract IDefaultPool", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [{ "internalType": "address", "name": "", "type": "address" }], + "name": "depositSnapshots", + "outputs": [ + { "internalType": "uint256", "name": "S", "type": "uint256" }, + { "internalType": "uint256", "name": "P", "type": "uint256" }, + { "internalType": "uint256", "name": "G", "type": "uint256" }, + { "internalType": "uint128", "name": "scale", "type": "uint128" }, + { "internalType": "uint128", "name": "epoch", "type": "uint128" }, + ], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [{ "internalType": "address", "name": "", "type": "address" }], + "name": "deposits", + "outputs": [{ "internalType": "uint256", "name": "initialValue", "type": "uint256" }, { + "internalType": "address", + "name": "frontEndTag", + "type": "address", + }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [{ "internalType": "uint128", "name": "", "type": "uint128" }, { + "internalType": "uint128", + "name": "", + "type": "uint128", + }], + "name": "epochToScaleToG", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [{ "internalType": "uint128", "name": "", "type": "uint128" }, { + "internalType": "uint128", + "name": "", + "type": "uint128", + }], + "name": "epochToScaleToSum", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [{ "internalType": "address", "name": "", "type": "address" }], + "name": "frontEndSnapshots", + "outputs": [ + { "internalType": "uint256", "name": "S", "type": "uint256" }, + { "internalType": "uint256", "name": "P", "type": "uint256" }, + { "internalType": "uint256", "name": "G", "type": "uint256" }, + { "internalType": "uint128", "name": "scale", "type": "uint128" }, + { "internalType": "uint128", "name": "epoch", "type": "uint128" }, + ], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [{ "internalType": "address", "name": "", "type": "address" }], + "name": "frontEndStakes", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [{ "internalType": "address", "name": "", "type": "address" }], + "name": "frontEnds", + "outputs": [{ "internalType": "uint256", "name": "kickbackRate", "type": "uint256" }, { + "internalType": "bool", + "name": "registered", + "type": "bool", + }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [{ "internalType": "address", "name": "_frontEnd", "type": "address" }], + "name": "getCompoundedFrontEndStake", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [{ "internalType": "address", "name": "_depositor", "type": "address" }], + "name": "getCompoundedLUSDDeposit", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [{ "internalType": "address", "name": "_depositor", "type": "address" }], + "name": "getDepositorETHGain", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [{ "internalType": "address", "name": "_depositor", "type": "address" }], + "name": "getDepositorLQTYGain", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [], + "name": "getETH", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [], + "name": "getEntireSystemColl", + "outputs": [{ "internalType": "uint256", "name": "entireSystemColl", "type": "uint256" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [], + "name": "getEntireSystemDebt", + "outputs": [{ "internalType": "uint256", "name": "entireSystemDebt", "type": "uint256" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [{ "internalType": "address", "name": "_frontEnd", "type": "address" }], + "name": "getFrontEndLQTYGain", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [], + "name": "getTotalLUSDDeposits", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [], + "name": "isOwner", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [], + "name": "lastETHError_Offset", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [], + "name": "lastLQTYError", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [], + "name": "lastLUSDLossError_Offset", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [], + "name": "lusdToken", + "outputs": [{ "internalType": "contract ILUSDToken", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [{ "internalType": "uint256", "name": "_debtToOffset", "type": "uint256" }, { + "internalType": "uint256", + "name": "_collToAdd", + "type": "uint256", + }], + "name": "offset", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", +}, { + "inputs": [], + "name": "owner", + "outputs": [{ "internalType": "address", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [], + "name": "priceFeed", + "outputs": [{ "internalType": "contract IPriceFeed", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [{ "internalType": "uint256", "name": "_amount", "type": "uint256" }, { + "internalType": "address", + "name": "_frontEndTag", + "type": "address", + }], + "name": "provideToSP", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", +}, { + "inputs": [{ "internalType": "uint256", "name": "_kickbackRate", "type": "uint256" }], + "name": "registerFrontEnd", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", +}, { + "inputs": [ + { "internalType": "address", "name": "_borrowerOperationsAddress", "type": "address" }, + { "internalType": "address", "name": "_troveManagerAddress", "type": "address" }, + { "internalType": "address", "name": "_activePoolAddress", "type": "address" }, + { "internalType": "address", "name": "_lusdTokenAddress", "type": "address" }, + { "internalType": "address", "name": "_sortedTrovesAddress", "type": "address" }, + { "internalType": "address", "name": "_priceFeedAddress", "type": "address" }, + { "internalType": "address", "name": "_communityIssuanceAddress", "type": "address" }, + ], + "name": "setAddresses", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", +}, { + "inputs": [], + "name": "sortedTroves", + "outputs": [{ "internalType": "contract ISortedTroves", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [], + "name": "troveManager", + "outputs": [{ "internalType": "contract ITroveManager", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function", +}, { + "inputs": [{ "internalType": "address", "name": "_upperHint", "type": "address" }, { + "internalType": "address", + "name": "_lowerHint", + "type": "address", + }], + "name": "withdrawETHGainToTrove", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", +}, { + "inputs": [{ "internalType": "uint256", "name": "_amount", "type": "uint256" }], + "name": "withdrawFromSP", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", +}, { "stateMutability": "payable", "type": "receive" }] as const; diff --git a/frontend/app/src/app/earn/[pool]/[action]/page.tsx b/frontend/app/src/app/earn/[pool]/[action]/page.tsx index c1259841..48746de1 100644 --- a/frontend/app/src/app/earn/[pool]/[action]/page.tsx +++ b/frontend/app/src/app/earn/[pool]/[action]/page.tsx @@ -2,6 +2,7 @@ export function generateStaticParams() { return [ { action: "deposit" }, { action: "claim" }, + { action: "compound" }, ]; } diff --git a/frontend/app/src/comps/AppLayout/AppLayout.tsx b/frontend/app/src/comps/AppLayout/AppLayout.tsx index 60b93a69..7c9628a8 100644 --- a/frontend/app/src/comps/AppLayout/AppLayout.tsx +++ b/frontend/app/src/comps/AppLayout/AppLayout.tsx @@ -4,6 +4,9 @@ import type { ReactNode } from "react"; import { Banner } from "@/Banner"; import { ProtocolStats } from "./ProtocolStats"; +import { V1StabilityPoolBanner } from "@/src/comps/V1StabilityPoolBanner/V1StabilityPoolBanner"; +import { V1StakingBanner } from "@/src/comps/V1StakingBanner/V1StakingBanner"; +import { V1_STABILITY_POOL_CHECK, V1_STAKING_CHECK } from "@/src/env"; import { TopBar } from "./TopBar"; import { css } from "@/styled-system/css"; import { useSubgraphStatus } from "@/src/services/SubgraphStatus"; @@ -32,6 +35,8 @@ export function AppLayout({ children }: { children: ReactNode }) { maxWidth: `${LAYOUT_WIDTH + 24 * 2}px`, }} > + {V1_STAKING_CHECK && } + {V1_STABILITY_POOL_CHECK && }
)}
@@ -190,15 +193,18 @@ function OpenLink({ active, path, title, + external, }: { active: boolean; path: string; title: string; + external?: boolean; }) { return ( - {active + {external ? : active ? : } diff --git a/frontend/app/src/comps/EarnPositionSummary/SboldPositionSummary.tsx b/frontend/app/src/comps/EarnPositionSummary/SboldPositionSummary.tsx index bfb9b5d8..ea841ecb 100644 --- a/frontend/app/src/comps/EarnPositionSummary/SboldPositionSummary.tsx +++ b/frontend/app/src/comps/EarnPositionSummary/SboldPositionSummary.tsx @@ -70,7 +70,7 @@ export function SboldPositionSummary({ body: <>The annualized rate {WHITE_LABEL_CONFIG.tokens.otherTokens.staked.symbol} deposits earned over the last 24 hours., footerLink: { label: "Check Dune for more details", - href: "https://dune.com/liquity/liquity-v2", + href: "https://dune.com/capital_k3/sbold", }, }} /> @@ -102,7 +102,7 @@ export function SboldPositionSummary({ body: <>The annualized rate {WHITE_LABEL_CONFIG.tokens.otherTokens.staked.symbol} deposits earned over the last 7 days., footerLink: { label: "Check Dune for more details", - href: "https://dune.com/liquity/liquity-v2", + href: "https://dune.com/capital_k3/sbold", }, }} /> diff --git a/frontend/app/src/comps/EarnPositionSummary/YboldPositionSummary.tsx b/frontend/app/src/comps/EarnPositionSummary/YboldPositionSummary.tsx new file mode 100644 index 00000000..dcd2993c --- /dev/null +++ b/frontend/app/src/comps/EarnPositionSummary/YboldPositionSummary.tsx @@ -0,0 +1,90 @@ +import { Amount } from "@/src/comps/Amount/Amount"; +import { getTokenDisplayName, useLiquityStats } from "@/src/liquity-utils"; +import { css } from "@/styled-system/css"; +import { InfoTooltip, TokenIcon } from "@liquity2/uikit"; +import { EarnPositionSummaryBase } from "./EarnPositionSummaryBase"; +import { WHITE_LABEL_CONFIG } from "@/src/white-label.config"; + +export function YboldPositionSummary() { + const { data: rawStats } = useLiquityStats(); + + const stats = rawStats?.yBOLD; + + return ( + +
+
+ 7d APR +
+ + The annualized rate yBOLD deposits earned over the last 7 days., + footerLink: { + label: "Check Yearn for more details", + href: "https://yearn.fi/v3/1/0x9F4330700a36B29952869fac9b33f45EEdd8A3d8", + }, + }} + /> +
+ + } + subtitle={ + <> +
TVL
+
+ +
+ + Total amount of {WHITE_LABEL_CONFIG.tokens.mainToken.symbol} deposited in the yBOLD pool. + + + } + infoItems={[{ + label: `${WHITE_LABEL_CONFIG.tokens.mainToken.symbol} deposit`, + content: ( +
+ +
+ ), + }]} + /> + ); +} diff --git a/frontend/app/src/comps/Field/Field.tsx b/frontend/app/src/comps/Field/Field.tsx index 469555c8..c4500b87 100644 --- a/frontend/app/src/comps/Field/Field.tsx +++ b/frontend/app/src/comps/Field/Field.tsx @@ -2,8 +2,11 @@ import type { RiskLevel } from "@/src/types"; import type { Dnum } from "dnum"; import type { ReactNode } from "react"; +import { Amount } from "@/src/comps/Amount/Amount"; +import { Value } from "@/src/comps/Value/Value"; +import { LEVERAGE_PRICE_IMPACT_HIGH } from "@/src/constants"; import content from "@/src/content"; -import { jsonStringifyWithDnum } from "@/src/dnum-utils"; +import { DNUM_0, jsonStringifyWithDnum } from "@/src/dnum-utils"; import { fmtnum } from "@/src/formatting"; import { formatLiquidationRisk, formatRedemptionRisk } from "@/src/formatting"; import { infoTooltipProps, riskLevelToStatusMode } from "@/src/uikit-utils"; @@ -11,6 +14,7 @@ import { css } from "@/styled-system/css"; import { HFlex, InfoTooltip, StatusDot } from "@liquity2/uikit"; import * as dn from "dnum"; import { memo } from "react"; +import { WHITE_LABEL_CONFIG } from "@/src/white-label.config"; type FooterRow = { start?: ReactNode; @@ -168,7 +172,7 @@ function FooterInfoWarnLevel({ title, }: { label: ReactNode; - level?: "low" | "medium" | "high" | null; + level?: RiskLevel | null; help?: ReactNode; title?: string; }) { @@ -285,6 +289,7 @@ export const FooterInfoLoanToValue = memo( {fmtnum(higherThanMax ? maxLtvRatio : ltvRatio, { @@ -399,6 +404,104 @@ export const FooterInfoMaxLtv = memo( (prev, next) => jsonStringifyWithDnum(prev) === jsonStringifyWithDnum(next), ); +export const FooterInfoPriceImpact = memo( + function FooterInfoPriceImpact(props: { + inputTokenName: string; + outputTokenName: string; + priceImpact?: Dnum | null; + }) { + return ( + + + + + The difference between the current spot price and the price at which your {props.inputTokenName}{" "} + gets converted to{" "} + {props.outputTokenName}. Depends on the size of your position and the available liquidity. + + + + } + /> + ); + }, + (prev, next) => jsonStringifyWithDnum(prev) === jsonStringifyWithDnum(next), +); + +export function FooterInfoPriceImpactNone() { + return ( + + ); +} + +export const FooterInfoSlippageRefundClose = memo( + function FooterInfoSlippageRefundClose(props: { + collateralName: string; + slippageProtection: Dnum; + }) { + return ( + + + + To allow for slippage, slightly more of your {props.collateralName}{" "} + will be converted to {WHITE_LABEL_CONFIG.tokens.mainToken.symbol} than needed for the repayment. The remaining {WHITE_LABEL_CONFIG.tokens.mainToken.symbol} will be refunded to your + wallet. The actual amount may be higher or lower than indicated here, according to the execution price of + your trade. + + + } + /> + ); + }, + (prev, next) => jsonStringifyWithDnum(prev) === jsonStringifyWithDnum(next), +); + +export const FooterInfoSlippageRefundLeverUp = memo( + function FooterInfoSlippageRefundLeverUp(props: { + collateralName: string; + slippageProtection: Dnum | null; + }) { + // When leveraging on the ETH branch, the wallet receives WETH instead of raw ETH + const collateralName = props.collateralName === "ETH" ? "WETH" : props.collateralName; + + return ( + + + + To allow for slippage, slightly more {WHITE_LABEL_CONFIG.tokens.mainToken.symbol} will be converted to {collateralName}{" "} + than needed to reach your chosen exposure. The remaining {collateralName}{" "} + will be refunded to your wallet. The actual amount may be higher or lower than indicated here, according + to the execution price of your trade. + + + } + /> + ); + }, + (prev, next) => jsonStringifyWithDnum(prev) === jsonStringifyWithDnum(next), +); + +export function FooterInfoSlippageRefundNone() { + return ( + + ); +} + Field.FooterInfo = FooterInfo; Field.FooterInfoLiquidationPrice = FooterInfoLiquidationPrice; Field.FooterInfoLiquidationRisk = FooterInfoLiquidationRisk; @@ -408,3 +511,8 @@ Field.FooterInfoRiskLabel = FooterInfoRiskLabel; Field.FooterInfoWarnLevel = FooterInfoWarnLevel; Field.FooterInfoCollPrice = FooterInfoCollPrice; Field.FooterInfoMaxLtv = FooterInfoMaxLtv; +Field.FooterInfoPriceImpact = FooterInfoPriceImpact; +Field.FooterInfoPriceImpactNone = FooterInfoPriceImpactNone; +Field.FooterInfoSlippageRefundClose = FooterInfoSlippageRefundClose; +Field.FooterInfoSlippageRefundLeverUp = FooterInfoSlippageRefundLeverUp; +Field.FooterInfoSlippageRefundNone = FooterInfoSlippageRefundNone; diff --git a/frontend/app/src/comps/InfoBanner/InfoBanner.tsx b/frontend/app/src/comps/InfoBanner/InfoBanner.tsx new file mode 100644 index 00000000..6d50d6f1 --- /dev/null +++ b/frontend/app/src/comps/InfoBanner/InfoBanner.tsx @@ -0,0 +1,119 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { useBreakpoint } from "@/src/breakpoints"; +import { LinkTextButton } from "@/src/comps/LinkTextButton/LinkTextButton"; +import { css } from "@/styled-system/css"; +import { token } from "@/styled-system/tokens"; +import { IconChevronSmallUp } from "@liquity2/uikit"; +import { a, useTransition } from "@react-spring/web"; +import { useState } from "react"; + +export const LAYOUT_WIDTH = 1092; + +type InfoBannerProps = { + show: boolean; + icon: ReactNode; + messageDesktop: ReactNode; + linkLabel: string; + linkLabelMobile?: string; + linkHref: string; + linkExternal?: boolean; + backgroundColor?: string; +}; + +export function InfoBanner({ + show, + icon, + messageDesktop, + linkLabel, + linkLabelMobile, + linkHref, + linkExternal = false, + backgroundColor = token("colors.brandDarkBlue"), +}: InfoBannerProps) { + const [compact, setCompact] = useState(false); + useBreakpoint(({ medium }) => { + setCompact(!medium); + }); + + const showTransition = useTransition(show, { + from: { marginTop: -41 }, + enter: { marginTop: 0 }, + leave: { marginTop: -41 }, + config: { + mass: 1, + tension: 2000, + friction: 160, + }, + }); + + return showTransition((style, shouldShow) => ( + shouldShow && ( + +
+
+ {icon} +
+ {!compact && messageDesktop}{" "} + +
+ {compact && linkLabelMobile ? linkLabelMobile : linkLabel} +
+
+ +
+
+ } + className={css({ + color: "inherit!", + textDecoration: "underline", + })} + /> +
+
+ +
+ ) + )); +} diff --git a/frontend/app/src/comps/InterestRateField/DelegateBox.tsx b/frontend/app/src/comps/InterestRateField/DelegateBox.tsx index 69d41ba2..6b5993dd 100644 --- a/frontend/app/src/comps/InterestRateField/DelegateBox.tsx +++ b/frontend/app/src/comps/InterestRateField/DelegateBox.tsx @@ -2,12 +2,12 @@ import type { BranchId, Delegate } from "@/src/types"; import { Amount } from "@/src/comps/Amount/Amount"; import { WHITE_LABEL_CONFIG } from "@/src/white-label.config"; +import { LinkTextButton } from "@/src/comps/LinkTextButton/LinkTextButton"; import { fmtnum, formatDuration, formatRedemptionRisk } from "@/src/formatting"; -import { getRedemptionRisk } from "@/src/liquity-math"; -import { useDebtPositioning } from "@/src/liquity-utils"; +import { useRedemptionRiskOfInterestRate } from "@/src/liquity-utils"; import { riskLevelToStatusMode } from "@/src/uikit-utils"; import { css } from "@/styled-system/css"; -import { Button, IconCopy, StatusDot, TextButton } from "@liquity2/uikit"; +import { Button, IconCopy, IconExternal, StatusDot, TextButton } from "@liquity2/uikit"; import { MiniChart } from "./MiniChart"; import { ShadowBox } from "./ShadowBox"; @@ -16,14 +16,18 @@ export function DelegateBox({ delegate, onSelect, selectLabel = "Select", + url, }: { branchId: BranchId; delegate: Delegate; onSelect: (delegate: Delegate) => void; selectLabel: string; + url?: string; }) { - const debtPositioning = useDebtPositioning(branchId, delegate.interestRate); - const delegationRisk = getRedemptionRisk(debtPositioning.debtInFront, debtPositioning.totalDebt); + // TODO further improve risk calculation by getting the bottom Trove within the batch (if any) + // and using its risk level with `useRedemptionRiskOfLoan()` + const delegationRisk = useRedemptionRiskOfInterestRate(branchId, delegate.interestRate); + return (

- {delegate.name} + {url + ? ( + + + {delegate.name} + + + + } + /> + ) + : {delegate.name}}

- {fmtnum(delegate.interestRate, "pct1z")}% + {fmtnum(delegate.interestRate, "pct2z")}%
- - {formatRedemptionRisk(delegationRisk)} + + {formatRedemptionRisk(delegationRisk.data ?? null)}
@@ -189,6 +216,9 @@ export function DelegateBox({ className={css({ fontSize: 14, })} + onClick={() => { + navigator.clipboard.writeText(delegate.address); + }} />
diff --git a/frontend/app/src/comps/InterestRateField/DelegateModal.tsx b/frontend/app/src/comps/InterestRateField/DelegateModal.tsx index 8620b89d..e7016fb2 100644 --- a/frontend/app/src/comps/InterestRateField/DelegateModal.tsx +++ b/frontend/app/src/comps/InterestRateField/DelegateModal.tsx @@ -2,18 +2,16 @@ import type { Address, BranchId, Delegate } from "@/src/types"; import { LinkTextButton } from "@/src/comps/LinkTextButton/LinkTextButton"; import content from "@/src/content"; -import { useInterestBatchDelegate } from "@/src/liquity-utils"; +import { useKnownDelegates } from "@/src/liquity-delegate"; +import { getBranch, useInterestBatchDelegate, useInterestBatchDelegates } from "@/src/liquity-utils"; import { css } from "@/styled-system/css"; import { AddressField, Modal } from "@liquity2/uikit"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { DelegateBox } from "./DelegateBox"; const URL_WHAT_IS_DELEGATION = "https://docs.liquity.org/v2-faq/redemptions-and-delegation#what-is-delegation-of-interest-rates"; -const DELEGATES_LIST_URL = - "https://docs.liquity.org/v2-faq/redemptions-and-delegation#docs-internal-guid-441d8c3f-7fff-4efa-6319-4ba00d908597"; - export function DelegateModal({ branchId, onClose, @@ -28,8 +26,76 @@ export function DelegateModal({ const [delegateAddress, setDelegateAddress] = useState(null); const [delegateAddressValue, setDelegateAddressValue] = useState(""); + const knownDelegatesQuery = useKnownDelegates(); + const delegate = useInterestBatchDelegate(branchId, delegateAddress); + const filteredStrategies = useMemo(() => { + if (!knownDelegatesQuery || knownDelegatesQuery.status === "pending" || !knownDelegatesQuery.data) return []; + const branch = getBranch(branchId); + const branchSymbol = branch.symbol; + const strategies: Array<{ groupName: string; strategy: any }> = []; + knownDelegatesQuery.data.forEach((group) => { + group.strategies.forEach((strategy) => { + if ( + strategy.branches.some((branch) => branch.toLowerCase() === branchSymbol.toLowerCase()) && + !strategy.hide + ) { + strategies.push({ + groupName: group.name, + strategy, + }); + } + }); + }); + return strategies; + }, [branchId, knownDelegatesQuery?.data, knownDelegatesQuery?.status]); + + const searchFilteredStrategies = useMemo(() => { + if (!delegateAddressValue.trim()) { + return filteredStrategies; + } + + const searchTerm = delegateAddressValue.toLowerCase(); + return filteredStrategies.filter(({ groupName, strategy }) => { + const displayName = strategy.name ? groupName + " - " + strategy.name : groupName; + return displayName.toLowerCase().includes(searchTerm) + || strategy.address.toLowerCase().includes(searchTerm); + }); + }, [filteredStrategies, delegateAddressValue]); + + const isSearchingAddress = useMemo(() => { + const term = delegateAddressValue.trim(); + return term.startsWith("0x") && term.length >= 10; + }, [delegateAddressValue]); + + const isCustomDelegate = useMemo(() => { + if (!isSearchingAddress || !delegateAddress || !delegate.data) return false; + return !searchFilteredStrategies.some(({ strategy }) => + strategy.address.toLowerCase() === delegateAddress.toLowerCase() + ); + }, [isSearchingAddress, delegateAddress, delegate.data, searchFilteredStrategies]); + + const isKnownDelegateForOtherCollateral = useMemo(() => { + if (!isSearchingAddress || !delegateAddress || !knownDelegatesQuery?.data) return false; + + const addressExists = knownDelegatesQuery.data.some((group) => + group.strategies.some((strategy) => strategy.address.toLowerCase() === delegateAddress.toLowerCase()) + ); + + const existsForCurrentCollateral = searchFilteredStrategies.some(({ strategy }) => + strategy.address.toLowerCase() === delegateAddress.toLowerCase() + ); + + return addressExists && !existsForCurrentCollateral; + }, [isSearchingAddress, delegateAddress, knownDelegatesQuery?.data, searchFilteredStrategies]); + + const delegateAddresses = useMemo(() => { + return searchFilteredStrategies.map(({ strategy }) => strategy.address as Address); + }, [searchFilteredStrategies]); + + const delegatesQuery = useInterestBatchDelegates(branchId, delegateAddresses); + return ( -
-
- {delegateAddress - ? ( -
+ +
+ Available Delegates +
+ +
+ {isCustomDelegate && delegate.data && ( +
+
+ Custom Delegate +
+ +
+ )} + + {isSearchingAddress && delegateAddress && !isCustomDelegate && ( +
{delegate.status === "pending" ? ( -
- Loading… +
+ Loading custom delegate...
) : delegate.status === "error" ? ( -
- Error: {delegate.error?.name} +
+ Invalid delegate address
) - : ( - delegate.data - ? ( - - ) - : ( -
- The address is not a valid{" "} - . -
- ) - )} + : null}
- ) - : ( - <> -
- Set a valid{" "} - {" "} - address. -
+ )} -
- Delegate addresses can be found{" "} - . -
- + {searchFilteredStrategies.length > 0 && isCustomDelegate && delegate.data && ( +
+ From Delegates List +
)} + + {knownDelegatesQuery?.status === "pending" + ? ( +
+ Loading delegates... +
+ ) + : knownDelegatesQuery?.status === "error" + ? ( +
+ Error loading delegates +
+ ) + : searchFilteredStrategies.length === 0 && delegateAddressValue.trim() && !isCustomDelegate + ? ( +
+ {isKnownDelegateForOtherCollateral + ? `This delegate (${delegateAddressValue}) does not support your Trove collateral. Choose a delegate for your collateral (${ + getBranch(branchId).symbol + }).` + : `No delegates found matching "${delegateAddressValue}"`} +
+ ) + : ( + searchFilteredStrategies.map(({ groupName, strategy }, index) => { + const delegateAddress = strategy.address as Address; + const delegateData = delegatesQuery.data?.find((d) => + d.address.toLowerCase() === delegateAddress.toLowerCase() + ); + + const displayName = strategy.name ? groupName + " - " + strategy.name : groupName; + + return ( +
+ {delegatesQuery.status === "pending" + ? ( +
+ Loading… +
+ ) + : delegatesQuery.status === "error" + ? ( +
+ Error: {delegatesQuery.error?.name} +
+ ) + : delegateData + ? ( + group.name === groupName)?.url} + /> + ) + : ( +
+ No data available for {displayName} +
+ )} +
+ ); + }) + )} +
+
+
+
+ +
); diff --git a/frontend/app/src/comps/InterestRateField/IcStrategiesModal.tsx b/frontend/app/src/comps/InterestRateField/IcStrategiesModal.tsx deleted file mode 100644 index 2ad8ed29..00000000 --- a/frontend/app/src/comps/InterestRateField/IcStrategiesModal.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import type { BranchId, Delegate } from "@/src/types"; - -import content from "@/src/content"; -import { getBranch, useInterestBatchDelegates } from "@/src/liquity-utils"; -import { css } from "@/styled-system/css"; -import { Modal, TextButton } from "@liquity2/uikit"; -import Image from "next/image"; -import { useState } from "react"; -import { DelegateBox } from "./DelegateBox"; -import { ShadowBox } from "./ShadowBox"; - -import icLogo from "./ic-logo.svg"; - -export function IcStrategiesModal({ - branchId, - onClose, - onSelectDelegate, - visible, -}: { - branchId: BranchId; - onClose: () => void; - onSelectDelegate: (delegate: Delegate) => void; - visible: boolean; -}) { - const [displayedDelegates, setDisplayedDelegates] = useState(5); - - const branch = getBranch(branchId); - const delegates = useInterestBatchDelegates(branchId, branch.strategies.map((s) => s.address)); - const icpDelegates = delegates.data?.map((delegate, index) => ({ - ...delegate, - name: branch.strategies[index]?.name ?? delegate.address, - })); - - return ( - -
{content.interestRateField.icStrategyModal.title}
- -
- } - visible={visible} - > -
- {content.interestRateField.icStrategyModal.intro} -
-
- {icpDelegates?.slice(0, displayedDelegates).map((delegate) => { - return ( - - ); - })} - {displayedDelegates < branch.strategies.length && ( - - setDisplayedDelegates(displayedDelegates + 5)} - className={css({ - width: "100%", - padding: "24px 0", - justifyContent: "center", - })} - /> - - )} -
- - ); -} diff --git a/frontend/app/src/comps/InterestRateField/InterestRateField.tsx b/frontend/app/src/comps/InterestRateField/InterestRateField.tsx index bbf00552..6dab00ba 100644 --- a/frontend/app/src/comps/InterestRateField/InterestRateField.tsx +++ b/frontend/app/src/comps/InterestRateField/InterestRateField.tsx @@ -3,13 +3,22 @@ import type { Dnum } from "dnum"; import { useAppear } from "@/src/anim-utils"; import { useBreakpointName } from "@/src/breakpoints"; -import { INTEREST_RATE_START, REDEMPTION_RISK } from "@/src/constants"; +import { INTEREST_RATE_MAX, INTEREST_RATE_START, REDEMPTION_RISK } from "@/src/constants"; import content from "@/src/content"; import { WHITE_LABEL_CONFIG } from "@/src/white-label.config"; import { DNUM_0, jsonStringifyWithDnum } from "@/src/dnum-utils"; import { useInputFieldValue } from "@/src/form-utils"; import { fmtnum } from "@/src/formatting"; -import { findClosestRateIndex, getBranch, useAverageInterestRate, useInterestRateChartData } from "@/src/liquity-utils"; +import { useDelegateDisplayName } from "@/src/liquity-delegate"; +import { getRedemptionRisk } from "@/src/liquity-math"; +import { + EMPTY_LOAN, + findClosestRateIndex, + useAverageInterestRate, + useDebtInFrontOfInterestRate, + useDebtInFrontOfLoan, + useInterestRateChartData, +} from "@/src/liquity-utils"; import { infoTooltipProps } from "@/src/uikit-utils"; import { noop } from "@/src/utils"; import { css } from "@/styled-system/css"; @@ -21,15 +30,11 @@ import Image from "next/image"; import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { match } from "ts-pattern"; import { DelegateModal } from "./DelegateModal"; -import { IcStrategiesModal } from "./IcStrategiesModal"; import { MiniChart } from "./MiniChart"; -import icLogo from "./ic-logo.svg"; - const DELEGATE_MODES = [ "manual", "delegate", - "strategy", ] as const; export type DelegateMode = typeof DELEGATE_MODES[number]; @@ -58,16 +63,19 @@ export const InterestRateField = memo( inputId?: string; interestRate: Dnum | null; mode: DelegateMode; - onAverageInterestRateLoad?: (averageInterestRate: Dnum) => void; + // XXX why is average interest rate loaded inside this component and not the parent? + onAverageInterestRateLoad?: (averageInterestRate: Dnum, setValue: (value: string) => void) => void; onChange: (interestRate: Dnum) => void; onDelegateChange: (delegate: Address | null) => void; onModeChange?: (mode: DelegateMode) => void; loan?: PositionLoanCommitted; }) { const [delegatePicker, setDelegatePicker] = useState< - "strategy" | "delegate" | null + "delegate" | null >(null); + const delegateDisplayName = useDelegateDisplayName(delegate); + const autoInputId = useId(); const inputId = inputIdFromProps ?? autoInputId; @@ -88,7 +96,7 @@ export const InterestRateField = memo( rateTouchedForBranch.current = branchId; setTimeout(() => { if (averageInterestRate.data && !cancelled) { - onAverageInterestRateLoad(averageInterestRate.data); + onAverageInterestRateLoad(averageInterestRate.data, fieldValue.setValue); } }, 0); return () => { @@ -103,25 +111,27 @@ export const InterestRateField = memo( useEffect(() => { setDelegatePicker(null); - onDelegateChange(null); - onModeChange("manual"); + if (!delegate) { + onDelegateChange(null); + onModeChange("manual"); + } }, [ branchId, + delegate, onDelegateChange, onModeChange, ]); const fieldValue = useInputFieldValue((value) => `${fmtnum(value)}%`, { + defaultValue: interestRate ? dn.toString(dn.mul(interestRate, 100)) : undefined, + onFocusChange: ({ parsed, focused }) => { if (!focused && parsed) { - const rounded = dn.div(dn.round(dn.mul(parsed, 10)), 10); - fieldValue.setValue( - rounded[0] === 0n - ? String(INTEREST_RATE_START * 100) - : dn.toString(rounded), - ); + if (dn.lt(parsed, INTEREST_RATE_START * 100)) fieldValue.setValue(String(INTEREST_RATE_START * 100)); + if (dn.gt(parsed, INTEREST_RATE_MAX * 100)) fieldValue.setValue(String(INTEREST_RATE_MAX * 100)); } }, + onChange: ({ parsed }) => { if (parsed) { rateTouchedForBranch.current = branchId; @@ -131,25 +141,35 @@ export const InterestRateField = memo( }); const interestChartData = useInterestRateChartData(branchId, loan); - const interestRateRounded = interestRate && dn.div(dn.round(dn.mul(interestRate, 1000)), 1000); - - const bracket = interestRateRounded && interestChartData.data?.find( - ({ rate }) => rate[0] === interestRateRounded[0], - ); - - const redeemableTransition = useAppear(bracket?.debtInFront !== undefined); + const debtInFrontOfLoan = useDebtInFrontOfLoan(loan ?? EMPTY_LOAN); + const debtInFrontOfInterestRate = useDebtInFrontOfInterestRate(branchId, interestRate ?? DNUM_0, loan); + + // When a loan exists already and the selected interest rate is the same as the existing interest rate + // (for example as in the initial state after navigating to the interest rate panel) + // show the current precise debt-in-front of the loan. + // This is useful for checking how far the loan is from redemption (beyond just checking the risk level). + // If the loan is not redeemable, e.g. because it has been fully redeemed, we revert to debt-in-front + // based on interest rate (i.e. the debt that would be in front of the position if it were to be made + // active again at its current interest rate). + const debtInFront = loan && interestRate && dn.eq(loan.interestRate, interestRate) + ? debtInFrontOfLoan.data && ( + debtInFrontOfLoan.data.debtInFront + ? debtInFrontOfLoan.data // redeemable (debtInFront not null) + : debtInFrontOfInterestRate.data // not redeemable (debtInFront is null) + ) + : debtInFrontOfInterestRate.data; + + const redemptionRisk = debtInFront + && getRedemptionRisk(debtInFront.debtInFront, debtInFront.totalDebt); + const redeemableTransition = useAppear(debtInFront !== undefined); const handleDelegateSelect = (delegate: Delegate) => { setDelegatePicker(null); - rateTouchedForBranch.current = branchId; - onChange(delegate.interestRate); + fieldValue.setValue(dn.toString(dn.mul(delegate.interestRate, 100))); onDelegateChange(delegate.address ?? null); }; - const branch = getBranch(branchId); - - const hasStrategies = branch.strategies.length > 0; - const activeDelegateModes = DELEGATE_MODES.filter((mode) => mode !== "strategy" || hasStrategies); + const activeDelegateModes = DELEGATE_MODES; const boldInterestPerYear = interestRate && debt && dn.mul(interestRate, debt); @@ -168,38 +188,13 @@ export const InterestRateField = memo( interestChartData={interestChartData} interestRate={interestRate} fieldValue={fieldValue} - /> - )) - .with("strategy", () => ( - - - {shortenAddress(delegate, 4).toLowerCase()} -
- ) - : "Choose strategy"} - onClick={() => { - setDelegatePicker("strategy"); - }} + handleColor={redemptionRisk && ( + redemptionRisk === "high" + ? 0 + : redemptionRisk === "medium" + ? 1 + : 2 + )} /> )) .with("delegate", () => ( @@ -227,7 +222,12 @@ export const InterestRateField = memo( borderRadius: 4, })} /> - {shortenAddress(delegate, 4).toLowerCase()} + {(() => { + const displayName = delegateDisplayName || shortenAddress(delegate, 4).toLowerCase(); + return breakpoint === "small" && displayName.length > 16 + ? displayName.substring(0, 16) + "..." + : displayName; + })()}
) : "Choose delegate"} @@ -253,21 +253,21 @@ export const InterestRateField = memo( size="small" title={`Set average interest rate (${ fmtnum(averageInterestRate.data, { - preset: "pct1z", + preset: "pct2z", suffix: "%", }) })`} label={`(avg. ${ fmtnum(averageInterestRate.data, { - preset: "pct1z", + preset: "pct2z", suffix: "%", }) })`} onClick={(event) => { if (averageInterestRate.data) { event.preventDefault(); - rateTouchedForBranch.current = branchId; - onChange(averageInterestRate.data); + const rounded = dn.div(dn.round(dn.mul(averageInterestRate.data, 1e4)), 1e4); + fieldValue.setValue(dn.toString(dn.mul(rounded, 100))); } }} /> @@ -329,7 +329,7 @@ export const InterestRateField = memo( {(mode === "manual" || delegate !== null) - ? fmtnum(bracket?.debtInFront, "compact") + ? fmtnum(debtInFront?.debtInFront, "compact") : "−"} {breakpoint === "large" && {` ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol}`}} @@ -385,7 +385,7 @@ export const InterestRateField = memo( > {(mode === "manual" || delegate !== null) && fmtnum( interestRate, - "pct1z", + "pct2z", )} - %{breakpoint === "large" ? " per year" : ""} + % ) @@ -412,14 +412,6 @@ export const InterestRateField = memo( onSelectDelegate={handleDelegateSelect} visible={delegatePicker === "delegate"} /> - { - setDelegatePicker(null); - }} - onSelectDelegate={handleDelegateSelect} - visible={delegatePicker === "strategy"} - /> ); }, @@ -430,9 +422,10 @@ export const InterestRateField = memo( function ManualInterestRateSlider({ fieldValue, + handleColor, interestChartData, interestRate, -}: { +}: Pick[0], "handleColor"> & { fieldValue: ReturnType; interestChartData: ReturnType; interestRate: Dnum | null; @@ -518,7 +511,7 @@ function ManualInterestRateSlider({ display: "flex", alignItems: "center", justifyContent: "center", - width: breakpoint === "large" ? 260 : 200, + width: breakpoint === "small" ? 200 : 260, paddingTop: 16, ...style, }} @@ -526,17 +519,12 @@ function ManualInterestRateSlider({ size) ?? []} onChange={(value) => { if (interestChartData.data) { - const index = Math.min( - interestChartData.data.length - 1, - Math.round(value * (interestChartData.data.length)), - ); - fieldValue.setValue(String(dn.toNumber(dn.mul( - interestChartData.data[index]?.rate ?? DNUM_0, - 100, - )))); + const index = Math.round(value * (interestChartData.data.length - 1)); + fieldValue.setValue(dn.toString(dn.mul(interestChartData.data[index]?.rate ?? DNUM_0, 100))); } }} value={value} diff --git a/frontend/app/src/comps/LeverageField/LeverageField.tsx b/frontend/app/src/comps/LeverageField/LeverageField.tsx index d3d51792..ebe634e5 100644 --- a/frontend/app/src/comps/LeverageField/LeverageField.tsx +++ b/frontend/app/src/comps/LeverageField/LeverageField.tsx @@ -1,89 +1,78 @@ -import type { CollateralToken } from "@liquity2/uikit"; +import type { CollateralToken, Drawer } from "@liquity2/uikit"; import type { Dnum } from "dnum"; import type { ComponentPropsWithoutRef } from "react"; -import { LEVERAGE_FACTOR_MIN, LEVERAGE_FACTOR_SUGGESTIONS, LTV_RISK, MAX_LTV_ALLOWED_RATIO } from "@/src/constants"; +import { useBreakpointName } from "@/src/breakpoints"; +import { + LEVERAGE_FACTOR_MIN, + LEVERAGE_FACTOR_PRECISION, + LEVERAGE_SLIPPAGE_TOLERANCE, + LTV_RISK, + MAX_LTV_ALLOWED_RATIO, + MIN_DEBT, +} from "@/src/constants"; import content from "@/src/content"; import { WHITE_LABEL_CONFIG } from "@/src/white-label.config"; -import { useInputFieldValue } from "@/src/form-utils"; +import { DNUM_0, DNUM_1, dnumNeg } from "@/src/dnum-utils"; +import { type InputFieldUpdateData, useInputFieldValue } from "@/src/form-utils"; import { fmtnum } from "@/src/formatting"; +import { useQuoteExactInput, useQuoteExactOutput } from "@/src/liquity-leverage"; import { getLeverageFactorFromLiquidationPrice, getLeverageFactorFromLtv, + getLiquidationPrice, getLiquidationPriceFromLeverage, getLiquidationRisk, + getLtv, getLtvFromLeverageFactor, + roundLeverageFactor, } from "@/src/liquity-math"; import { infoTooltipProps } from "@/src/uikit-utils"; -import { roundToDecimal } from "@/src/utils"; import { css } from "@/styled-system/css"; import { HFlex, InfoTooltip, InputField, lerp, norm, Slider } from "@liquity2/uikit"; import * as dn from "dnum"; -import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; export function LeverageField({ collPrice, collToken, debt, - deposit, drawer, - highRiskLeverageFactor, - inputId: inputIdFromProps, + inputId, leverageFactor, - liquidationPriceField, + liquidationPriceInputFieldProps, liquidationRisk, - maxLeverageFactorAllowed, - mediumRiskLeverageFactor, - onDrawerClose, sliderProps, }: ReturnType & { disabled?: boolean; drawer?: ComponentPropsWithoutRef["drawer"]; - inputId?: string; - onDrawerClose?: ComponentPropsWithoutRef["onDrawerClose"]; + inputId: string; }) { - const autoInputId = useId(); - const inputId = inputIdFromProps ?? autoInputId; - - const isDepositNegative = !deposit || dn.lt(deposit, 0); + const breakpoint = useBreakpointName(); + console.log("breakpoint", breakpoint); return ( - + } label={{ + start: content.leverageScreen.liquidationPriceField.label, end: (
- Total debt {!debt || isDepositNegative ? "−" : ( + Debt {!debt ? "−" : ( <> ), - start: content.leverageScreen.liquidationPriceField.label, }} placeholder="0.00" secondary={{ @@ -131,213 +119,186 @@ export function LeverageField({ ), }} - {...liquidationPriceField.inputFieldProps} - valueUnfocused={isDepositNegative - ? ( - - N/A - - ) - : liquidationPriceField.inputFieldProps.value} + {...liquidationPriceInputFieldProps} /> ); } +function formatLiquidationPrice(value: Dnum) { + return fmtnum(value, { dust: false, prefix: "$ ", preset: "2z" }); +} + export function useLeverageField({ collPrice, collToken, - depositPreLeverage, + positionDeposit, + positionDebt, maxLtvAllowedRatio = MAX_LTV_ALLOWED_RATIO, - onFocusChange, + defaultLeverageFactorAdjustment = 0, }: { - collPrice: Dnum; + collPrice: Dnum | null; collToken: CollateralToken; - depositPreLeverage: Dnum | null; + positionDeposit: Dnum | null; + positionDebt: Dnum; maxLtvAllowedRatio?: number; - onFocusChange?: (focused: boolean) => void; + defaultLeverageFactorAdjustment?: number; }) { - const isFocused = useRef(false); - const { collateralRatio } = collToken; const maxLtv = dn.from(1 / collateralRatio, 18); - const maxLeverageFactor = getLeverageFactorFromLtv(maxLtv); - const maxLtvAllowed = dn.mul(maxLtv, maxLtvAllowedRatio); const maxLeverageFactorAllowed = getLeverageFactorFromLtv(maxLtvAllowed); + const netDeposit = positionDeposit && collPrice && dn.sub(positionDeposit, dn.div(positionDebt, collPrice)); - if (!LEVERAGE_FACTOR_SUGGESTIONS[0]) { - throw new Error("LEVERAGE_FACTOR_SUGGESTIONS must have at least one suggestion set"); - } + const leverageFactorBeforeAdjustment = dn.toNumber( + positionDeposit && netDeposit && !dn.eq(netDeposit, DNUM_0) + ? dn.div(positionDeposit, netDeposit) + : DNUM_1, + ); - const [leverageFactor, setLeverageFactor] = useState( - getLeverageFactorFromRatio( - LEVERAGE_FACTOR_MIN, - maxLeverageFactor, - LEVERAGE_FACTOR_SUGGESTIONS[0], - ), + const clampLeverageFactor = useCallback( + (leverageFactor: number) => Math.min(Math.max(leverageFactor, LEVERAGE_FACTOR_MIN), maxLeverageFactorAllowed), + [maxLeverageFactorAllowed], ); - const ltv = getLtvFromLeverageFactor(leverageFactor); - const liquidationRisk = ltv && getLiquidationRisk(ltv, maxLtv); + const [leverageFactorAdjustment, setLeverageFactorAdjustment] = useState(defaultLeverageFactorAdjustment); + const leverageFactor = clampLeverageFactor(leverageFactorBeforeAdjustment + leverageFactorAdjustment); + const leverageFactorChange = leverageFactor - leverageFactorBeforeAdjustment; + const depositChange = netDeposit && dn.mul(netDeposit, leverageFactorChange); + const idealDebtChange = depositChange && dn.mul(depositChange, collPrice); + const slippageProtection = depositChange && dn.mul(depositChange, LEVERAGE_SLIPPAGE_TOLERANCE); + const deposit = depositChange && dn.add(positionDeposit, depositChange); - const mediumRiskLeverageFactor = getLeverageFactorFromLtv(dn.mul(maxLtv, LTV_RISK.medium)); - const highRiskLeverageFactor = getLeverageFactorFromLtv(dn.mul(maxLtv, LTV_RISK.high)); + const quoteLeverUp = useQuoteExactOutput({ + inputToken: WHITE_LABEL_CONFIG.tokens.mainToken.symbol, + outputToken: collToken.symbol, + outputAmount: leverageFactorChange > 0 && slippageProtection ? dn.add(depositChange, slippageProtection) : DNUM_0, + }); - // liquidation prices based on the min and max leverage factors - const liquidationPriceBoundaries = [ - getLiquidationPriceFromLeverage(LEVERAGE_FACTOR_MIN, collPrice, collateralRatio), - getLiquidationPriceFromLeverage(maxLeverageFactor, collPrice, collateralRatio), - ] as const; + const quoteLeverDown = useQuoteExactInput({ + inputToken: collToken.symbol, + outputToken: WHITE_LABEL_CONFIG.tokens.mainToken.symbol, + inputAmount: leverageFactorChange < 0 && depositChange ? dn.abs(depositChange) : DNUM_0, + }); - const deposit = depositPreLeverage && leverageFactor > 1 - ? dn.mul(depositPreLeverage, leverageFactor) - : null; + const quoteData = leverageFactorChange === 0 ? null : ( + leverageFactorChange > 0 + ? quoteLeverUp + : quoteLeverDown + ).data; - const debt = depositPreLeverage && calculateDebt( - depositPreLeverage, - leverageFactor, - collPrice, + // undefined: loading or not applicable (no leverage change) + // null: loaded, but the quote failed due to lack of liquidity + const quoteAmount = leverageFactorChange === 0 ? undefined : ( + leverageFactorChange > 0 + ? quoteLeverUp.data?.inputAmount + : quoteLeverDown.data?.outputAmount ); - const getLeverageFactorFromLiquidationPriceClamped = (liquidationPrice: Dnum) => { - const leverageFactor = getLeverageFactorFromLiquidationPrice( - liquidationPrice, - collPrice, - collateralRatio, - ); - - if (dn.lt(liquidationPrice, liquidationPriceBoundaries[0])) { - return LEVERAGE_FACTOR_MIN; - } - - if (dn.gt(liquidationPrice, liquidationPriceBoundaries[1])) { - return maxLeverageFactorAllowed; - } + const actualDebtChange = leverageFactorChange === 0 ? DNUM_0 : ( + !quoteData?.bouncing && ( + leverageFactorChange > 0 + ? quoteAmount + : quoteAmount && dnumNeg(quoteAmount) + ) + || null + ); - return leverageFactor; - }; + const priceImpact = quoteData?.priceImpact ?? null; + const debtChange = actualDebtChange ?? idealDebtChange; + const debt = debtChange && dn.add(positionDebt, debtChange); + const ltv = (deposit && debt && getLtv(deposit, debt, collPrice)) ?? getLtvFromLeverageFactor(leverageFactor); + const liquidationRisk = ltv && getLiquidationRisk(ltv, maxLtv); + const liquidationPrice = (deposit && debt && getLiquidationPrice(deposit, debt, collateralRatio)) + ?? (collPrice && getLiquidationPriceFromLeverage(leverageFactor, collPrice, collateralRatio)); - const leverageFactorSuggestions = useMemo(() => { - return LEVERAGE_FACTOR_SUGGESTIONS.map((factor) => ( - getLeverageFactorFromRatio(LEVERAGE_FACTOR_MIN, maxLeverageFactor, factor) - )); - }, [maxLeverageFactor]); + const setLeverageFactor = useCallback( + (leverageFactor: number) => { + setLeverageFactorAdjustment(roundLeverageFactor(leverageFactor - leverageFactorBeforeAdjustment)); + }, + [leverageFactorBeforeAdjustment], + ); - const liquidationPriceField = useInputFieldValue( - (value) => fmtnum(value, { dust: false, prefix: "$ ", preset: "2z" }), + const { + setValue: setLiquidationPriceInputValue, + inputFieldProps: liquidationPriceInputFieldProps, + isFocused: liquidationPriceFocused, + } = useInputFieldValue( + formatLiquidationPrice, { - onChange: ({ parsed: liquidationPrice, focused }) => { - if (liquidationPrice && dn.gt(liquidationPrice, 0) && liquidationPriceField.isFocused && focused) { - const lf = getLeverageFactorFromLiquidationPriceClamped(liquidationPrice); - if (lf !== null) { - setLeverageFactor(lf); - } - } - }, - onFocusChange: ({ focused, parsed: price }) => { - isFocused.current = focused; - onFocusChange?.(focused); - - // Make sure the input value corresponds to the leverage - // factor matching to the desired liquidation price. - if (!focused && price) { - const lf = getLeverageFactorFromLiquidationPriceClamped(price); - if (lf !== null) { - setLeverageFactor(lf); - } + onChange: useCallback(({ focused, parsed }: InputFieldUpdateData) => { + if (focused && parsed && collPrice) { + setLeverageFactor( + getLeverageFactorFromLiquidationPrice(parsed, collPrice, collateralRatio) + ?? maxLeverageFactorAllowed, + ); } - }, + }, [collPrice, collateralRatio, maxLeverageFactorAllowed, setLeverageFactor]), }, ); - const updateLeverageFactor = useCallback((leverageFactor: number) => { - setLeverageFactor(leverageFactor); - if (deposit && debt) { - liquidationPriceField.setValue(dn.toString( - getLiquidationPriceFromLeverage(leverageFactor, collPrice, collateralRatio), - 2, - )); - } - }, [ - collPrice, - collateralRatio, - liquidationPriceField, - ]); + const liquidationPriceInputValue = liquidationPrice && dn.toString(liquidationPrice, 2); - // update the leverage factor when the collateral price changes - const previousCollPrice = useRef(collPrice); useEffect(() => { - if (!dn.eq(previousCollPrice.current, collPrice) && deposit && debt) { - liquidationPriceField.setValue(dn.toString( - getLiquidationPriceFromLeverage(leverageFactor, collPrice, collateralRatio), - 2, - )); - previousCollPrice.current = collPrice; + if (!liquidationPriceFocused && liquidationPriceInputValue !== null) { + setLiquidationPriceInputValue(liquidationPriceInputValue); } - }, [ - collPrice, - collateralRatio, - getLeverageFactorFromLiquidationPriceClamped, - leverageFactor, - liquidationPriceField, - updateLeverageFactor, - ]); + }, [liquidationPriceFocused, liquidationPriceInputValue, setLiquidationPriceInputValue]); - const sliderProps = { - onChange: (value: number) => { - updateLeverageFactor( - roundToDecimal( - lerp(LEVERAGE_FACTOR_MIN, maxLeverageFactorAllowed, value), - 1, - ), - ); - }, - value: norm( - leverageFactor, - LEVERAGE_FACTOR_MIN, - maxLeverageFactorAllowed, - ), - }; + const sliderValue = norm(leverageFactor, LEVERAGE_FACTOR_MIN, maxLeverageFactorAllowed); + const keyboardStepSize = LEVERAGE_FACTOR_PRECISION / (maxLeverageFactorAllowed - LEVERAGE_FACTOR_MIN); - function calculateTotalPositionValue(deposit: Dnum, leverageFactor: number, collateralPrice: Dnum): Dnum { - return dn.mul(dn.mul(deposit, dn.from(leverageFactor, 18)), collateralPrice); - } + const sliderGradient = useMemo((): [number, number] => [ + norm(getLeverageFactorFromLtv(dn.mul(maxLtv, LTV_RISK.medium)), LEVERAGE_FACTOR_MIN, maxLeverageFactorAllowed), + norm(getLeverageFactorFromLtv(dn.mul(maxLtv, LTV_RISK.high)), LEVERAGE_FACTOR_MIN, maxLeverageFactorAllowed), + ], [maxLtv, maxLeverageFactorAllowed]); - function calculateDebt(deposit: Dnum, leverageFactor: number, collateralPrice: Dnum): Dnum { - const totalPositionValue = calculateTotalPositionValue(deposit, leverageFactor, collateralPrice); - const initialDepositValue = dn.mul(deposit, collateralPrice); - return dn.sub(totalPositionValue, initialDepositValue); - } + const onSliderChange = useCallback((value: number) => { + setLeverageFactor(lerp(LEVERAGE_FACTOR_MIN, maxLeverageFactorAllowed, value)); + }, [maxLeverageFactorAllowed, setLeverageFactor]); + + const keyboardStep = useCallback( + (value: number, direction: -1 | 1) => value + direction * keyboardStepSize, + [keyboardStepSize], + ); + + const sliderProps = useMemo(() => ({ + value: sliderValue, + gradient: sliderGradient, + onChange: onSliderChange, + keyboardStep, + }), [sliderValue, sliderGradient, onSliderChange, keyboardStep]); + + const drawer: Drawer | null = deposit && dn.gt(deposit, DNUM_0) && debt && dn.lt(debt, MIN_DEBT) + ? { mode: "error", message: `Debt must be at least ${fmtnum(MIN_DEBT, 2)} ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol}.` } + : quoteAmount === null + ? { mode: "error", message: `Not enough ${collToken.name} liquidity to reach your chosen exposure.` } + : null; + + const isValid = !drawer + && collPrice + && ( + quoteData === null + || quoteData?.bouncing === false + ); return { collPrice, collToken, debt, + debtChange, deposit, - highRiskLeverageFactor, + depositChange, + drawer, + isValid, leverageFactor, - leverageFactorSuggestions, - liquidationPriceField, + leverageFactorChange, + liquidationPriceInputFieldProps, liquidationRisk, ltv, - maxLeverageFactor, - maxLeverageFactorAllowed, maxLtv, - maxLtvAllowed, - mediumRiskLeverageFactor, + priceImpact, sliderProps, - updateLeverageFactor, + slippageProtection, }; } - -function getLeverageFactorFromRatio(minLeverageFactor: number, maxLeverageFactor: number, ratio: number) { - return Math.max( - LEVERAGE_FACTOR_MIN, - Math.round(lerp(minLeverageFactor, maxLeverageFactor, ratio) * 10) / 10, - ); -} diff --git a/frontend/app/src/comps/Positions/NewPositionCard.tsx b/frontend/app/src/comps/Positions/NewPositionCard.tsx index 1ff7cf92..1c756b2c 100644 --- a/frontend/app/src/comps/Positions/NewPositionCard.tsx +++ b/frontend/app/src/comps/Positions/NewPositionCard.tsx @@ -20,16 +20,16 @@ const actions = { path: "/borrow", title: "Borrow", }, - // multiply: { - // colors: { - // background: token("colors.brandGreen"), - // foreground: token("colors.brandGreenContent"), - // foregroundAlt: token("colors.brandGreenContentAlt"), - // }, - // description: contentActions.multiply.description, - // path: "/multiply", - // title: "Multiply", - // }, + multiply: { + colors: { + background: token("colors.brandGreen"), + foreground: token("colors.brandGreenContent"), + foregroundAlt: token("colors.brandGreenContentAlt"), + }, + description: contentActions.multiply.description, + path: "/multiply", + title: "Multiply", + }, earn: { colors: { background: token("colors.brandBlue"), diff --git a/frontend/app/src/comps/Positions/PositionCardBorrow.tsx b/frontend/app/src/comps/Positions/PositionCardBorrow.tsx index 5a8b30fd..613deb3c 100644 --- a/frontend/app/src/comps/Positions/PositionCardBorrow.tsx +++ b/frontend/app/src/comps/Positions/PositionCardBorrow.tsx @@ -1,60 +1,66 @@ import type { PositionLoanCommitted } from "@/src/types"; -import type { Dnum } from "dnum"; +import * as dn from "dnum"; import type { ReactNode } from "react"; import { Amount } from "@/src/comps/Amount/Amount"; -import { formatLiquidationRisk } from "@/src/formatting"; +import { CrossedText } from "@/src/comps/CrossedText/CrossedText"; +import { DNUM_0 } from "@/src/dnum-utils"; import { fmtnum } from "@/src/formatting"; -import { getLiquidationRisk, getLtv, getRedemptionRisk } from "@/src/liquity-math"; -import { getCollToken, shortenTroveId, useDebtPositioning } from "@/src/liquity-utils"; +import { getLiquidationRisk, getLtv } from "@/src/liquity-math"; +import { getCollToken, shortenTroveId, useRedemptionRiskOfLoan } from "@/src/liquity-utils"; import { usePrice } from "@/src/services/Prices"; -import { riskLevelToStatusMode } from "@/src/uikit-utils"; import { css } from "@/styled-system/css"; -import { HFlex, IconBorrow, StatusDot, TokenIcon } from "@liquity2/uikit"; -import * as dn from "dnum"; +import { HFlex, IconBorrow, TokenIcon } from "@liquity2/uikit"; import { PositionCard } from "./PositionCard"; -import { CardRow, CardRows } from "./shared"; import { WHITE_LABEL_CONFIG } from "@/src/white-label.config"; +import { PositionCardSecondaryContent } from "./PositionCardSecondaryContent"; export function PositionCardBorrow({ batchManager, - debt, + borrowed, branchId, deposit, interestRate, - liquidated = false, + isZombie, + status, statusTag, troveId, + liquidatedColl, + liquidatedDebt, + collSurplus, + priceAtLiquidation, + collSurplusOnChain, }: & Pick< PositionLoanCommitted, | "batchManager" + | "borrowed" | "branchId" + | "deposit" | "interestRate" + | "isZombie" + | "status" | "troveId" + | "liquidatedColl" + | "liquidatedDebt" + | "collSurplus" + | "priceAtLiquidation" > - & { - debt: null | Dnum; - deposit: null | Dnum; - liquidated?: boolean; - statusTag?: ReactNode; - }) + & { statusTag?: ReactNode; collSurplusOnChain: dn.Dnum | null }) { const token = getCollToken(branchId); const collateralPriceUsd = usePrice(token?.symbol ?? null); - const ltv = debt && deposit && collateralPriceUsd.data - && getLtv(deposit, debt, collateralPriceUsd.data); - const debtPositioning = useDebtPositioning(branchId, interestRate); - const redemptionRisk = getRedemptionRisk(debtPositioning.debtInFront, debtPositioning.totalDebt); + const ltv = collateralPriceUsd.data && getLtv(deposit, borrowed, collateralPriceUsd.data); + const redemptionRisk = useRedemptionRiskOfLoan({ branchId, troveId, interestRate, status, isZombie }); - const maxLtv = token && dn.from(1 / token.collateralRatio, 18); - const liquidationRisk = ltv && maxLtv && getLiquidationRisk(ltv, maxLtv); + const maxLtv = dn.from(1 / token.collateralRatio, 18); + const liquidationRisk = ltv && getLiquidationRisk(ltv, maxLtv); const title = token ? [ `Loan ID: ${shortenTroveId(troveId)}…`, - `Debt: ${fmtnum(debt, "full")} ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol}`, + `Debt: ${fmtnum(borrowed, "full")} ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol}`, `Collateral: ${fmtnum(deposit, "full")} ${token.name}`, `Interest rate: ${fmtnum(interestRate, "pctfull")}%`, ] @@ -88,181 +94,62 @@ export function PositionCardBorrow({
} main={{ - value: ( - + value: status === "liquidated" + ? ( + + + + + + + ) + : ( + + + + + ), + label: status === "liquidated" + ? ( +
+ Was backed by {liquidatedColl ? fmtnum(liquidatedColl) : "−"} {token.name} + +
+ ) + : (
- + Backed by {!dn.eq(deposit, DNUM_0) ? fmtnum(deposit) : "−"} {token.name} +
- -
- ), - // label: "Total debt", - label: token && ( -
- Backed by {deposit ? fmtnum(deposit) : "−"} {token.name} - -
- ), + ), }} secondary={ - - -
- LTV -
- {liquidated - ? "N/A" - : ltv - ? ( -
- {fmtnum(ltv, "pct2")}% -
- ) - : "−"} - - } - end={!liquidated && ( -
-
- {liquidationRisk && formatLiquidationRisk(liquidationRisk)} -
- -
- )} - /> - -
- {batchManager ? "Int. rate" : "Interest rate"} -
-
- {liquidated - ? "N/A" - : fmtnum(interestRate, { preset: "pct2", suffix: "%" })} -
- {batchManager && ( -
- D -
- )} - - } - end={!liquidated && ( -
- { -
- {redemptionRisk === "low" - ? "Low" - : redemptionRisk === "medium" - ? "Medium" - : "High"} redemption risk -
- } - -
- )} - /> -
+ } /> ); diff --git a/frontend/app/src/comps/Positions/PositionCardLeverage.tsx b/frontend/app/src/comps/Positions/PositionCardLeverage.tsx index df0e35cd..b1b66769 100644 --- a/frontend/app/src/comps/Positions/PositionCardLeverage.tsx +++ b/frontend/app/src/comps/Positions/PositionCardLeverage.tsx @@ -1,40 +1,54 @@ import type { PositionLoanCommitted } from "@/src/types"; -import type { Dnum } from "dnum"; +import * as dn from "dnum"; import type { ReactNode } from "react"; -import { formatRedemptionRisk } from "@/src/formatting"; +import { Amount } from "@/src/comps/Amount/Amount"; +import { CrossedText } from "@/src/comps/CrossedText/CrossedText"; +import { Value } from "@/src/comps/Value/Value"; +import { DNUM_0 } from "@/src/dnum-utils"; import { fmtnum } from "@/src/formatting"; -import { getLiquidationRisk, getLtv, getRedemptionRisk } from "@/src/liquity-math"; -import { getCollToken, useDebtPositioning } from "@/src/liquity-utils"; +import { getLoanDetails } from "@/src/liquity-math"; +import { getCollToken, useRedemptionRiskOfLoan } from "@/src/liquity-utils"; import { usePrice } from "@/src/services/Prices"; -import { riskLevelToStatusMode } from "@/src/uikit-utils"; +import { roundToDecimal } from "@/src/utils"; import { css } from "@/styled-system/css"; -import { HFlex, IconLeverage, StatusDot, TokenIcon } from "@liquity2/uikit"; -import * as dn from "dnum"; +import { HFlex, IconLeverage, TokenIcon } from "@liquity2/uikit"; import { PositionCard } from "./PositionCard"; -import { CardRow, CardRows } from "./shared"; +import { PositionCardSecondaryContent } from "./PositionCardSecondaryContent"; +import { WHITE_LABEL_CONFIG } from "@/src/white-label.config"; export function PositionCardLeverage({ - debt, + batchManager, + borrowed, branchId, deposit, interestRate, - liquidated = false, + status, statusTag, troveId, + isZombie, + liquidatedColl, + liquidatedDebt, + collSurplus, + priceAtLiquidation, + collSurplusOnChain, }: & Pick< PositionLoanCommitted, + | "batchManager" + | "borrowed" | "branchId" + | "deposit" | "interestRate" + | "isZombie" + | "status" | "troveId" + | "liquidatedColl" + | "liquidatedDebt" + | "collSurplus" + | "priceAtLiquidation" > - & { - debt: null | Dnum; - deposit: null | Dnum; - liquidated?: boolean; - statusTag?: ReactNode; - }) + & { statusTag?: ReactNode; collSurplusOnChain: dn.Dnum | null }) { const token = getCollToken(branchId); if (!token) { @@ -42,17 +56,19 @@ export function PositionCardLeverage({ } const collateralPriceUsd = usePrice(token.symbol); + const redemptionRisk = useRedemptionRiskOfLoan({ branchId, troveId, interestRate, status, isZombie }); - const maxLtv = dn.from(1 / token.collateralRatio, 18); - const ltv = debt && deposit && collateralPriceUsd.data - && getLtv(deposit, debt, collateralPriceUsd.data); - const liquidationRisk = ltv && getLiquidationRisk(ltv, maxLtv); - const debtPositioning = useDebtPositioning(branchId, interestRate); - const redemptionRisk = getRedemptionRisk(debtPositioning.debtInFront, debtPositioning.totalDebt); + const { ltv, liquidationRisk, ...loanDetails } = getLoanDetails( + deposit, + borrowed, + interestRate, + token.collateralRatio, + collateralPriceUsd.data ?? null, + ); return ( } main={{ - value: ( - - {deposit ? fmtnum(deposit, 2) : "−"} - - - ), - label: "Net value", - }} - secondary={ - - -
- LTV -
- {liquidated - ? "N/A" - : ltv - ? ( -
- {fmtnum(ltv, "pct2")}% -
- ) - : "−"} - - } - end={!liquidated && ( -
- {liquidationRisk && ( -
+ + + + + + ) + : ( + + + + + + + + {loanDetails.leverageFactor !== null && ( +
+ - {liquidationRisk === "low" ? "Low" : liquidationRisk === "medium" ? "Medium" : "High"}{" "} - liquidation risk -
- )} - -
- )} - /> - -
- Interest rate -
-
- {liquidated - ? "N/A" - : fmtnum(interestRate, "pct2")}% + {roundToDecimal(loanDetails.leverageFactor, 1)}x +
-
- } - end={!liquidated && ( -
-
- {formatRedemptionRisk(redemptionRisk)} -
- -
- )} - /> -
+ )} + + ), + label: status === "liquidated" + ? ( + <> + Was backed by {liquidatedColl ? fmtnum(liquidatedColl) : "−"} {token.name} + + ) + : <>Exposure {!dn.eq(deposit, DNUM_0) ? fmtnum(deposit) : "−"} {token.name}, + }} + secondary={ + } /> ); diff --git a/frontend/app/src/comps/Positions/PositionCardLoan.tsx b/frontend/app/src/comps/Positions/PositionCardLoan.tsx index c8237854..69db5977 100644 --- a/frontend/app/src/comps/Positions/PositionCardLoan.tsx +++ b/frontend/app/src/comps/Positions/PositionCardLoan.tsx @@ -19,31 +19,30 @@ export function PositionCardLoan( | "interestRate" | "status" | "troveId" - | "indexedDebt" - >, + | "recordedDebt" + | "isZombie" + | "liquidatedColl" + | "liquidatedDebt" + | "collSurplus" + | "priceAtLiquidation" + > & { + collSurplusOnChain: dn.Dnum | null; + }, ) { const storedState = useStoredState(); const prefixedTroveId = getPrefixedTroveId(props.branchId, props.troveId); const loanMode = storedState.loanModes[prefixedTroveId] ?? props.type; - const Card = loanMode === "multiply" ? PositionCardLeverage : PositionCardBorrow; return ( : props.status === "redeemed" ? ( diff --git a/frontend/app/src/comps/Positions/PositionCardSecondaryContent.tsx b/frontend/app/src/comps/Positions/PositionCardSecondaryContent.tsx new file mode 100644 index 00000000..0d20a2ec --- /dev/null +++ b/frontend/app/src/comps/Positions/PositionCardSecondaryContent.tsx @@ -0,0 +1,242 @@ +import type { Dnum, RiskLevel, TroveStatus } from "@/src/types"; +import type { CollateralToken } from "@liquity2/uikit"; +import type { ReactNode } from "react"; + +import { LoanStatusTag } from "@/src/comps/Tag/LoanStatusTag"; +import { fmtnum, formatLiquidationRisk, formatRedemptionRisk } from "@/src/formatting"; +import { riskLevelToStatusMode } from "@/src/uikit-utils"; +import { css } from "@/styled-system/css"; +import { HFlex, StatusDot } from "@liquity2/uikit"; +import * as dn from "dnum"; +import { CardRow, CardRows } from "./shared"; + +type PositionCardSecondaryContentProps = { + status: TroveStatus; + collSurplus: Dnum | null; + collSurplusOnChain: Dnum | null; + liquidatedColl: Dnum | null; + liquidatedDebt: Dnum | null; + priceAtLiquidation: Dnum | null; + token: CollateralToken; + ltv: Dnum | null | undefined; + liquidationRisk: RiskLevel | null | undefined; + interestRate: Dnum; + batchManager: string | null; + redemptionRisk: { + status: string; + data: RiskLevel | null | undefined; + }; +}; + +export function PositionCardSecondaryContent({ + status, + collSurplus, + collSurplusOnChain, + priceAtLiquidation, + token, + ltv, + liquidationRisk, + interestRate, + batchManager, + redemptionRisk, +}: PositionCardSecondaryContentProps): ReactNode { + if (status === "liquidated") { + const collateralWasClaimed = collSurplus && collSurplusOnChain + && dn.gt(collSurplus, 0) + && dn.eq(collSurplusOnChain, 0); + + return ( + + +
+ Remaining coll. +
+
+ {fmtnum(collSurplus) || "−"} {token.name} +
+ {collateralWasClaimed !== null && ( + + )} + + } + /> + +
+ Liquidation price +
+
+ {priceAtLiquidation ? `$${fmtnum(priceAtLiquidation)}` : "−"} +
+ + } + /> +
+ ); + } + + return ( + + +
+ LTV +
+ {ltv + ? ( +
+ {fmtnum(ltv, "pct2")}% +
+ ) + : "−"} + + } + end={ +
+
+ {formatLiquidationRisk(liquidationRisk ?? "not-applicable")} +
+ +
+ } + /> + +
+ {batchManager ? "Int. rate" : "Interest rate"} +
+
+ {fmtnum(interestRate, { preset: "pct2", suffix: "%" })} +
+ {batchManager && ( +
+ D +
+ )} + + } + end={ +
+ { +
+ {formatRedemptionRisk(redemptionRisk.data ?? null)} +
+ } + +
+ } + /> +
+ ); +} diff --git a/frontend/app/src/comps/Positions/Positions.tsx b/frontend/app/src/comps/Positions/Positions.tsx index 3a98789a..1ecd9dfb 100644 --- a/frontend/app/src/comps/Positions/Positions.tsx +++ b/frontend/app/src/comps/Positions/Positions.tsx @@ -1,17 +1,20 @@ -import type { Address, BranchId, Position, PositionLoanUncommitted } from "@/src/types"; +import type { Address, BranchId, Position, PositionLoanCommitted, PositionLoanUncommitted } from "@/src/types"; +import type { Dnum } from "dnum"; import type { ReactNode } from "react"; import { useBreakpointName } from "@/src/breakpoints"; import { ActionCard } from "@/src/comps/ActionCard/ActionCard"; import content from "@/src/content"; import { useWhiteLabelHeader } from "@/src/hooks/useWhiteLabel"; -import { useEarnPositionsByAccount, useEarnPools, useLoansByAccount, useStakePosition } from "@/src/liquity-utils"; +import { getBranches, useEarnPositionsByAccount, useCollateralSurplusByBranches, useEarnPools, useLoansByAccount, useStakePosition } from "@/src/liquity-utils"; import { useSboldPosition } from "@/src/sbold"; +import { isPositionLoan } from "@/src/types"; import { css } from "@/styled-system/css"; +import { IconChevronSmallUp } from "@liquity2/uikit"; import { a, useSpring, useTransition } from "@react-spring/web"; import { HFlex } from "@liquity2/uikit"; import * as dn from "dnum"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { match, P } from "ts-pattern"; import { NewPositionCard } from "./NewPositionCard"; import { PositionCard } from "./PositionCard"; @@ -23,7 +26,7 @@ import { SortButton, type SortField } from "./SortButton"; type Mode = "positions" | "loading" | "actions"; -// Move actionCards inside component to make it dynamic +const branchIds = getBranches().map((b) => b.branchId); export function Positions({ address, @@ -58,6 +61,17 @@ export function Positions({ const stakePosition = useStakePosition(headerConfig.navigation.showStake ? address : null); const [sortBy, setSortBy] = useState("default"); + const collSurplusQueries = useCollateralSurplusByBranches(address, branchIds); + + const collSurplusMap = useMemo(() => { + if (!collSurplusQueries.data) return null; + + const map = new Map(); + for (const item of collSurplusQueries.data) { + map.set(item.branchId, item.surplus); + } + return map; + }, [collSurplusQueries.data]); const isPositionsPending = Boolean( address && ( @@ -65,6 +79,7 @@ export function Positions({ || earnPositions.isPending || sboldPosition.isPending || (headerConfig.navigation.showStake && stakePosition.isPending) + || !collSurplusMap ), ); @@ -218,6 +233,7 @@ export function Positions({ actionCards={actionCards} sortBy={sortBy} setSortBy={setSortBy} + collSurplusMap={collSurplusMap} /> ); } @@ -231,6 +247,7 @@ function PositionsGroup({ actionCards, sortBy, setSortBy, + collSurplusMap, }: { columns?: number; mode: Mode; @@ -240,6 +257,7 @@ function PositionsGroup({ actionCards: readonly ("borrow" | "earn" | "stake" | "multiply")[]; sortBy: SortField; setSortBy: (sortBy: SortField) => void; + collSurplusMap: Map | null; }) { columns ??= (mode === "actions" || mode === "loading") ? actionCards.length : 3; @@ -257,6 +275,51 @@ function PositionsGroup({ setSortBy(`${field}-desc` as SortField); } }; + const [isLiquidatedExpanded, setIsLiquidatedExpanded] = useState(false); + + const toggleLiquidatedExpanded = useCallback(() => { + setIsLiquidatedExpanded(!isLiquidatedExpanded); + }, [isLiquidatedExpanded]); + + const { activePositions, liquidatedPositions } = useMemo(() => { + const active: Position[] = []; + const liquidated: PositionLoanCommitted[] = []; + + for (const position of positions) { + if (isPositionLoan(position) && position.status === "liquidated") { + liquidated.push(position); + } else { + active.push(position); + } + } + + return { activePositions: active, liquidatedPositions: liquidated }; + }, [positions]); + + const { liquidatedWithClaimable, liquidatedWithoutClaimable } = useMemo(() => { + const withClaimable: PositionLoanCommitted[] = []; + const withoutClaimable: PositionLoanCommitted[] = []; + + if (!collSurplusMap) { + return { liquidatedWithClaimable: withClaimable, liquidatedWithoutClaimable: withoutClaimable }; + } + + for (const position of liquidatedPositions) { + const surplus = collSurplusMap.get(position.branchId); + if (surplus && dn.gt(surplus, 0)) { + withClaimable.push(position); + } else { + withoutClaimable.push(position); + } + } + + return { liquidatedWithClaimable: withClaimable, liquidatedWithoutClaimable: withoutClaimable }; + }, [liquidatedPositions, collSurplusMap]); + + const topLevelPositions = useMemo( + () => [...activePositions, ...liquidatedWithClaimable], + [activePositions, liquidatedWithClaimable], + ); const cards = match(mode) .returnType>() @@ -264,17 +327,26 @@ function PositionsGroup({ let cards: Array<[number, ReactNode]> = []; if (showNewPositionCard) { - cards.push([positions.length ?? -1, ]); + cards.push([topLevelPositions.length ?? -1, ]); } cards = cards.concat( - positions.map((position, index) => ( + topLevelPositions.map((position, index) => ( match(position) .returnType<[number, ReactNode]>() - .with({ type: P.union("borrow", "multiply") }, (p) => [ - index, - , - ]) + .with({ type: P.union("borrow", "multiply") }, (p) => { + if (p.troveId !== null) { + return [ + index, + , + ]; + } + return [index, null]; + }) .with({ type: "earn" }, (p) => [ index, , @@ -308,14 +380,24 @@ function PositionsGroup({ )) .exhaustive(); + const liquidatedCards = liquidatedWithoutClaimable.map((position, index) => { + return [ + index, + , + ] as [number, ReactNode]; + }); + const breakpoint = useBreakpointName(); const cardHeight = mode === "actions" ? 144 : 180; const rows = Math.ceil(cards.length / columns); const containerHeight = cardHeight * rows + (breakpoint === "small" ? 16 : 24) * (rows - 1); - const positionTransitions = useTransition(cards, { - keys: ([index]) => `${mode}${index}`, + const TRANSITION_CONFIG = { from: { display: "none", opacity: 0, @@ -337,7 +419,19 @@ function PositionsGroup({ tension: 1600, friction: 120, }, - }); + }; + + function usePositionCardTransitions( + cards: Array<[number, ReactNode]>, + keyPrefix: string, + ) { + return useTransition(cards, { + keys: ([index]) => `${keyPrefix}${index}`, + ...TRANSITION_CONFIG, + }); + } + + const positionTransitions = usePositionCardTransitions(cards, mode); const animateHeight = useRef(false); if (mode === "loading") { @@ -356,6 +450,12 @@ function PositionsGroup({ }, }); + const liquidatedRows = Math.ceil(liquidatedCards.length / columns); + const liquidatedContainerHeight = 180 * liquidatedRows + + (breakpoint === "small" ? 16 : 24) * (liquidatedRows - 1); + + const liquidatedTransitions = usePositionCardTransitions(liquidatedCards, "liquidated"); + return (
{title_ && ( @@ -492,6 +592,135 @@ function PositionsGroup({ ))} + {liquidatedCards.length > 0 && ( +
+
+ + +
+ {isLiquidatedExpanded && ( + <> + + + {liquidatedTransitions((style, [_, card]) => ( + + {card} + + ))} + + + + )} +
+ )}
); } diff --git a/frontend/app/src/comps/Tag/LoanStatusTag.tsx b/frontend/app/src/comps/Tag/LoanStatusTag.tsx index 40892f5e..dee2f6f3 100644 --- a/frontend/app/src/comps/Tag/LoanStatusTag.tsx +++ b/frontend/app/src/comps/Tag/LoanStatusTag.tsx @@ -3,31 +3,50 @@ import { css } from "@/styled-system/css"; export function LoanStatusTag({ status, + size = "normal", }: { - status: "liquidated" | "partially-redeemed" | "fully-redeemed"; + status: "liquidated" | "partially-redeemed" | "fully-redeemed" | "unclaimed" | "claimed"; + size?: "normal" | "small"; }) { return (
); diff --git a/frontend/app/src/comps/TagConfirmed/TagConfirmed.tsx b/frontend/app/src/comps/TagConfirmed/TagConfirmed.tsx new file mode 100644 index 00000000..a32b6ec7 --- /dev/null +++ b/frontend/app/src/comps/TagConfirmed/TagConfirmed.tsx @@ -0,0 +1,24 @@ +import { css } from "@/styled-system/css"; +import { IconCheckmark } from "@liquity2/uikit"; + +export function TagConfirmed() { + return ( +
+ Confirmed + +
+ ); +} diff --git a/frontend/app/src/comps/V1StabilityPoolBanner/V1StabilityPoolBanner.tsx b/frontend/app/src/comps/V1StabilityPoolBanner/V1StabilityPoolBanner.tsx new file mode 100644 index 00000000..f49f75dc --- /dev/null +++ b/frontend/app/src/comps/V1StabilityPoolBanner/V1StabilityPoolBanner.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { InfoBanner } from "@/src/comps/InfoBanner/InfoBanner"; +import { fmtnum } from "@/src/formatting"; +import { useV1StabilityPoolLqtyGain } from "@/src/liquity-utils"; +import { useAccount } from "@/src/wagmi-utils"; +import { IconInfo } from "@liquity2/uikit"; +import * as dn from "dnum"; + +const MIN_LQTY_THRESHOLD = dn.from(1000, 18); // 1000 LQTY + +export function V1StabilityPoolBanner() { + const account = useAccount(); + const v1LqtyGain = useV1StabilityPoolLqtyGain(account.address ?? null); + + const hasSignificantLqtyGain = Boolean( + account.address + && v1LqtyGain.data + && dn.gt(v1LqtyGain.data, MIN_LQTY_THRESHOLD), + ); + + const lqtyAmount = v1LqtyGain.data; + const formattedAmount = lqtyAmount ? fmtnum(lqtyAmount, { digits: 0 }) : "0"; + + return ( + } + messageDesktop={<>You have {formattedAmount} unclaimed LQTY in the V1 Stability Pool.} + linkLabel="Claim and stake in V2" + linkLabelMobile={`${formattedAmount} LQTY unclaimed in v1 - Claim & stake`} + linkHref="https://docs.liquity.org/v2-faq/lqty-staking" + linkExternal + /> + ); +} diff --git a/frontend/app/src/comps/V1StakingBanner/V1StakingBanner.tsx b/frontend/app/src/comps/V1StakingBanner/V1StakingBanner.tsx new file mode 100644 index 00000000..26e13939 --- /dev/null +++ b/frontend/app/src/comps/V1StakingBanner/V1StakingBanner.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { InfoBanner } from "@/src/comps/InfoBanner/InfoBanner"; +import { fmtnum } from "@/src/formatting"; +import { useStakePosition } from "@/src/liquity-utils"; +import { useAccount } from "@/src/wagmi-utils"; +import { IconInfo } from "@liquity2/uikit"; +import * as dn from "dnum"; + +export function V1StakingBanner() { + const account = useAccount(); + const stakePositionV1 = useStakePosition(account.address ?? null, "v1"); + + const hasV1Stake = Boolean( + account.address + && stakePositionV1.data + && dn.gt(stakePositionV1.data.deposit, 0), + ); + + const v1Amount = stakePositionV1.data?.deposit; + const formattedAmount = v1Amount ? fmtnum(v1Amount) : "0"; + + return ( + } + messageDesktop={<>You have {formattedAmount} LQTY staked in V1.} + linkLabel="Migrate to V2 to accrue voting power and earn bribes" + linkLabelMobile={`${formattedAmount} LQTY in V1 - Migrate to V2`} + linkHref="https://docs.liquity.org/v2-faq/lqty-staking" + linkExternal + /> + ); +} diff --git a/frontend/app/src/comps/VoteInput/VoteInput.tsx b/frontend/app/src/comps/VoteInput/VoteInput.tsx deleted file mode 100644 index 665b52ae..00000000 --- a/frontend/app/src/comps/VoteInput/VoteInput.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import type { Dnum } from "@/src/types"; -import type { RefObject } from "react"; - -import { dnumMax, dnumMin } from "@/src/dnum-utils"; -import { parseInputFloat } from "@/src/form-utils"; -import { css } from "@/styled-system/css"; -import { IconDownvote, IconUpvote } from "@liquity2/uikit"; -import { a, useTransition } from "@react-spring/web"; -import * as dn from "dnum"; -import { useEffect, useRef, useState } from "react"; - -type Vote = "for" | "against"; - -export function VoteInput({ - againstDisabled, - forDisabled, - onChange, - onVote, - ref: forwardedRef, - value, - vote, -}: { - againstDisabled?: boolean; - forDisabled?: boolean; - onChange: (value: Dnum) => void; - onVote: (vote: "for" | "against") => void; - value: Dnum | null; - vote: "for" | "against" | null; - ref?: RefObject; -}) { - const [isFocused, setIsFocused] = useState(false); - - const [inputValue, setInputValue] = useState( - value ? dn.toString(dn.mul(value, 100)) : "", - ); - - const inputRef = useRef(null); - - const displayValue = (() => { - // not selected => empty - if (!vote) { - return ""; - } - // focused => show input value - if (isFocused) { - return inputValue; - } - // no value => show 0% - if (!value) { - return "0%"; - } - // normal display - return `${dn.toString(dn.mul(value, 100))}%`; - })(); - - useEffect(() => { - if (!vote) { - setInputValue(""); - } else if (!isFocused) { - setInputValue(value ? dn.toString(dn.mul(value, 100)) : "0"); - } - }, [vote, value?.[0], isFocused]); - - const prevVote = useRef(vote); - useEffect(() => { - if (vote && !prevVote.current) { - inputRef.current?.focus(); - } - prevVote.current = vote; - }, [vote]); - - return ( -
- onVote("for")} - selected={vote === "for"} - vote="for" - /> - onVote("against")} - selected={vote === "against"} - vote="against" - /> - { - inputRef.current = elt; - if (forwardedRef) { - forwardedRef.current = elt; - } - }} - onFocus={() => { - setIsFocused(true); - setInputValue( - !value || dn.eq(value, 0) - ? "" // clear input if value is 0 - : dn.toString(dn.mul(value, 100)), - ); - }} - onBlur={() => { - setIsFocused(false); - - // invalid input => reset to current value - const parsed = parseInputFloat(inputValue.replace("%", "")); - if (!parsed) { - setInputValue(value ? dn.toString(dn.mul(value, 100)) : "0"); - } - }} - onChange={(event) => { - setInputValue(event.target.value); - const parsed = parseInputFloat(event.target.value); - if (parsed) { - onChange( - dnumMax(dnumMin(dn.from(100, 18), parsed), dn.from(0, 18)), - ); - } - }} - value={displayValue} - placeholder="0%" - disabled={vote === null} - className={css({ - display: "block", - width: 62, - height: "100%", - padding: 0, - paddingRight: 8, - fontSize: 14, - textAlign: "right", - color: "content", - background: "transparent", - border: 0, - borderRadius: 8, - outline: 0, - })} - /> -
- ); -} - -function VoteButton({ - disabled, - onSelect, - selected, - vote, -}: { - disabled?: boolean; - onSelect: () => void; - selected: boolean; - vote: Vote; -}) { - const selectTransition = useTransition(selected, { - from: { transform: "scale(1.5)" }, - enter: { transform: "scale(1)" }, - leave: { opacity: 0, immediate: true }, - config: { - mass: 1, - tension: 1800, - friction: 120, - }, - }); - - return ( - - ); -} diff --git a/frontend/app/src/constants.ts b/frontend/app/src/constants.ts index d7b338ed..dd758808 100644 --- a/frontend/app/src/constants.ts +++ b/frontend/app/src/constants.ts @@ -5,9 +5,9 @@ import type { BranchId, ChainId, CollateralSymbol, IcStrategy, RiskLevel } from import { vEnvLegacyCheck } from "@/src/valibot-utils"; import { WHITE_LABEL_CONFIG } from "@/src/white-label.config"; import { getDeploymentInfo } from "@/src/white-label-utils"; -import { norm } from "@liquity2/uikit"; import * as dn from "dnum"; import * as v from "valibot"; +import { maxUint256 } from "viem"; // make sure the icons in /public/fork-icons/ // are 54x54px, especially for PNGs. @@ -24,6 +24,7 @@ export const ONE_SECOND = 1000; export const ONE_MINUTE = 60 * ONE_SECOND; export const ONE_HOUR = 60 * ONE_MINUTE; export const ONE_DAY = 24 * ONE_HOUR; +export const ONE_YEAR_D18 = 365n * 24n * 60n * 60n * BigInt(1e18); export const GAS_MIN_HEADROOM = 100_000; export const GAS_RELATIVE_HEADROOM = 0.25; @@ -32,8 +33,10 @@ export const GAS_ALLOCATE_LQTY_MIN_HEADROOM = 350_000; export const LOCAL_STORAGE_PREFIX = "liquity2:"; export const LEVERAGE_FACTOR_MIN = 1.1; +export const LEVERAGE_FACTOR_DEFAULT = 1.5; +export const LEVERAGE_FACTOR_PRECISION = 0.1; -export const MAX_LTV_ALLOWED_RATIO = 0.916; // ratio of the max LTV allowed by the app (when opening a position) +export const MAX_LTV_ALLOWED_RATIO = 0.916666667; // ratio of the max LTV allowed by the app (when opening a position) export const MAX_LTV_RESERVE_RATIO = 0.04; // ratio of the max LTV in non-limited mode (e.g. when updating a position), to prevent reaching the max LTV export const ETH_MAX_RESERVE = dn.from(0.1, 18); // leave 0.1 ETH when users click on "max" to deposit from their account @@ -45,6 +48,7 @@ export const INTEREST_RATE_ADJ_COOLDOWN = 7 * 24 * 60 * 60; // 7 days in seconds // interest rate field config export const INTEREST_RATE_START = 0.005; // 0.5% export const INTEREST_RATE_END = 0.25; // 25% +export const INTEREST_RATE_MAX = 2.5; // 250% export const INTEREST_RATE_DEFAULT = 0.1; // 10% export const INTEREST_RATE_PRECISE_UNTIL = 0.1; // use precise increments until 10% export const INTEREST_RATE_INCREMENT_PRECISE = 0.001; // 0.1% increments (precise) @@ -56,9 +60,9 @@ export const DATA_REFRESH_INTERVAL = 30_000; export const PRICE_REFRESH_INTERVAL = 60_000; export const DATA_STALE_TIME = 5_000; -export const LEVERAGE_MAX_SLIPPAGE = 0.05; // 5% -export const CLOSE_FROM_COLLATERAL_SLIPPAGE = 0.05; // 5% -export const MAX_UPFRONT_FEE = 1000n * 10n ** 18n; +export const LEVERAGE_SLIPPAGE_TOLERANCE = 0.0005; // 0.05% +export const LEVERAGE_PRICE_IMPACT_HIGH = 0.01; // 1% +export const MAX_UPFRONT_FEE = maxUint256; export const MIN_DEBT = dn.from(2000, 18); export const TROVE_STATUS_NONEXISTENT = 0; @@ -74,13 +78,9 @@ export const MAX_COLLATERAL_DEPOSITS: Record = Object dn.from(BigInt(collateral.maxDeposit), 18), ]) ) as Record; - -// LTV factor suggestions, as ratios of the multiply factor range -export const LEVERAGE_FACTOR_SUGGESTIONS = [ - norm(1.5, 1.1, 11), // 1.5x multiply with a 1.1x => 11x range - norm(2.5, 1.1, 11), - norm(5, 1.1, 11), -]; +export const REDEMPTION_MAX_ITERATIONS_PER_COLL = 25; +export const REDEMPTION_FEE_HIGH = 0.01; // 1% +export const REDEMPTION_SLIPPAGE_TOLERANCE = 0.001; // 0.1% // DEBT suggestions, as ratios of the max LTV export const DEBT_SUGGESTIONS = [ @@ -90,13 +90,13 @@ export const DEBT_SUGGESTIONS = [ ]; // ltv risk levels, as ratios of the max ltv -export const LTV_RISK: Record, number> = { +export const LTV_RISK: Record, number> = { medium: 0.54, high: 0.73, }; // redemption risk levels, as debt positioning ratios -export const REDEMPTION_RISK: Record, number> = { +export const REDEMPTION_RISK: Record, number> = { medium: 0.05, // 5% of total debt in front low: 0.60, // 60% of total debt in front }; diff --git a/frontend/app/src/content.tsx b/frontend/app/src/content.tsx index 11429f24..1a4f3af5 100644 --- a/frontend/app/src/content.tsx +++ b/frontend/app/src/content.tsx @@ -135,28 +135,6 @@ export default { label: "Delegated", secondary: <>The interest rate is set and updated by a third party of your choice. They may charge a fee., }, - strategy: { - label: "Autonomous Rate Manager", - secondary: ( - <> - The interest rate is set and updated by an automated strategy running on the Internet Computer (ICP). - - ), - }, - }, - - icStrategyModal: { - title: ( - <> - Autonomous Rate Manager (ARM) - - ), - intro: ( - <> - These strategies are run on the Internet Computer (ICP). They are automated and decentralized. More strategies - may be added over time. - - ), }, delegatesModal: { @@ -180,10 +158,10 @@ export default { You are repaying your debt and closing the position. The deposit will be returned to your wallet. ), - repayWithCollateralMessage: ( + repayWithCollateralMessage: (collateralName: string) => ( <> - To close your position, a part of your collateral will be sold to pay back the debt. The rest of your collateral - will be returned to your wallet. + To close your position, part of your {collateralName}{" "} + will be sold to pay back the debt. The rest will be returned to your wallet. ), buttonRepayAndClose: "Repay & close", @@ -272,9 +250,6 @@ export default { borrowField: { label: "Loan", }, - liquidationPriceField: { - label: "ETH liquidation price", - }, interestRateField: { label: "Interest rate", }, @@ -294,10 +269,10 @@ export default { ), depositField: { - label: "You deposit", + label: "Deposit", }, liquidationPriceField: { - label: "ETH liquidation price", + label: "Liquidation price", }, interestRateField: { label: "Interest rate", @@ -373,8 +348,9 @@ export default { rewardsLabel: "My rewards", }, tabs: { - deposit: "Deposit", + deposit: "Update", claim: "Claim rewards", + compound: "Compound", }, depositPanel: { label: "Increase deposit", @@ -384,13 +360,17 @@ export default { }, withdrawPanel: { label: "Decrease deposit", - claimCheckbox: "Claim rewards", action: "Next: Summary", }, rewardsPanel: { - boldRewardsLabel: "Your earnings from protocol revenue distributions to this stability pool", - collRewardsLabel: "Your proceeds from liquidations conducted by this stability pool", - totalUsdLabel: "Total in USD", + boldRewardsLabel: `Your ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol} rewards will be paid out`, + collRewardsLabel: (collateral: N) => <>Your {collateral} rewards will be paid out, + expectedGasFeeLabel: "Expected gas fee", + action: "Next: Summary", + }, + compoundPanel: { + boldRewardsLabel: `Your ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol} rewards will be used to top-up your deposit`, + collRewardsLabel: (collateral: N) => <>Your {collateral} rewards will remain in your deposit, expectedGasFeeLabel: "Expected gas fee", action: "Next: Summary", }, @@ -401,16 +381,21 @@ export default { depositPoolShare: [ `Percentage of your ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol} deposit compared to the total deposited in this stability pool.`, ], - alsoClaimRewardsDeposit: [ + alsoClaimRewardsDeposit: (collateral: N) => [ <> - If checked, rewards are paid out as part of the update transaction. Otherwise rewards will be compounded into - your deposit. + If checked, rewards will be paid out as part of the deposit transaction. Otherwise, ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol} rewards will be + compounded and {collateral} rewards will remain claimable. , ], - alsoClaimRewardsWithdraw: [ + alsoClaimRewardsWithdraw: (collateral: N) => [ <> - If checked, rewards are paid out as part of the update transaction.
- Note: This needs to be checked to fully withdraw from the Stability Pool. +
+ If checked, rewards will be paid out as part of the withdrawal transaction. Otherwise, ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol} rewards will be + compounded and {collateral} rewards will remain claimable. +
+
+ Rewards will always be claimed when fully withdrawing from the Stability Pool. +
, ], currentApr: [ @@ -479,10 +464,33 @@ export default { title: "Allocate your voting power", intro: ( <> - Direct incentives from ${WHITE_LABEL_CONFIG.branding.appName} protocol revenues towards liquidity providers for ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol}. Upvote from Thursday - to Tuesday. Downvote all week. Learn more + Vote on initiatives and direct incentives from {WHITE_LABEL_CONFIG.branding.appName} protocol revenues towards liquidity venues for {WHITE_LABEL_CONFIG.tokens.mainToken.symbol}. + Upvote from Thursday to Tuesday. Downvote all week. Get and claim bribes for some of them. ), + resources: { + overview: { + description: "Learn more about voting accrual, initiative and protocol incentivized liquidity (PIL).", + linkText: "LQTY Voting & Staking in V2", + linkUrl: "https://docs.liquity.org/v2-faq/lqty-staking", + }, + discuss: { + description: "Overview over the PIL initiatives – propose and discuss initiatives.", + linkText: "Protocol Incentivized Liquidy (PIL) Initiatives", + linkUrl: "https://voting.liquity.org/", + }, + dashboard: { + description: "Check Dune Dash for the weekly voting and reward distributions.", + linkText: "Voting stats", + linkUrl: "https://dune.com/liquity/protocol-incentivized-liquidity", + }, + bribes: { + description: + "Initiatives can offer Bribes. Active bribing campaigns are visible below and can be claimed weekly.", + linkText: "Bribing Markets", + linkUrl: "https://www.liquity.org/blog/bribe-markets-in-liquity-v2-strategic-value-for-lqty-stakers", + }, + }, }, infoTooltips: { alsoClaimRewardsDeposit: [ @@ -503,25 +511,107 @@ export default { ), }, }, + atRiskWarning: { + delegated: (maxLtvAllowed: string) => ( +
+ When you delegate your interest rate management, your LTV must be below + {" "} + {maxLtvAllowed}. Please reduce your loan or add more collateral to proceed. +
+ ), + manual: (ltv: string, maxLtv: string) => ({ + message: ( +
+ Your position's LTV is {ltv}, which is close to the maximum of{" "} + {maxLtv}. You are at high risk of liquidation. +
+ ), + checkboxLabel: "I understand. Let's continue.", + }), + }, + ccrWarning: { + title: "Borrowing Restrictions Apply", + learnMoreUrl: + "https://docs.liquity.org/v2-faq/borrowing-and-liquidations#docs-internal-guid-fee4cc44-7fff-c866-9ccf-bac2da1b5222", + learnMoreLabel: "Learn more about borrowing restrictions", + openPosition: (params: { tcr: N; ccr: N; newTcr: N; isOldTcrLtCcr: boolean }) => ( + <> + {params.isOldTcrLtCcr && ( + <> + The branch TCR of {params.tcr} is currently below the{" "} + CCR of {params.ccr}.{" "} + + )} + Opening a position must bring the branch TCR {params.isOldTcrLtCcr + ? <>above {params.ccr}. + : ( + <> + above the CCR of {params.ccr}. + + )} Opening this loan would result in a TCR of{" "} + {params.newTcr}. Please reduce your loan amount or increase your collateral to proceed. + + ), + updatePushBelow: (params: { newTcr: N; ccr: N }) => ( + <> + This update to your existing loan would bring the branch TCR to{" "} + {params.newTcr}, which is below the CCR of{" "} + {params.ccr}. Please reduce your loan amount or increase your collateral to proceed. + + ), + updateBorrowMore: (params: { tcr: N; ccr: N; newTcr: N; isNewTcrLteCcr: boolean }) => ( + <> + The branch TCR of {params.tcr} is currently below the{" "} + CCR of {params.ccr}. {params.isNewTcrLteCcr + ? ( + <> + New borrowing must bring the TCR above{" "} + {params.ccr}. Your current loan update would result in a TCR + {" "} + of {params.newTcr}. + + ) + : <>When borrowing, your collateral increase must exceed your debt increase.}{" "} + Please reduce your loan amount or increase your collateral to proceed. + + ), + updateWithdrawColl: (params: { tcr: N; ccr: N }) => ( + <> + The branch TCR of {params.tcr} is currently below the{" "} + CCR of{" "} + {params.ccr}. Collateral withdrawal must be matched by debt repayment. Please repay debt equal to or greater + than the collateral value you wish to withdraw. + + ), + interestRateAdjustment: (params: { tcr: N; ccr: N; cooldownDays: number }) => ( + <> + The branch TCR of {params.tcr} is currently below the{" "} + CCR of{" "} + {params.ccr}. Interest rate adjustments are restricted until either the{" "} + TCR rises above {params.ccr}, or {params.cooldownDays}{" "} + days have passed since your last adjustment. + + ), + }, } as const; -function Link({ - href, - children, -}: { - href: string; - children: N; -}) { - const props = !href.startsWith("http") ? {} : { - target: "_blank", - rel: "noopener noreferrer", - }; - return ( - - {children} - - ); -} +// function Link({ +// href, +// children, +// }: { +// href: string; +// children: N; +// }) { +// const props = !href.startsWith("http") ? {} : { +// target: "_blank", +// rel: "noopener noreferrer", +// }; +// return ( +// +// {children} +// +// ); +// } function NoWrap({ children, diff --git a/frontend/app/src/contract-read-calls.ts b/frontend/app/src/contract-read-calls.ts index f3e6f583..a8c10e55 100644 --- a/frontend/app/src/contract-read-calls.ts +++ b/frontend/app/src/contract-read-calls.ts @@ -1,7 +1,7 @@ import type { Address } from "@liquity2/uikit"; import { createPublicClient, hexToBigInt, http, toHex, isAddressEqual, zeroAddress } from "viem"; import { CONTRACTS } from "./contracts"; -import { BranchId, CombinedTroveData, DebtPerInterestRate, PrefixedTroveId, ReturnCombinedTroveReadCallData, ReturnTroveReadCallData, Trove, TroveStatus } from "./types"; +import { BranchId, CombinedTroveData, DebtPerInterestRate, PrefixedTroveId, ReturnCombinedTroveReadCallData, ReturnTroveReadCallData, Trove, TroveStatusEnum } from "./types"; import { CHAIN_RPC_URL, CHAIN_ID, CHAIN_NAME, CHAIN_CURRENCY } from "./env"; import { getCollToken, getPrefixedTroveId, parsePrefixedTroveId } from "./liquity-utils"; @@ -209,7 +209,7 @@ export async function getTrovesByAccount(account: Address): Promise ({ id: branchId, diff --git a/frontend/app/src/dnum-utils.ts b/frontend/app/src/dnum-utils.ts index a01aa237..bb2c0bda 100644 --- a/frontend/app/src/dnum-utils.ts +++ b/frontend/app/src/dnum-utils.ts @@ -12,6 +12,13 @@ export function dnum18(value: string | bigint | number | null | undefined): Dnum return value === undefined || value === null ? null : [BigInt(value), 18]; } +export function dnum36(value: null | undefined): null; +export function dnum36(value: string | bigint | number): Dnum; +export function dnum36(value: string | bigint | number | null | undefined): Dnum | null; +export function dnum36(value: string | bigint | number | null | undefined): Dnum | null { + return value === undefined || value === null ? null : [BigInt(value), 36]; +} + export function dnumOrNull(value: Numberish | null | undefined, decimals: number): Dnum | null { if (value == null || value === undefined) { return null; @@ -31,6 +38,10 @@ export function dnumMin(a: Dnum, ...rest: Dnum[]) { return rest.reduce((min, value) => dn.lt(value, min) ? value : min, a); } +export function dnumNeg(value: Dnum): Dnum { + return [-value[0], value[1]]; +} + export const jsonStringifyWithDnum: typeof JSON.stringify = (data, replacer, space) => { return JSON.stringify( data, diff --git a/frontend/app/src/env.ts b/frontend/app/src/env.ts index 4f4e5c12..f33dd832 100644 --- a/frontend/app/src/env.ts +++ b/frontend/app/src/env.ts @@ -144,12 +144,16 @@ export const EnvSchema = v.pipe( v.optional(v.string(), ""), v.transform((value) => value.trim() || null), ), + KNOWN_DELEGATES_URL: v.optional(v.union([v.pipe(v.string(), v.url()), v.literal("")])), KNOWN_INITIATIVES_URL: v.optional(v.pipe(v.string(), v.url())), LEGACY_CHECK: v.optional(vEnvLegacyCheck(), "true"), + V1_STAKING_CHECK: v.optional(vEnvFlag(), "true"), + V1_STABILITY_POOL_CHECK: v.optional(vEnvFlag(), "true"), LIQUITY_STATS_URL: v.optional(v.pipe(v.string(), v.url())), LIQUITY_GOVERNANCE_URL: v.optional(v.union([v.pipe(v.string(), v.url()), v.literal("")])), SAFE_API_URL: v.optional(v.union([v.pipe(v.string(), v.url()), v.literal("")])), SBOLD: v.optional(v.union([vAddress(), v.literal("")])), + YBOLD: v.optional(vEnvFlag(), "false"), SUBGRAPH_URL: v.pipe(v.string(), v.url()), VERCEL_ANALYTICS: v.optional(vEnvFlag(), "false"), WALLET_CONNECT_PROJECT_ID: v.pipe( @@ -167,12 +171,15 @@ export const EnvSchema = v.pipe( CONTRACT_COLLATERAL_REGISTRY: vAddress(), CONTRACT_DEBT_IN_FRONT_HELPER: vAddress(), CONTRACT_EXCHANGE_HELPERS: vAddress(), + CONTRACT_EXCHANGE_HELPERS_V2: vAddress(), CONTRACT_GOVERNANCE: vAddress(), CONTRACT_HINT_HELPERS: vAddress(), CONTRACT_LQTY_STAKING: vAddress(), CONTRACT_LQTY_TOKEN: vAddress(), CONTRACT_LUSD_TOKEN: vAddress(), CONTRACT_MULTI_TROVE_GETTER: vAddress(), + CONTRACT_REDEMPTION_HELPER: vAddress(), + CONTRACT_V1_STABILITY_POOL: vAddress(), CONTRACT_WETH: vAddress(), // Collateral contracts are now loaded directly from white-label.config, not env vars @@ -279,12 +286,16 @@ const parsedEnv = v.safeParse(EnvSchema, { ), CONTRACTS_COMMIT_URL: process.env.NEXT_PUBLIC_CONTRACTS_COMMIT_URL, DEPLOYMENT_FLAVOR: process.env.NEXT_PUBLIC_DEPLOYMENT_FLAVOR, + KNOWN_DELEGATES_URL: process.env.NEXT_PUBLIC_KNOWN_DELEGATES_URL, KNOWN_INITIATIVES_URL: process.env.NEXT_PUBLIC_KNOWN_INITIATIVES_URL, LEGACY_CHECK: process.env.NEXT_PUBLIC_LEGACY_CHECK, + V1_STAKING_CHECK: process.env.NEXT_PUBLIC_V1_STAKING_CHECK, + V1_STABILITY_POOL_CHECK: process.env.NEXT_PUBLIC_V1_STABILITY_POOL_CHECK, LIQUITY_STATS_URL: process.env.NEXT_PUBLIC_LIQUITY_STATS_URL, LIQUITY_GOVERNANCE_URL: process.env.NEXT_PUBLIC_LIQUITY_GOVERNANCE_URL, SAFE_API_URL: process.env.NEXT_PUBLIC_SAFE_API_URL, SBOLD: process.env.NEXT_PUBLIC_SBOLD, + YBOLD: process.env.NEXT_PUBLIC_YBOLD, SUBGRAPH_URL: process.env.NEXT_PUBLIC_SUBGRAPH_URL, VERCEL_ANALYTICS: process.env.NEXT_PUBLIC_VERCEL_ANALYTICS, WALLET_CONNECT_PROJECT_ID: process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID, @@ -295,12 +306,15 @@ const parsedEnv = v.safeParse(EnvSchema, { CONTRACT_COLLATERAL_REGISTRY: process.env.NEXT_PUBLIC_CONTRACT_COLLATERAL_REGISTRY, CONTRACT_DEBT_IN_FRONT_HELPER: process.env.NEXT_PUBLIC_CONTRACT_DEBT_IN_FRONT_HELPER, CONTRACT_EXCHANGE_HELPERS: process.env.NEXT_PUBLIC_CONTRACT_EXCHANGE_HELPERS, + CONTRACT_EXCHANGE_HELPERS_V2: process.env.NEXT_PUBLIC_CONTRACT_EXCHANGE_HELPERS_V2, CONTRACT_GOVERNANCE: process.env.NEXT_PUBLIC_CONTRACT_GOVERNANCE, CONTRACT_HINT_HELPERS: process.env.NEXT_PUBLIC_CONTRACT_HINT_HELPERS, CONTRACT_LQTY_STAKING: process.env.NEXT_PUBLIC_CONTRACT_LQTY_STAKING, CONTRACT_LQTY_TOKEN: process.env.NEXT_PUBLIC_CONTRACT_LQTY_TOKEN, CONTRACT_LUSD_TOKEN: process.env.NEXT_PUBLIC_CONTRACT_LUSD_TOKEN, CONTRACT_MULTI_TROVE_GETTER: process.env.NEXT_PUBLIC_CONTRACT_MULTI_TROVE_GETTER, + CONTRACT_REDEMPTION_HELPER: process.env.NEXT_PUBLIC_CONTRACT_REDEMPTION_HELPER, + CONTRACT_V1_STABILITY_POOL: process.env.NEXT_PUBLIC_CONTRACT_V1_STABILITY_POOL, CONTRACT_WETH: process.env.NEXT_PUBLIC_CONTRACT_WETH, }); @@ -342,20 +356,27 @@ export const { CONTRACT_COLLATERAL_REGISTRY, CONTRACT_DEBT_IN_FRONT_HELPER, CONTRACT_EXCHANGE_HELPERS, + CONTRACT_EXCHANGE_HELPERS_V2, CONTRACT_GOVERNANCE, CONTRACT_HINT_HELPERS, CONTRACT_LQTY_STAKING, CONTRACT_LQTY_TOKEN, CONTRACT_LUSD_TOKEN, CONTRACT_MULTI_TROVE_GETTER, + CONTRACT_REDEMPTION_HELPER, + CONTRACT_V1_STABILITY_POOL, CONTRACT_WETH, DEPLOYMENT_FLAVOR, + KNOWN_DELEGATES_URL, KNOWN_INITIATIVES_URL, LEGACY_CHECK, + V1_STAKING_CHECK, + V1_STABILITY_POOL_CHECK, LIQUITY_STATS_URL, LIQUITY_GOVERNANCE_URL, SAFE_API_URL, SBOLD, + YBOLD, SUBGRAPH_URL, VERCEL_ANALYTICS, WALLET_CONNECT_PROJECT_ID, diff --git a/frontend/app/src/form-utils.ts b/frontend/app/src/form-utils.ts index 36fc5866..b65bc28e 100644 --- a/frontend/app/src/form-utils.ts +++ b/frontend/app/src/form-utils.ts @@ -2,7 +2,7 @@ import type { Dnum } from "dnum"; import { ADDRESS_ZERO, isAddress } from "@liquity2/uikit"; import * as dn from "dnum"; -import { useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; const inputValueRegex = /^[0-9]*\.?[0-9]*?$/; export function isInputFloat(value: string) { @@ -115,12 +115,14 @@ export function useForm
>>( } as const; } -type InputFieldUpdateData = { +export type InputFieldUpdateData = { focused: boolean; parsed: Dnum | null; value: string; }; +const validateNoop = (parsed: Dnum | null, value: string) => ({ parsed, value }); + export function useInputFieldValue( format: (value: Dnum) => string, { @@ -128,7 +130,7 @@ export function useInputFieldValue( onChange, onFocusChange, parse = parseInputFloat, - validate = (parsed, value) => ({ parsed, value }), + validate = validateNoop, }: { defaultValue?: string; onChange?: (data: InputFieldUpdateData) => void; @@ -137,63 +139,60 @@ export function useInputFieldValue( validate?: (parsed: Dnum | null, value: string) => { parsed: Dnum | null; value: string }; } = {}, ) { - const [{ value, focused, parsed }, set] = useState<{ - value: string; - focused: boolean; - parsed: Dnum | null; - }>({ + const [data, setData] = useState({ value: defaultValue, focused: false, parsed: parse(defaultValue), }); - const ref = useRef(null); - - return useMemo(() => { - const setValue = (value: string) => { - let parsed = parse(value); - - const result = validate(parsed, value); - parsed = result.parsed; - value = result.value; - - set((s) => ({ ...s, parsed, value })); - onChange?.({ focused, parsed, value }); - }; - - const setFocused = (focused: boolean) => { - set((s) => ({ ...s, focused })); - }; - - return ({ - focus: () => { - ref.current?.focus(); - }, - inputFieldProps: { - ref, - onBlur: () => { - setFocused(false); - onFocusChange?.({ focused: false, parsed, value }); - }, - onFocus: () => { - setFocused(true); - onFocusChange?.({ focused: true, parsed, value }); - }, - onChange: setValue, - value: focused || !parsed || !value.trim() ? value : format(parsed), - }, - isEmpty: value.trim() === "", - isFocused: focused, - parsed, - setValue, - value, - }); - }, [ - focused, - format, - onChange, - onFocusChange, + const { value, focused, parsed } = data; + const dataRef = useRef(data); + const inputRef = useRef(null); + const isEmpty = !value.trim(); + const inputFieldValue = isEmpty || focused || !parsed ? value : format(parsed); + + const setDataAndRef = useCallback((newData: InputFieldUpdateData) => { + setData(newData); + dataRef.current = newData; + }, []); + + const setValue = useCallback((value: string) => { + const newData = { ...dataRef.current, ...validate(parse(value), value) }; + setDataAndRef(newData); + onChange?.(newData); + }, [parse, validate, setDataAndRef, onChange]); + + const focus = useCallback(() => { + inputRef.current?.focus(); + }, []); + + const onBlur = useCallback(() => { + const newData = { ...dataRef.current, focused: false }; + setDataAndRef(newData); + onFocusChange?.(newData); + }, [setDataAndRef, onFocusChange]); + + const onFocus = useCallback(() => { + const newData = { ...dataRef.current, focused: true }; + setDataAndRef(newData); + onFocusChange?.(newData); + }, [setDataAndRef, onFocusChange]); + + const inputFieldProps = useMemo(() => ({ + ref: inputRef, + onBlur, + onFocus, + onChange: setValue, + value: inputFieldValue, + }), [onBlur, onFocus, setValue, inputFieldValue]); + + return useMemo(() => ({ + focus, + inputFieldProps, + isEmpty, + isFocused: focused, parsed, value, - ]); + setValue, + }), [focus, inputFieldProps, isEmpty, focused, parsed, value, setValue]); } diff --git a/frontend/app/src/formatting.ts b/frontend/app/src/formatting.ts index 74b40201..e7b41c5a 100644 --- a/frontend/app/src/formatting.ts +++ b/frontend/app/src/formatting.ts @@ -9,6 +9,7 @@ import { match, P } from "ts-pattern"; const fmtnumPresets = { "1z": { digits: 1, trailingZeros: true }, "2z": { digits: 2, trailingZeros: true }, + "4z": { digits: 4, trailingZeros: true }, "12z": { digits: 12, trailingZeros: true }, "2diff": { digits: 2, signDisplay: "exceptZero" }, "4diff": { digits: 4, signDisplay: "exceptZero" }, @@ -115,6 +116,7 @@ export function formatLiquidationRisk(liquidationRisk: RiskLevel | null) { .with("low", () => "Low liquidation risk") .with("medium", () => "Medium liquidation risk") .with("high", () => "High liquidation risk") + .with("not-applicable", () => "Liquidation risk N/A") .exhaustive(); } @@ -124,6 +126,7 @@ export function formatRedemptionRisk(redemptionRisk: RiskLevel | null) { .with("low", () => "Low redemption risk") .with("medium", () => "Medium redemption risk") .with("high", () => "High redemption risk") + .with("not-applicable", () => "Redemption risk N/A") .exhaustive(); } @@ -133,6 +136,7 @@ export function formatRisk(risk: RiskLevel | null) { .with("low", () => "Low") .with("medium", () => "Medium") .with("high", () => "High") + .with("not-applicable", () => "N/A") .exhaustive(); } diff --git a/frontend/app/src/graphql/gql.ts b/frontend/app/src/graphql/gql.ts index f304963a..a3a3b2a5 100644 --- a/frontend/app/src/graphql/gql.ts +++ b/frontend/app/src/graphql/gql.ts @@ -17,10 +17,10 @@ import * as types from './graphql'; type Documents = { "\n query BlockNumber {\n _meta {\n block {\n number\n }\n }\n }\n": typeof types.BlockNumberDocument, "\n query NextOwnerIndexesByBorrower($id: ID!) {\n borrowerInfo(id: $id) {\n nextOwnerIndexes\n }\n }\n": typeof types.NextOwnerIndexesByBorrowerDocument, - "\n query TrovesByAccount($account: Bytes!) {\n troves(\n where: {\n or: [\n { previousOwner: $account, status: liquidated },\n { borrower: $account, status_in: [active,redeemed] }\n ],\n }\n orderBy: updatedAt\n orderDirection: desc\n ) {\n id\n closedAt\n createdAt\n lastUserActionAt\n mightBeLeveraged\n status\n debt\n redemptionCount\n redeemedColl\n redeemedDebt\n }\n }\n": typeof types.TrovesByAccountDocument, - "\n query TroveById($id: ID!) {\n trove(id: $id) {\n id\n borrower\n closedAt\n createdAt\n lastUserActionAt\n mightBeLeveraged\n previousOwner\n status\n debt\n redemptionCount\n redeemedColl\n redeemedDebt\n }\n }\n": typeof types.TroveByIdDocument, + "\n query TrovesByAccount($account: Bytes!) {\n troves(\n where: {\n or: [\n { previousOwner: $account, status: liquidated },\n { borrower: $account, status_in: [active, redeemed] }\n ],\n }\n orderBy: updatedAt\n orderDirection: desc\n ) {\n id\n closedAt\n createdAt\n lastUserActionAt\n updatedAt\n mightBeLeveraged\n status\n debt\n redemptionCount\n redeemedColl\n redeemedDebt\n liquidatedColl\n liquidatedDebt\n collSurplus\n priceAtLiquidation\n }\n }\n": typeof types.TrovesByAccountDocument, + "\n query TroveById($id: ID!) {\n trove(id: $id) {\n id\n borrower\n closedAt\n createdAt\n lastUserActionAt\n updatedAt\n mightBeLeveraged\n previousOwner\n status\n debt\n redemptionCount\n redeemedColl\n redeemedDebt\n liquidatedColl\n liquidatedDebt\n collSurplus\n priceAtLiquidation\n }\n }\n": typeof types.TroveByIdDocument, "\n query InterestBatches($ids: [ID!]!) {\n interestBatches(where: { id_in: $ids }) {\n collateral {\n collIndex\n }\n batchManager\n debt\n coll\n annualInterestRate\n annualManagementFee\n }\n }\n": typeof types.InterestBatchesDocument, - "\n query AllInterestRateBrackets {\n interestRateBrackets(\n first: 1000\n where: { totalDebt_gt: 0 }\n orderBy: rate\n ) {\n collateral {\n collIndex\n }\n rate\n totalDebt\n }\n }\n": typeof types.AllInterestRateBracketsDocument, + "\n query AllInterestRateBrackets {\n interestRateBrackets(\n first: 1000\n where: { totalDebt_gt: 0 }\n orderBy: rate\n ) {\n collateral {\n collIndex\n }\n rate\n totalDebt\n sumDebtTimesRateD36\n pendingDebtTimesOneYearD36\n updatedAt\n }\n }\n": typeof types.AllInterestRateBracketsDocument, "\n query GovernanceGlobalData {\n governanceInitiatives {\n id\n }\n\n governanceVotingPower(id: \"total\") {\n allocatedLQTY\n allocatedOffset\n unallocatedLQTY\n unallocatedOffset\n }\n }\n": typeof types.GovernanceGlobalDataDocument, "\n query UserAllocationHistory($user: String) {\n governanceAllocations(\n where: { user: $user }\n orderBy: epoch\n orderDirection: desc\n first: 1000\n ) {\n epoch\n initiative { id }\n voteLQTY\n vetoLQTY\n voteOffset\n vetoOffset\n }\n }\n": typeof types.UserAllocationHistoryDocument, "\n query TotalAllocationHistory($initiative: String) {\n governanceAllocations(\n where: { initiative: $initiative, user: null }\n orderBy: epoch\n orderDirection: desc\n first: 1000\n ) {\n epoch\n voteLQTY\n vetoLQTY\n voteOffset\n vetoOffset\n }\n }\n": typeof types.TotalAllocationHistoryDocument, @@ -28,10 +28,10 @@ type Documents = { const documents: Documents = { "\n query BlockNumber {\n _meta {\n block {\n number\n }\n }\n }\n": types.BlockNumberDocument, "\n query NextOwnerIndexesByBorrower($id: ID!) {\n borrowerInfo(id: $id) {\n nextOwnerIndexes\n }\n }\n": types.NextOwnerIndexesByBorrowerDocument, - "\n query TrovesByAccount($account: Bytes!) {\n troves(\n where: {\n or: [\n { previousOwner: $account, status: liquidated },\n { borrower: $account, status_in: [active,redeemed] }\n ],\n }\n orderBy: updatedAt\n orderDirection: desc\n ) {\n id\n closedAt\n createdAt\n lastUserActionAt\n mightBeLeveraged\n status\n debt\n redemptionCount\n redeemedColl\n redeemedDebt\n }\n }\n": types.TrovesByAccountDocument, - "\n query TroveById($id: ID!) {\n trove(id: $id) {\n id\n borrower\n closedAt\n createdAt\n lastUserActionAt\n mightBeLeveraged\n previousOwner\n status\n debt\n redemptionCount\n redeemedColl\n redeemedDebt\n }\n }\n": types.TroveByIdDocument, + "\n query TrovesByAccount($account: Bytes!) {\n troves(\n where: {\n or: [\n { previousOwner: $account, status: liquidated },\n { borrower: $account, status_in: [active, redeemed] }\n ],\n }\n orderBy: updatedAt\n orderDirection: desc\n ) {\n id\n closedAt\n createdAt\n lastUserActionAt\n updatedAt\n mightBeLeveraged\n status\n debt\n redemptionCount\n redeemedColl\n redeemedDebt\n liquidatedColl\n liquidatedDebt\n collSurplus\n priceAtLiquidation\n }\n }\n": types.TrovesByAccountDocument, + "\n query TroveById($id: ID!) {\n trove(id: $id) {\n id\n borrower\n closedAt\n createdAt\n lastUserActionAt\n updatedAt\n mightBeLeveraged\n previousOwner\n status\n debt\n redemptionCount\n redeemedColl\n redeemedDebt\n liquidatedColl\n liquidatedDebt\n collSurplus\n priceAtLiquidation\n }\n }\n": types.TroveByIdDocument, "\n query InterestBatches($ids: [ID!]!) {\n interestBatches(where: { id_in: $ids }) {\n collateral {\n collIndex\n }\n batchManager\n debt\n coll\n annualInterestRate\n annualManagementFee\n }\n }\n": types.InterestBatchesDocument, - "\n query AllInterestRateBrackets {\n interestRateBrackets(\n first: 1000\n where: { totalDebt_gt: 0 }\n orderBy: rate\n ) {\n collateral {\n collIndex\n }\n rate\n totalDebt\n }\n }\n": types.AllInterestRateBracketsDocument, + "\n query AllInterestRateBrackets {\n interestRateBrackets(\n first: 1000\n where: { totalDebt_gt: 0 }\n orderBy: rate\n ) {\n collateral {\n collIndex\n }\n rate\n totalDebt\n sumDebtTimesRateD36\n pendingDebtTimesOneYearD36\n updatedAt\n }\n }\n": types.AllInterestRateBracketsDocument, "\n query GovernanceGlobalData {\n governanceInitiatives {\n id\n }\n\n governanceVotingPower(id: \"total\") {\n allocatedLQTY\n allocatedOffset\n unallocatedLQTY\n unallocatedOffset\n }\n }\n": types.GovernanceGlobalDataDocument, "\n query UserAllocationHistory($user: String) {\n governanceAllocations(\n where: { user: $user }\n orderBy: epoch\n orderDirection: desc\n first: 1000\n ) {\n epoch\n initiative { id }\n voteLQTY\n vetoLQTY\n voteOffset\n vetoOffset\n }\n }\n": types.UserAllocationHistoryDocument, "\n query TotalAllocationHistory($initiative: String) {\n governanceAllocations(\n where: { initiative: $initiative, user: null }\n orderBy: epoch\n orderDirection: desc\n first: 1000\n ) {\n epoch\n voteLQTY\n vetoLQTY\n voteOffset\n vetoOffset\n }\n }\n": types.TotalAllocationHistoryDocument, @@ -48,11 +48,11 @@ export function graphql(source: "\n query NextOwnerIndexesByBorrower($id: ID!) /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n query TrovesByAccount($account: Bytes!) {\n troves(\n where: {\n or: [\n { previousOwner: $account, status: liquidated },\n { borrower: $account, status_in: [active,redeemed] }\n ],\n }\n orderBy: updatedAt\n orderDirection: desc\n ) {\n id\n closedAt\n createdAt\n lastUserActionAt\n mightBeLeveraged\n status\n debt\n redemptionCount\n redeemedColl\n redeemedDebt\n }\n }\n"): typeof import('./graphql').TrovesByAccountDocument; +export function graphql(source: "\n query TrovesByAccount($account: Bytes!) {\n troves(\n where: {\n or: [\n { previousOwner: $account, status: liquidated },\n { borrower: $account, status_in: [active, redeemed] }\n ],\n }\n orderBy: updatedAt\n orderDirection: desc\n ) {\n id\n closedAt\n createdAt\n lastUserActionAt\n updatedAt\n mightBeLeveraged\n status\n debt\n redemptionCount\n redeemedColl\n redeemedDebt\n liquidatedColl\n liquidatedDebt\n collSurplus\n priceAtLiquidation\n }\n }\n"): typeof import('./graphql').TrovesByAccountDocument; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n query TroveById($id: ID!) {\n trove(id: $id) {\n id\n borrower\n closedAt\n createdAt\n lastUserActionAt\n mightBeLeveraged\n previousOwner\n status\n debt\n redemptionCount\n redeemedColl\n redeemedDebt\n }\n }\n"): typeof import('./graphql').TroveByIdDocument; +export function graphql(source: "\n query TroveById($id: ID!) {\n trove(id: $id) {\n id\n borrower\n closedAt\n createdAt\n lastUserActionAt\n updatedAt\n mightBeLeveraged\n previousOwner\n status\n debt\n redemptionCount\n redeemedColl\n redeemedDebt\n liquidatedColl\n liquidatedDebt\n collSurplus\n priceAtLiquidation\n }\n }\n"): typeof import('./graphql').TroveByIdDocument; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -60,7 +60,7 @@ export function graphql(source: "\n query InterestBatches($ids: [ID!]!) {\n /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n query AllInterestRateBrackets {\n interestRateBrackets(\n first: 1000\n where: { totalDebt_gt: 0 }\n orderBy: rate\n ) {\n collateral {\n collIndex\n }\n rate\n totalDebt\n }\n }\n"): typeof import('./graphql').AllInterestRateBracketsDocument; +export function graphql(source: "\n query AllInterestRateBrackets {\n interestRateBrackets(\n first: 1000\n where: { totalDebt_gt: 0 }\n orderBy: rate\n ) {\n collateral {\n collIndex\n }\n rate\n totalDebt\n sumDebtTimesRateD36\n pendingDebtTimesOneYearD36\n updatedAt\n }\n }\n"): typeof import('./graphql').AllInterestRateBracketsDocument; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/frontend/app/src/graphql/graphql.ts b/frontend/app/src/graphql/graphql.ts index f2ec998c..4efda200 100644 --- a/frontend/app/src/graphql/graphql.ts +++ b/frontend/app/src/graphql/graphql.ts @@ -17,15 +17,9 @@ export type Scalars = { BigDecimal: { input: string; output: string; } BigInt: { input: string; output: string; } Bytes: { input: string; output: string; } - /** - * 8 bytes signed integer - * - */ + /** 8 bytes signed integer */ Int8: { input: number; output: number; } - /** - * A string representation of microseconds UNIX timestamp (16 digits) - * - */ + /** A string representation of microseconds UNIX timestamp (16 digits) */ Timestamp: { input: string; output: string; } }; @@ -46,7 +40,9 @@ export type Block_Height = { export type BorrowerInfo = { __typename?: 'BorrowerInfo'; + collSurplusBalance: Array; id: Scalars['ID']['output']; + lastCollSurplusClaimAt: Array; nextOwnerIndexes: Array; troves: Scalars['Int']['output']; trovesByCollateral: Array; @@ -56,6 +52,12 @@ export type BorrowerInfo_Filter = { /** Filter for the block changed event. */ _change_block?: InputMaybe; and?: InputMaybe>>; + collSurplusBalance?: InputMaybe>; + collSurplusBalance_contains?: InputMaybe>; + collSurplusBalance_contains_nocase?: InputMaybe>; + collSurplusBalance_not?: InputMaybe>; + collSurplusBalance_not_contains?: InputMaybe>; + collSurplusBalance_not_contains_nocase?: InputMaybe>; id?: InputMaybe; id_gt?: InputMaybe; id_gte?: InputMaybe; @@ -64,6 +66,12 @@ export type BorrowerInfo_Filter = { id_lte?: InputMaybe; id_not?: InputMaybe; id_not_in?: InputMaybe>; + lastCollSurplusClaimAt?: InputMaybe>; + lastCollSurplusClaimAt_contains?: InputMaybe>; + lastCollSurplusClaimAt_contains_nocase?: InputMaybe>; + lastCollSurplusClaimAt_not?: InputMaybe>; + lastCollSurplusClaimAt_not_contains?: InputMaybe>; + lastCollSurplusClaimAt_not_contains_nocase?: InputMaybe>; nextOwnerIndexes?: InputMaybe>; nextOwnerIndexes_contains?: InputMaybe>; nextOwnerIndexes_contains_nocase?: InputMaybe>; @@ -88,7 +96,9 @@ export type BorrowerInfo_Filter = { }; export enum BorrowerInfo_OrderBy { + CollSurplusBalance = 'collSurplusBalance', Id = 'id', + LastCollSurplusClaimAt = 'lastCollSurplusClaimAt', NextOwnerIndexes = 'nextOwnerIndexes', Troves = 'troves', TrovesByCollateral = 'trovesByCollateral' @@ -115,6 +125,7 @@ export type CollateralTrovesArgs = { export type CollateralAddresses = { __typename?: 'CollateralAddresses'; borrowerOperations: Scalars['Bytes']['output']; + collSurplusPool?: Maybe; collateral: Collateral; id: Scalars['ID']['output']; sortedTroves: Scalars['Bytes']['output']; @@ -138,6 +149,16 @@ export type CollateralAddresses_Filter = { borrowerOperations_not?: InputMaybe; borrowerOperations_not_contains?: InputMaybe; borrowerOperations_not_in?: InputMaybe>; + collSurplusPool?: InputMaybe; + collSurplusPool_contains?: InputMaybe; + collSurplusPool_gt?: InputMaybe; + collSurplusPool_gte?: InputMaybe; + collSurplusPool_in?: InputMaybe>; + collSurplusPool_lt?: InputMaybe; + collSurplusPool_lte?: InputMaybe; + collSurplusPool_not?: InputMaybe; + collSurplusPool_not_contains?: InputMaybe; + collSurplusPool_not_in?: InputMaybe>; collateral?: InputMaybe; collateral_?: InputMaybe; collateral_contains?: InputMaybe; @@ -222,6 +243,7 @@ export type CollateralAddresses_Filter = { export enum CollateralAddresses_OrderBy { BorrowerOperations = 'borrowerOperations', + CollSurplusPool = 'collSurplusPool', Collateral = 'collateral', CollateralCollIndex = 'collateral__collIndex', CollateralId = 'collateral__id', @@ -270,6 +292,7 @@ export type Collateral_Filter = { export enum Collateral_OrderBy { Addresses = 'addresses', AddressesBorrowerOperations = 'addresses__borrowerOperations', + AddressesCollSurplusPool = 'addresses__collSurplusPool', AddressesId = 'addresses__id', AddressesSortedTroves = 'addresses__sortedTroves', AddressesStabilityPool = 'addresses__stabilityPool', @@ -1043,6 +1066,7 @@ export type Trove = { __typename?: 'Trove'; borrower: Scalars['Bytes']['output']; closedAt?: Maybe; + collSurplus?: Maybe; collateral: Collateral; createdAt: Scalars['BigInt']['output']; debt: Scalars['BigInt']['output']; @@ -1051,8 +1075,11 @@ export type Trove = { interestBatch?: Maybe; interestRate: Scalars['BigInt']['output']; lastUserActionAt: Scalars['BigInt']['output']; + liquidatedColl?: Maybe; + liquidatedDebt?: Maybe; mightBeLeveraged: Scalars['Boolean']['output']; previousOwner: Scalars['Bytes']['output']; + priceAtLiquidation?: Maybe; redeemedColl: Scalars['BigInt']['output']; redeemedDebt: Scalars['BigInt']['output']; redemptionCount: Scalars['Int']['output']; @@ -1091,6 +1118,14 @@ export type Trove_Filter = { closedAt_lte?: InputMaybe; closedAt_not?: InputMaybe; closedAt_not_in?: InputMaybe>; + collSurplus?: InputMaybe; + collSurplus_gt?: InputMaybe; + collSurplus_gte?: InputMaybe; + collSurplus_in?: InputMaybe>; + collSurplus_lt?: InputMaybe; + collSurplus_lte?: InputMaybe; + collSurplus_not?: InputMaybe; + collSurplus_not_in?: InputMaybe>; collateral?: InputMaybe; collateral_?: InputMaybe; collateral_contains?: InputMaybe; @@ -1181,6 +1216,22 @@ export type Trove_Filter = { lastUserActionAt_lte?: InputMaybe; lastUserActionAt_not?: InputMaybe; lastUserActionAt_not_in?: InputMaybe>; + liquidatedColl?: InputMaybe; + liquidatedColl_gt?: InputMaybe; + liquidatedColl_gte?: InputMaybe; + liquidatedColl_in?: InputMaybe>; + liquidatedColl_lt?: InputMaybe; + liquidatedColl_lte?: InputMaybe; + liquidatedColl_not?: InputMaybe; + liquidatedColl_not_in?: InputMaybe>; + liquidatedDebt?: InputMaybe; + liquidatedDebt_gt?: InputMaybe; + liquidatedDebt_gte?: InputMaybe; + liquidatedDebt_in?: InputMaybe>; + liquidatedDebt_lt?: InputMaybe; + liquidatedDebt_lte?: InputMaybe; + liquidatedDebt_not?: InputMaybe; + liquidatedDebt_not_in?: InputMaybe>; mightBeLeveraged?: InputMaybe; mightBeLeveraged_in?: InputMaybe>; mightBeLeveraged_not?: InputMaybe; @@ -1196,6 +1247,14 @@ export type Trove_Filter = { previousOwner_not?: InputMaybe; previousOwner_not_contains?: InputMaybe; previousOwner_not_in?: InputMaybe>; + priceAtLiquidation?: InputMaybe; + priceAtLiquidation_gt?: InputMaybe; + priceAtLiquidation_gte?: InputMaybe; + priceAtLiquidation_in?: InputMaybe>; + priceAtLiquidation_lt?: InputMaybe; + priceAtLiquidation_lte?: InputMaybe; + priceAtLiquidation_not?: InputMaybe; + priceAtLiquidation_not_in?: InputMaybe>; redeemedColl?: InputMaybe; redeemedColl_gt?: InputMaybe; redeemedColl_gte?: InputMaybe; @@ -1265,6 +1324,7 @@ export type Trove_Filter = { export enum Trove_OrderBy { Borrower = 'borrower', ClosedAt = 'closedAt', + CollSurplus = 'collSurplus', Collateral = 'collateral', CollateralCollIndex = 'collateral__collIndex', CollateralId = 'collateral__id', @@ -1283,8 +1343,11 @@ export enum Trove_OrderBy { InterestBatchUpdatedAt = 'interestBatch__updatedAt', InterestRate = 'interestRate', LastUserActionAt = 'lastUserActionAt', + LiquidatedColl = 'liquidatedColl', + LiquidatedDebt = 'liquidatedDebt', MightBeLeveraged = 'mightBeLeveraged', PreviousOwner = 'previousOwner', + PriceAtLiquidation = 'priceAtLiquidation', RedeemedColl = 'redeemedColl', RedeemedDebt = 'redeemedDebt', RedemptionCount = 'redemptionCount', @@ -1314,7 +1377,6 @@ export type _Meta_ = { * will be null if the _meta field has a block constraint that asks for * a block number. It will be filled if the _meta field has no block constraint * and therefore asks for the latest block - * */ block: _Block_; /** The deployment ID */ @@ -1347,14 +1409,14 @@ export type TrovesByAccountQueryVariables = Exact<{ }>; -export type TrovesByAccountQuery = { __typename?: 'Query', troves: Array<{ __typename?: 'Trove', id: string, closedAt?: string | null, createdAt: string, lastUserActionAt: string, mightBeLeveraged: boolean, status: TroveStatus, debt: string, redemptionCount: number, redeemedColl: string, redeemedDebt: string }> }; +export type TrovesByAccountQuery = { __typename?: 'Query', troves: Array<{ __typename?: 'Trove', id: string, closedAt?: string | null, createdAt: string, lastUserActionAt: string, updatedAt: string, mightBeLeveraged: boolean, status: TroveStatus, debt: string, redemptionCount: number, redeemedColl: string, redeemedDebt: string, liquidatedColl?: string | null, liquidatedDebt?: string | null, collSurplus?: string | null, priceAtLiquidation?: string | null }> }; export type TroveByIdQueryVariables = Exact<{ id: Scalars['ID']['input']; }>; -export type TroveByIdQuery = { __typename?: 'Query', trove?: { __typename?: 'Trove', id: string, borrower: string, closedAt?: string | null, createdAt: string, lastUserActionAt: string, mightBeLeveraged: boolean, previousOwner: string, status: TroveStatus, debt: string, redemptionCount: number, redeemedColl: string, redeemedDebt: string } | null }; +export type TroveByIdQuery = { __typename?: 'Query', trove?: { __typename?: 'Trove', id: string, borrower: string, closedAt?: string | null, createdAt: string, lastUserActionAt: string, updatedAt: string, mightBeLeveraged: boolean, previousOwner: string, status: TroveStatus, debt: string, redemptionCount: number, redeemedColl: string, redeemedDebt: string, liquidatedColl?: string | null, liquidatedDebt?: string | null, collSurplus?: string | null, priceAtLiquidation?: string | null } | null }; export type InterestBatchesQueryVariables = Exact<{ ids: Array | Scalars['ID']['input']; @@ -1366,7 +1428,7 @@ export type InterestBatchesQuery = { __typename?: 'Query', interestBatches: Arra export type AllInterestRateBracketsQueryVariables = Exact<{ [key: string]: never; }>; -export type AllInterestRateBracketsQuery = { __typename?: 'Query', interestRateBrackets: Array<{ __typename?: 'InterestRateBracket', rate: string, totalDebt: string, collateral: { __typename?: 'Collateral', collIndex: number } }> }; +export type AllInterestRateBracketsQuery = { __typename?: 'Query', interestRateBrackets: Array<{ __typename?: 'InterestRateBracket', rate: string, totalDebt: string, sumDebtTimesRateD36: string, pendingDebtTimesOneYearD36: string, updatedAt: string, collateral: { __typename?: 'Collateral', collIndex: number } }> }; export type GovernanceGlobalDataQueryVariables = Exact<{ [key: string]: never; }>; @@ -1433,12 +1495,17 @@ export const TrovesByAccountDocument = new TypedDocumentString(` closedAt createdAt lastUserActionAt + updatedAt mightBeLeveraged status debt redemptionCount redeemedColl redeemedDebt + liquidatedColl + liquidatedDebt + collSurplus + priceAtLiquidation } } `) as unknown as TypedDocumentString; @@ -1450,6 +1517,7 @@ export const TroveByIdDocument = new TypedDocumentString(` closedAt createdAt lastUserActionAt + updatedAt mightBeLeveraged previousOwner status @@ -1457,6 +1525,10 @@ export const TroveByIdDocument = new TypedDocumentString(` redemptionCount redeemedColl redeemedDebt + liquidatedColl + liquidatedDebt + collSurplus + priceAtLiquidation } } `) as unknown as TypedDocumentString; @@ -1482,6 +1554,9 @@ export const AllInterestRateBracketsDocument = new TypedDocumentString(` } rate totalDebt + sumDebtTimesRateD36 + pendingDebtTimesOneYearD36 + updatedAt } } `) as unknown as TypedDocumentString; diff --git a/frontend/app/src/liquity-delegate.ts b/frontend/app/src/liquity-delegate.ts new file mode 100644 index 00000000..83a6dacf --- /dev/null +++ b/frontend/app/src/liquity-delegate.ts @@ -0,0 +1,55 @@ +import { KNOWN_DELEGATES_URL } from "@/src/env"; +import { useQuery } from "@tanstack/react-query"; +import type { UseQueryResult } from "@tanstack/react-query"; +import { useMemo } from "react"; +import type { InferOutput } from "valibot"; +import * as v from "valibot"; + +const KnownDelegatesSchema = v.array( + v.object({ + name: v.string(), + url: v.string(), + strategies: v.array( + v.object({ + name: v.string(), + address: v.string(), + branches: v.array(v.string()), + hide: v.optional(v.boolean(), false), + }), + ), + }), +); + +export type KnownDelegates = InferOutput; + +export function useKnownDelegates(): UseQueryResult { + return useQuery({ + queryKey: ["knownDelegates"], + queryFn: async () => { + if (!KNOWN_DELEGATES_URL) return null; + + const response = await fetch(KNOWN_DELEGATES_URL); + const data = await response.json(); + return v.parse(KnownDelegatesSchema, data); + }, + }); +} + +export function useDelegateDisplayName(delegateAddress: string | null) { + const knownDelegatesQuery = useKnownDelegates(); + + return useMemo(() => { + if (!delegateAddress || !knownDelegatesQuery.data) return undefined; + + // branchId could be used for filtering delegates by collateral in the future + for (const group of knownDelegatesQuery.data) { + const strategy = group.strategies.find( + (s) => s.address.toLowerCase() === delegateAddress.toLowerCase(), + ); + if (strategy) { + return strategy.name ? `${group.name} - ${strategy.name}` : group.name; + } + } + return undefined; + }, [delegateAddress, knownDelegatesQuery.data]); +} diff --git a/frontend/app/src/liquity-governance.ts b/frontend/app/src/liquity-governance.ts index d6ee9b2c..aa8d253b 100644 --- a/frontend/app/src/liquity-governance.ts +++ b/frontend/app/src/liquity-governance.ts @@ -18,10 +18,11 @@ import { useQuery } from "@tanstack/react-query"; import * as dn from "dnum"; import { useMemo } from "react"; import * as v from "valibot"; +import { InferOutput } from "valibot"; import { erc20Abi, parseAbi } from "viem"; import { useConfig as useWagmiConfig, useReadContract, useReadContracts } from "wagmi"; import { readContract, readContracts } from "wagmi/actions"; -import { InferOutput } from 'valibot'; +import { combineStatus } from "./query-utils"; export type InitiativeStatus = | "nonexistent" @@ -124,21 +125,12 @@ export function useGovernanceState() { } const KnownInitiativesSchema = v.record( - v.pipe( - vAddress(), - v.transform((address) => address.toLowerCase()), - ), - v.pipe( - v.object({ - name: v.string(), - name_link: v.optional(v.pipe(v.string(), v.url())), - group: v.string(), - }), - v.transform(({ name_link = null, ...initiative }) => ({ - ...initiative, - url: name_link, - })), - ), + v.pipe(vAddress(), v.transform((address) => address.toLowerCase())), + v.object({ + name: v.string(), + group: v.string(), + url: v.nullish(v.pipe(v.string(), v.url()), null), + }), ); export type KnownInitiatives = InferOutput; @@ -166,58 +158,58 @@ function useGovernanceGlobalData() { export function useNamedInitiatives() { const knownInitiatives = useKnownInitiatives(); const governanceGlobal = useGovernanceGlobalData(); + const initiativeInfo = useInitiativeInfo(governanceGlobal.data?.registeredInitiatives ?? []); + const status = [knownInitiatives.status, governanceGlobal.status, initiativeInfo.status].reduce(combineStatus); + const isLoading = [knownInitiatives, governanceGlobal, initiativeInfo].some((x) => x.isLoading); + + return useMemo(() => { + if ( + knownInitiatives.data === undefined + || governanceGlobal.data === undefined + || initiativeInfo.data === undefined + ) { + return { + status, + isLoading, + data: undefined, + }; + } - return useQuery({ - queryKey: ["namedInitiatives"], - enabled: ( - knownInitiatives.isSuccess && governanceGlobal.isSuccess - || knownInitiatives.isError - || governanceGlobal.isError - ), - queryFn: () => { - if (knownInitiatives.isError) throw knownInitiatives.error; - if (governanceGlobal.isError) throw governanceGlobal.error; - if (knownInitiatives.isPending || governanceGlobal.isPending) throw new Error("should not happen"); // see enabled + return { + status, + isLoading, - return governanceGlobal.data.registeredInitiatives.map((address): Initiative => { + data: governanceGlobal.data.registeredInitiatives.map((address): Initiative => { const ki = knownInitiatives.data?.[address]; + const ii = initiativeInfo.data[address]; + return { address, - name: ki?.name ?? null, - pairVolume: null, - protocol: ki?.group ?? null, - tvl: null, - url: ki?.url ?? null, - votesDistribution: null, + ...(ki ?? { name: null, group: null, url: null }), + ...(ii ?? { isBribeInitiative: false, bribeToken: null }), }; - }); - }, - }); + }), + }; + }, [status, isLoading, governanceGlobal.data, knownInitiatives.data, initiativeInfo.data]); } export type InitiativeState = Record +}>; export function useInitiativesStates(initiatives: Address[]) { const wagmiConfig = useWagmiConfig(); const Governance = getProtocolContract("Governance"); - // stabilize the order of addresses (cache improvement and predictable) - const sortedAddress = useMemo( - () => [...initiatives].filter(Boolean).sort((a, b) => a.localeCompare(b)), - [initiatives] - ); - return useQuery({ - enabled: sortedAddress.length > 0, - queryKey: ["initiativesStates", sortedAddress.join("")], + enabled: initiatives.length > 0, + queryKey: ["initiativesStates", initiatives], queryFn: async () => { const results = await readContracts(wagmiConfig, { - contracts: sortedAddress.map((address) => ({ + contracts: initiatives.map((address) => ({ ...Governance, functionName: "getInitiativeState", args: [address], @@ -227,8 +219,8 @@ export function useInitiativesStates(initiatives: Address[]) { const initiativesStates: InitiativeState = {}; for (const [i, { result }] of results.entries()) { - if (result && sortedAddress[i]) { - initiativesStates[sortedAddress[i]] = { + if (result && initiatives[i]) { + initiativesStates[initiatives[i]] = { status: initiativeStatusFromNumber(result[0]), lastEpochClaim: result[1], claimableAmount: result[2], @@ -255,7 +247,7 @@ export function useInitiativesVoteTotals(initiatives: Address[]) { // stabilize the order of addresses (cache improvement and predictable) const sortedAddress = useMemo( () => [...initiatives].filter(Boolean).sort((a, b) => a.localeCompare(b)), - [initiatives] + [initiatives], ); return useQuery({ @@ -526,61 +518,33 @@ export type InitiativeBribe = { export type InitiativeBribeResult = Record; export function useCurrentEpochBribes( - initiatives: Address[], + initiatives: Initiative[], ): UseQueryResult { const wagmiConfig = useWagmiConfig(); const govState = useGovernanceState(); - // stabilize the order of addresses (cache improvement and predictable) - const sortedAddress = useMemo( - () => [...initiatives].filter(Boolean).sort((a, b) => a.localeCompare(b)), - [initiatives] - ); - return useQuery({ queryKey: [ "currentEpochBribes", - sortedAddress.join(""), + initiatives, String(govState.data?.epoch), ], + queryFn: async () => { - if (!govState.data || sortedAddress.length === 0) { + if (!govState.data || initiatives.length === 0) { return {}; } - const bribeTokens = await readContracts(wagmiConfig, { - contracts: sortedAddress.map((initiative) => ({ - abi: BribeInitiative, - address: initiative, - functionName: "bribeToken", - } as const)), - // this is needed because some initiatives may revert if they don't have a bribe token - allowFailure: true, - }); - - // initiatives with a bribe token - const bribeInitiatives: Array<{ - initiative: Address; - bribeToken: Address; - }> = []; - - for (const [index, bribeTokenResult] of bribeTokens.entries()) { - if (bribeTokenResult.result && sortedAddress[index]) { - bribeInitiatives.push({ - initiative: sortedAddress[index], - bribeToken: bribeTokenResult.result, - }); - } - } + const bribeInitiatives = initiatives.filter((initiative) => initiative.isBribeInitiative); if (bribeInitiatives.length === 0) { return {}; } const bribeAmounts = await readContracts(wagmiConfig, { - contracts: bribeInitiatives.map(({ initiative }) => ({ + contracts: bribeInitiatives.map(({ address }) => ({ abi: BribeInitiative, - address: initiative, + address, functionName: "bribeByEpoch", args: [govState.data.epoch], } as const)), @@ -602,9 +566,9 @@ export function useCurrentEpochBribes( const bribeInitiative = bribeInitiatives[index]; if (!bribeInitiative) continue; - const { initiative, bribeToken } = bribeInitiative; + const { address, bribeToken } = bribeInitiative; - bribes[initiative] = { + bribes[address] = { boldAmount: dnum18(remainingBold), tokenAmount: dnum18(remainingBribeToken), tokenAddress: bribeToken, @@ -635,6 +599,22 @@ const UserAllocationSchema = v.object({ const UserAllocationHistorySchema = v.array(UserAllocationSchema); +const InitiativeInfoSchema = v.record( + v.pipe(vAddress(), v.transform((address) => address.toLowerCase())), + v.union([ + v.object({ + isBribeInitiative: v.literal(true), + bribeToken: vAddress(), + }), + v.object({ + isBribeInitiative: v.literal(false), + bribeToken: v.null(), + }), + ]), +); + +type InitiativeInfo = InferOutput; + // A user's allocation history ordered by descending epoch async function getUserAllocationHistory(user: Address) { if (LIQUITY_GOVERNANCE_URL) { @@ -664,6 +644,46 @@ async function getLatestCompletedEpoch(currentEpoch: bigint) { } } +function useInitiativeInfo(initiatives: Address[]) { + const useApi = !!LIQUITY_GOVERNANCE_URL; + + const initiativeInfoFromApi = useQuery({ + queryKey: ["initiativeInfoFromApi"], + enabled: useApi, + + queryFn: async () => { + const response = await fetch(`${LIQUITY_GOVERNANCE_URL}/initiatives.json`); + return v.parse(InitiativeInfoSchema, await response.json()); + }, + }); + + const initiativeInfoFromMulticall = useReadContracts({ + allowFailure: true, + + contracts: initiatives.map((initiative) => ({ + abi: BribeInitiative, + address: initiative, + functionName: "bribeToken", + } as const)), + + query: { + enabled: !useApi, + + select: (results): InitiativeInfo => + Object.fromEntries( + results.map(({ result }, i) => [ + initiatives[i], + result + ? { isBribeInitiative: true, bribeToken: result } + : { isBribeInitiative: false, bribeToken: null }, + ]), + ), + }, + }); + + return useApi ? initiativeInfoFromApi : initiativeInfoFromMulticall; +} + // limit checks to the last 52 epochs const BRIBING_CHECK_EPOCH_LIMIT = 52; @@ -681,6 +701,7 @@ export function useBribingClaim( account, String(govState.data?.epoch), jsonStringifyWithBigInt(govUser.data), + jsonStringifyWithBigInt(initiatives.data), ], queryFn: async () => { if (!account || !govState.data || !govUser.data || !initiatives.data) { @@ -699,37 +720,12 @@ export function useBribingClaim( }; } - const [completedEpochs, userAllocations, bribeChecks] = await Promise.all([ + const [completedEpochs, userAllocations] = await Promise.all([ getLatestCompletedEpoch(currentEpoch), getUserAllocationHistory(account), - readContracts(wagmiConfig, { - contracts: initiativesToCheck.map((initiative) => ({ - abi: BribeInitiative, - address: initiative, - functionName: "bribeToken", - } as const)), - allowFailure: true, - }), ]); - const bribeInitiatives: Array<{ - address: Address; - bribeToken: Address; - }> = []; - - for (const [index, token] of bribeChecks.entries()) { - const address = initiativesToCheck[index]?.toLowerCase() as Address | undefined; - if ( - address - && token.result - && userAllocations.find((allocation) => allocation.initiative === address && allocation.voteLQTY > 0n) - ) { - bribeInitiatives.push({ - address, - bribeToken: token.result, - }); - } - } + const bribeInitiatives = initiatives.data.filter((initiative) => initiative.isBribeInitiative); if (bribeInitiatives.length === 0) { return { diff --git a/frontend/app/src/liquity-leverage.ts b/frontend/app/src/liquity-leverage.ts index a81191be..6f39a009 100644 --- a/frontend/app/src/liquity-leverage.ts +++ b/frontend/app/src/liquity-leverage.ts @@ -1,247 +1,214 @@ -import type { BranchId, Dnum, TroveId } from "@/src/types"; -import type { Config as WagmiConfig } from "wagmi"; +import type { FlowStep } from "@/src/services/TransactionFlow"; +import type { BranchId, CollateralSymbol, Dnum } from "@/src/types"; -import { CLOSE_FROM_COLLATERAL_SLIPPAGE } from "@/src/constants"; -import { getProtocolContract } from "@/src/contracts"; -import { dnum18 } from "@/src/dnum-utils"; +import { getBranchContract, getProtocolContract } from "@/src/contracts"; +import { dnum18, DNUM_0 } from "@/src/dnum-utils"; import { getBranch } from "@/src/liquity-utils"; -import { useDebouncedQueryKey } from "@/src/react-utils"; import { useQuery } from "@tanstack/react-query"; import * as dn from "dnum"; -import { useConfig as useWagmiConfig } from "wagmi"; -import { readContract, readContracts } from "wagmi/actions"; - -const DECIMAL_PRECISION = 10n ** 18n; - -export async function getLeverUpTroveParams( - branchId: BranchId, - troveId: TroveId, - leverageFactor: number, - wagmiConfig: WagmiConfig, +import { useMemo } from "react"; +import { parseEventLogs } from "viem"; +import { useConfig as useWagmiConfig, useReadContracts } from "wagmi"; +import { getTransactionReceipt } from "wagmi/actions"; +import { useDebounced } from "./react-utils"; +import { WHITE_LABEL_CONFIG } from "./white-label.config"; + +const MARGINAL_AMOUNT_DIVIDER = 1_000n; + +export type SwapDirection = + | { inputToken: typeof WHITE_LABEL_CONFIG.tokens.mainToken.symbol; outputToken: CollateralSymbol } + | { inputToken: CollateralSymbol; outputToken: typeof WHITE_LABEL_CONFIG.tokens.mainToken.symbol }; + +export type QuoteExactInputParams = SwapDirection & { + inputAmount: Dnum; +}; + +export type QuoteExactOutputParams = SwapDirection & { + outputAmount: Dnum; +}; + +function calcPriceImpact( + inputAmount: bigint, + inputAmountMarginal: bigint, + outputAmount: bigint, + outputAmountMarginal: bigint, ) { - const { PriceFeed, TroveManager } = getBranch(branchId).contracts; - const [priceResult, troveDataResult] = await readContracts(wagmiConfig, { - contracts: [{ - abi: PriceFeed.abi, - address: PriceFeed.address, - functionName: "fetchPrice", - }, { - abi: TroveManager.abi, - address: TroveManager.address, - functionName: "getLatestTroveData", - args: [BigInt(troveId)], - }], - }); - - const [price] = priceResult.result ?? []; - const troveData = troveDataResult.result; - - if (!price || !troveData) { - return null; - } - - const currentCR = await readContract(wagmiConfig, { - abi: TroveManager.abi, - address: TroveManager.address, - functionName: "getCurrentICR", - args: [BigInt(troveId), price], - }); - - const currentLR = leverageRatioToCollateralRatio(currentCR); - - const leverageRatio = BigInt(leverageFactor * 1000) * DECIMAL_PRECISION / 1000n; - if (leverageRatio <= currentLR) { - throw new Error(`Multiply ratio should increase: ${leverageRatio} <= ${currentLR}`); - } + if (inputAmount == 0n || inputAmountMarginal == 0n) return null; - const currentCollAmount = troveData.entireColl; - const flashLoanAmount = currentCollAmount * leverageRatio / currentLR - currentCollAmount; - const expectedBoldAmount = flashLoanAmount * price / DECIMAL_PRECISION; - const maxNetDebtIncrease = expectedBoldAmount * 105n / 100n; // 5% slippage + const exchangeRate = dn.div(outputAmount, inputAmount, 18); + const exchangeRateMarginal = dn.div(outputAmountMarginal, inputAmountMarginal, 18); - return { - flashLoanAmount, - effectiveBoldAmount: maxNetDebtIncrease, - }; + if (dn.eq(exchangeRateMarginal, DNUM_0)) return null; + return dn.div(dn.sub(exchangeRateMarginal, exchangeRate), exchangeRateMarginal); } -export async function getLeverDownTroveParams( - branchId: BranchId, - troveId: TroveId, - leverageFactor: number, - wagmiConfig: WagmiConfig, -) { - const { PriceFeed, TroveManager } = getBranch(branchId).contracts; - const [priceResult, troveDataResult] = await readContracts(wagmiConfig, { - contracts: [{ - abi: PriceFeed.abi, - address: PriceFeed.address, - functionName: "fetchPrice", - }, { - abi: TroveManager.abi, - address: TroveManager.address, - functionName: "getLatestTroveData", - args: [BigInt(troveId)], - }], - }); - - const [price] = priceResult.result ?? []; - const troveData = troveDataResult.result; - - if (!price || !troveData) { - return null; - } - - const currentCR = await readContract(wagmiConfig, { - abi: TroveManager.abi, - address: TroveManager.address, - functionName: "getCurrentICR", - args: [BigInt(troveId), price], +export function useQuoteExactInput(params: QuoteExactInputParams) { + const inputAmount = dn.from(params.inputAmount, 18)[0]; + const inputAmountMarginal = inputAmount / MARGINAL_AMOUNT_DIVIDER; + const collToBold = params.outputToken === WHITE_LABEL_CONFIG.tokens.mainToken.symbol; + const collToken = getBranchContract(collToBold ? params.inputToken : params.outputToken, "CollToken").address; + + const values = useMemo(() => ({ + inputAmount, + inputAmountMarginal, + collToBold, + collToken, + }), [inputAmount, inputAmountMarginal, collToBold, collToken]); + + const [debounced, bouncing] = useDebounced(values); + const ExchangeHelpersV2 = getProtocolContract("ExchangeHelpersV2"); + + return useReadContracts({ + contracts: [ + { + ...ExchangeHelpersV2, + functionName: "quoteExactInput", + args: [debounced.inputAmount, debounced.collToBold, debounced.collToken], + }, + { + ...ExchangeHelpersV2, + functionName: "quoteExactInput", + args: [debounced.inputAmountMarginal, debounced.collToBold, debounced.collToken], + }, + ], + + query: { + refetchInterval: 12_000, + enabled: !bouncing && debounced.inputAmountMarginal > 0n, // implies debounced.inputAmount > 0n + + select: (data) => + data[0].status === "failure" + ? ({ bouncing, outputAmount: null, priceImpact: null }) + : ({ + bouncing, + outputAmount: dnum18(data[0].result), + priceImpact: calcPriceImpact( + debounced.inputAmount, + debounced.inputAmountMarginal, + data[0].result, + data[1].result ?? 0n, + ), + }), + }, }); - - const currentLR = leverageRatioToCollateralRatio(currentCR); - - const leverageRatio = BigInt(leverageFactor * 1000) * DECIMAL_PRECISION / 1000n; - if (leverageRatio >= currentLR) { - throw new Error(`Multiply ratio should decrease: ${leverageRatio} >= ${currentLR}`); - } - - const currentCollAmount = troveData.entireColl; - const flashLoanAmount = currentCollAmount - (currentCollAmount * leverageRatio / currentLR); - const expectedBoldAmount = flashLoanAmount * price / DECIMAL_PRECISION; - const minBoldDebt = expectedBoldAmount * 95n / 100n; // 5% slippage - - return { - flashLoanAmount, - minBoldAmount: minBoldDebt, - }; } -// from openLeveragedTroveWithIndex() in contracts/src/test/zapperLeverage.t.sol -export async function getOpenLeveragedTroveParams( - branchId: BranchId, - collAmount: bigint, - leverageFactor: number, - wagmiConfig: WagmiConfig, -) { - const { PriceFeed } = getBranch(branchId).contracts; - const FetchPriceAbi = PriceFeed.abi.find((fn) => fn.name === "fetchPrice"); - if (!FetchPriceAbi) { - throw new Error("fetchPrice ABI not found"); - } - const [price] = await readContract(wagmiConfig, { - abi: [{ ...FetchPriceAbi, stateMutability: "view" }] as const, - address: PriceFeed.address, - functionName: "fetchPrice", +export function useQuoteExactOutput(params: QuoteExactOutputParams) { + const outputAmount = dn.from(params.outputAmount, 18)[0]; + const outputAmountMarginal = outputAmount / MARGINAL_AMOUNT_DIVIDER; + const collToBold = params.outputToken === WHITE_LABEL_CONFIG.tokens.mainToken.symbol; + const collToken = getBranchContract(collToBold ? params.inputToken : params.outputToken, "CollToken").address; + + const values = useMemo(() => ({ + outputAmount, + outputAmountMarginal, + collToBold, + collToken, + }), [outputAmount, outputAmountMarginal, collToBold, collToken]); + + const [debounced, bouncing] = useDebounced(values); + const ExchangeHelpersV2 = getProtocolContract("ExchangeHelpersV2"); + + return useReadContracts({ + contracts: [ + { + ...ExchangeHelpersV2, + functionName: "quoteExactOutput", + args: [debounced.outputAmount, debounced.collToBold, debounced.collToken], + }, + { + ...ExchangeHelpersV2, + functionName: "quoteExactOutput", + args: [debounced.outputAmountMarginal, debounced.collToBold, debounced.collToken], + }, + ], + + query: { + refetchInterval: 12_000, + enabled: !bouncing && debounced.outputAmountMarginal > 0n, // implies debounced.outputAmount > 0n + + select: (data) => + data[0].status === "failure" + ? ({ bouncing, inputAmount: null, priceImpact: null }) + : ({ + bouncing, + inputAmount: dnum18(data[0].result), + priceImpact: calcPriceImpact( + data[0].result, + data[1].result ?? 0n, + debounced.outputAmount, + debounced.outputAmountMarginal, + ), + }), + }, }); - - const leverageRatio = BigInt(leverageFactor * 1000) * DECIMAL_PRECISION / 1000n; - const flashLoanAmount = collAmount * (leverageRatio - DECIMAL_PRECISION) / DECIMAL_PRECISION; - const expectedBoldAmount = flashLoanAmount * price / DECIMAL_PRECISION; - const maxNetDebt = expectedBoldAmount * 105n / 100n; // 5% slippage - - return { - effectiveBoldAmount: maxNetDebt, - expectedBoldAmount, - flashLoanAmount, - maxNetDebt, - price, - }; } -// from _getCloseFlashLoanAmount() in contracts/src/test/zapperLeverage.t.sol -export async function getCloseFlashLoanAmount( +/** + * Extracts the slippage refund amount from a leverage or close position transaction receipt. + * The slippage refund is a Transfer event from the zapper contract to the user's wallet. + * + * Supported transaction types: + * - openLeveragedTrove: Opening a leverage position + * - leverUpTrove: Increasing leverage + * - leverDownTrove: Decreasing leverage + * - closeLoanPosition: Closing position (when repaying with collateral) + */ +export function useSlippageRefund( branchId: BranchId, - troveId: TroveId, - wagmiConfig: WagmiConfig, -): Promise { - const { PriceFeed, TroveManager } = getBranch(branchId).contracts; - const [priceResult, latestTroveDataResult] = await readContracts(wagmiConfig, { - contracts: [{ - abi: PriceFeed.abi, - address: PriceFeed.address, - functionName: "fetchPrice", - }, { - abi: TroveManager.abi, - address: TroveManager.address, - functionName: "getLatestTroveData", - args: [BigInt(troveId)], - }], - }); - - const [price] = priceResult.result ?? []; - const latestTroveData = latestTroveDataResult.result; - - if (!price || !latestTroveData) { - return null; - } - - return ( - latestTroveData.entireDebt * DECIMAL_PRECISION - / price - * BigInt(100 + CLOSE_FROM_COLLATERAL_SLIPPAGE * 100) - / 100n - ); -} - -function leverageRatioToCollateralRatio(inputRatio: bigint) { - return inputRatio * DECIMAL_PRECISION / (inputRatio - DECIMAL_PRECISION); -} - -export function useCheckLeverageSlippage({ - branchId, - initialDeposit, - leverageFactor, - ownerIndex, -}: { - branchId: BranchId; - initialDeposit: Dnum | null; - leverageFactor: number; - ownerIndex: number | null; -}) { + account: string, + steps: FlowStep[] | null, + isCloseLoanToCollateral = false, +) { const wagmiConfig = useWagmiConfig(); - const WethContract = getProtocolContract("WETH"); - const ExchangeHelpersContract = getProtocolContract("ExchangeHelpers"); - - const debouncedQueryKey = useDebouncedQueryKey([ - "openLeveragedTroveParams", - branchId, - String(!initialDeposit || initialDeposit[0]), - leverageFactor, - ownerIndex, - ], 100); + const branch = getBranch(branchId); + + // Find the last confirmed step that might have a slippage refund + const relevantStep = steps + ?.slice() + .reverse() + .find( + (step) => + ( + step.id === "leverUpTrove" + || step.id === "leverDownTrove" + || (step.id === "closeLoanPosition" && isCloseLoanToCollateral) + || step.id === "openLeveragedTrove" + ) + && step.status === "confirmed" + && step.artifact, + ); return useQuery({ - queryKey: debouncedQueryKey, + enabled: Boolean(relevantStep?.artifact), + queryKey: ["slippage-refund", relevantStep?.artifact], queryFn: async () => { - const params = initialDeposit && (await getOpenLeveragedTroveParams( - branchId, - initialDeposit[0], - leverageFactor, - wagmiConfig, - )); - - if (params === null) { - return null; - } - - const [_, slippage] = await readContract(wagmiConfig, { - abi: ExchangeHelpersContract.abi, - address: ExchangeHelpersContract.address, - functionName: "getCollFromBold", - args: [ - params.expectedBoldAmount, - WethContract.address, - params.flashLoanAmount, - ], + if (!relevantStep?.artifact) return null; + + const receipt = await getTransactionReceipt(wagmiConfig, { + hash: relevantStep.artifact as `0x${string}`, + }); + + const transferEvents = parseEventLogs({ + abi: branch.contracts.CollToken.abi, + logs: receipt.logs, + eventName: "Transfer", }); - return dnum18(slippage); + // Find transfer from zapper to user (slippage refund) + const zapperAddress = branch.symbol === "ETH" + ? branch.contracts.LeverageWETHZapper.address.toLowerCase() + : branch.contracts.LeverageLSTZapper.address.toLowerCase(); + + const slippageTransfer = transferEvents.find( + (event) => + event.args.from?.toLowerCase() === zapperAddress + && event.args.to?.toLowerCase() === account.toLowerCase(), + ); + + return slippageTransfer?.args.value + ? dnum18(slippageTransfer.args.value) + : null; }, - enabled: Boolean( - initialDeposit - && dn.gt(initialDeposit, 0) - && ownerIndex !== null, - ), }); } diff --git a/frontend/app/src/liquity-math.test.ts b/frontend/app/src/liquity-math.test.ts index 75861b31..83af806b 100644 --- a/frontend/app/src/liquity-math.test.ts +++ b/frontend/app/src/liquity-math.test.ts @@ -33,10 +33,7 @@ const d = (value: number | bigint): Dnum => ( test("getRedemptionRisk() works", () => { const totalDebt = d(1_000_000); // 1M total debt - expect(getRedemptionRisk(null, totalDebt)).toBe(null); - expect(getRedemptionRisk(d(100_000), null)).toBe(null); - expect(getRedemptionRisk(null, null)).toBe(null); - expect(getRedemptionRisk(d(100_000), d(0))).toBe(null); + expect(getRedemptionRisk(d(0), d(0))).toBe("not-applicable"); // high risk: low debtInFront ratio expect(getRedemptionRisk(d(0), totalDebt)).toBe("high"); diff --git a/frontend/app/src/liquity-math.ts b/frontend/app/src/liquity-math.ts index 40bc45f3..d89d732b 100644 --- a/frontend/app/src/liquity-math.ts +++ b/frontend/app/src/liquity-math.ts @@ -1,16 +1,16 @@ import type { LoanDetails, RiskLevel } from "@/src/types"; import type { Dnum } from "dnum"; -import { LTV_RISK, MAX_LTV_ALLOWED_RATIO, REDEMPTION_RISK } from "@/src/constants"; +import { LEVERAGE_FACTOR_PRECISION, LTV_RISK, MAX_LTV_ALLOWED_RATIO, REDEMPTION_RISK } from "@/src/constants"; import * as dn from "dnum"; import { match } from "ts-pattern"; export function getRedemptionRisk( - debtInFront: Dnum | null, - totalDebt: Dnum | null, -): null | RiskLevel { - if (!debtInFront || !totalDebt || dn.eq(totalDebt, 0)) { - return null; + debtInFront: Dnum, + totalDebt: Dnum, +): RiskLevel { + if (dn.eq(totalDebt, 0)) { + return "not-applicable"; } const debtInFrontRatio = dn.div(debtInFront, totalDebt); @@ -27,6 +27,7 @@ export function getLiquidationRisk(ltv: Dnum, maxLtv: Dnum): RiskLevel { .returnType() .when((ltv) => dn.gt(ltv, dn.mul(maxLtv, LTV_RISK.high)), () => "high") .when((ltv) => dn.gt(ltv, dn.mul(maxLtv, LTV_RISK.medium)), () => "medium") + .when((ltv) => dn.eq(ltv, 0), () => "not-applicable") .otherwise(() => "low"); } @@ -46,6 +47,13 @@ export function getLeverageFactorFromLtv(ltv: Dnum): number { return 1 / (1 - dn.toNumber(ltv)); } +export function roundLeverageFactor(leverageFactor: number) { + return Math.round( + leverageFactor + / LEVERAGE_FACTOR_PRECISION, + ) * LEVERAGE_FACTOR_PRECISION; +} + export function getLeverageFactorFromLiquidationPrice( liquidationPrice: Dnum, collPrice: Dnum, @@ -53,16 +61,19 @@ export function getLeverageFactorFromLiquidationPrice( ): null | number { const collPriceRatio = dn.mul(collPrice, minCollRatio); - if (!dn.lt(liquidationPrice, collPriceRatio)) { + if (dn.gte(liquidationPrice, collPriceRatio)) { return null; } - return Math.round( - dn.toNumber(dn.div( + return dn.toNumber( + dn.div( collPriceRatio, - dn.sub(collPriceRatio, liquidationPrice), - )) * 10, - ) / 10; + dn.sub( + collPriceRatio, + liquidationPrice, + ), + ), + ); } export function getLiquidationPriceFromLeverage( @@ -109,6 +120,19 @@ export function getLtv( return dn.gt(depositUsd, 0) ? dn.div(debt, depositUsd) : null; } +export function getLoanChanges( + currentDeposit: Dnum, + newDeposit: Dnum, + currentDebt: Dnum, + newDebt: Dnum, + collPrice: Dnum, +): { loanCollChange: Dnum; loanDebtChange: Dnum } { + const loanCollChange = dn.mul(dn.sub(newDeposit, currentDeposit), collPrice); + const loanDebtChange = dn.sub(newDebt, currentDebt); + + return { loanCollChange, loanDebtChange }; +} + export function getLoanDetails( deposit: Dnum | null, debt: Dnum | null, diff --git a/frontend/app/src/liquity-utils.ts b/frontend/app/src/liquity-utils.ts index d58c12be..b0d2adca 100644 --- a/frontend/app/src/liquity-utils.ts +++ b/frontend/app/src/liquity-utils.ts @@ -8,7 +8,6 @@ import type { PositionLoanCommitted, PositionStake, PrefixedTroveId, - RiskLevel, Token, TokenSymbol, TroveId, @@ -27,12 +26,15 @@ import { INTEREST_RATE_INCREMENT_PRECISE, INTEREST_RATE_PRECISE_UNTIL, INTEREST_RATE_START, + ONE_YEAR_D18, TROVE_STATUS_ZOMBIE, } from "@/src/constants"; import { CONTRACTS, getBranchContract, getProtocolContract } from "@/src/contracts"; import { dnum18, DNUM_0, dnumOrNull, jsonStringifyWithDnum } from "@/src/dnum-utils"; import { CHAIN_BLOCK_EXPLORER, ENV_BRANCHES, LEGACY_CHECK, LIQUITY_STATS_URL } from "@/src/env"; import { getRedemptionRisk } from "@/src/liquity-math"; +import { combineStatus } from "@/src/query-utils"; +import { useDebounced } from "@/src/react-utils"; import { usePrice } from "@/src/services/Prices"; import { getAllInterestRateBrackets, @@ -51,7 +53,7 @@ import * as dn from "dnum"; import { useMemo } from "react"; import * as v from "valibot"; import { encodeAbiParameters, erc20Abi, isAddressEqual, keccak256, parseAbiParameters, zeroAddress } from "viem"; -import { useBalance, useConfig as useWagmiConfig, useReadContract, useReadContracts } from "wagmi"; +import { useBalance, useConfig as useWagmiConfig, useReadContract, useReadContracts, useSimulateContract } from "wagmi"; import { readContract, readContracts } from "wagmi/actions"; function isLegacyCheckObject(check: typeof LEGACY_CHECK): check is { BRANCHES: Array; [key: string]: any } { @@ -132,6 +134,19 @@ export function getBranches(): Branch[] { }); } +export function getTokenDisplayName(symbol: TokenSymbol) { + const token = TOKENS_BY_SYMBOL[symbol]; + + switch (symbol) { + case "SBOLD": + return "sBOLD by K3 Capital"; + case "YBOLD": + return "yBOLD by Yearn"; + default: + return token?.name ?? symbol; + } +} + export function getBranchesCount(): number { return ENV_BRANCHES.length; } @@ -368,7 +383,7 @@ export function useEarnPositionsByAccount(account: null | Address) { }); } -export function useStakePosition(address: null | Address) { +export function useStakePosition(address: null | Address, version: "v1" | "v2" = "v2") { const LqtyStaking = getProtocolContract("LqtyStaking"); const LusdToken = getProtocolContract("LusdToken"); const Governance = getProtocolContract("Governance"); @@ -389,23 +404,28 @@ export function useStakePosition(address: null | Address) { }, }); + let userStakingAddress = address ?? "0x"; + if (version === "v2") { + userStakingAddress = userProxyAddress.data ?? "0x"; + } + return useReadContracts({ contracts: [{ ...LqtyStaking, functionName: "stakes", - args: [userProxyAddress.data ?? "0x"], + args: [userStakingAddress], }, { ...LqtyStaking, functionName: "getPendingETHGain", - args: [userProxyAddress.data ?? "0x"], + args: [userStakingAddress], }, { ...LqtyStaking, functionName: "getPendingLUSDGain", - args: [userProxyAddress.data ?? "0x"], + args: [userStakingAddress], }, { ...LusdToken, functionName: "balanceOf", - args: [userProxyAddress.data ?? "0x"], + args: [userStakingAddress], }], query: { enabled: Boolean(address) && userProxyAddress.isSuccess && userProxyBalance.isSuccess, @@ -438,138 +458,157 @@ export function useStakePosition(address: null | Address) { }); } +export function useV1StabilityPoolLqtyGain(address: null | Address) { + const V1StabilityPool = getProtocolContract("V1StabilityPool"); + + return useReadContract({ + ...V1StabilityPool, + functionName: "getDepositorLQTYGain", + args: [address ?? "0x"], + query: { + enabled: Boolean(address), + select: (result) => dnum18(result), + }, + }); +} + export function useTroveNftUrl(branchId: null | BranchId, troveId: null | TroveId) { const TroveNft = getBranchContract(branchId, "TroveNFT"); return TroveNft && troveId && `${CHAIN_BLOCK_EXPLORER?.url}nft/${TroveNft.address}/${BigInt(troveId)}`; } export function useInterestRateBrackets(branchId: BranchId) { - const brackets = useAllInterestRateBrackets(); - return useQuery({ - queryKey: ["InterestRateBrackets", branchId], - enabled: brackets.status !== "pending", - queryFn: () => { - if (brackets.status === "error") { - throw brackets.error; - } + const { status, data } = useAllInterestRateBrackets(); - if (brackets.status === "pending") { - throw new Error(); // should not reach - } + return useMemo(() => { + if (!data) return { status, data }; - return brackets.data.filter((bracket) => bracket.branchId === branchId); - }, - }); + return { + status, + data: { + lastUpdatedAt: data.lastUpdatedAt, + brackets: data.brackets.filter((bracket) => bracket.branchId === branchId), + }, + }; + }, [branchId, status, data]); } -export function useAllInterestRateBrackets() { +function useAllInterestRateBrackets() { return useQuery({ queryKey: ["AllInterestRateBrackets"], - queryFn: () => getAllInterestRateBrackets(), + queryFn: getAllInterestRateBrackets, }); } export function useAverageInterestRate(branchId: BranchId) { - const brackets = useInterestRateBrackets(branchId); + const { status, data } = useInterestRateBrackets(branchId); - const data = useMemo(() => { - if (!brackets.isSuccess) { - return null; - } + return useMemo(() => { + if (!data) return { status, data }; let totalDebt = DNUM_0; let totalWeightedRate = DNUM_0; - for (const bracket of brackets.data) { - totalDebt = dn.add(totalDebt, bracket.totalDebt); - totalWeightedRate = dn.add( - totalWeightedRate, - dn.mul(bracket.rate, bracket.totalDebt), - ); + for (const bracket of data.brackets) { + totalDebt = dn.add(totalDebt, bracket.totalDebt(BigInt(Math.floor(Date.now() / 1000)))); + totalWeightedRate = dn.add(totalWeightedRate, bracket.totalWeightedRate); } - return dn.eq(totalDebt, 0) - ? DNUM_0 - : dn.div(totalWeightedRate, totalDebt); - }, [brackets.isSuccess, brackets.data]); - - return { - ...brackets, - data, - }; + return { + status, + data: dn.eq(totalDebt, 0) + ? DNUM_0 + : dn.div(totalWeightedRate, totalDebt), + }; + }, [status, data]); } export function useInterestRateChartData(branchId: BranchId, excludedLoan?: PositionLoanCommitted) { - const brackets = useInterestRateBrackets(branchId); - return useQuery({ - queryKey: ["useInterestRateChartData", jsonStringifyWithDnum(brackets.data), excludedLoan?.troveId], - queryFn: () => { - if (!brackets.isSuccess) { - throw new Error(); // should never happen (see enabled) - } + const { status, data } = useInterestRateBrackets(branchId); + + return useMemo(() => { + if (!data) return { status, data }; + + // brackets or loan could have been updated in the "future", if client clock is running behind + // or the blockchain clock has been fast-forwarded, e.g. in case of Anvil + const timestamp = BigInt( + Math.floor( + Math.max( + Date.now(), + Number(data.lastUpdatedAt) * 1000, + ...(excludedLoan ? [excludedLoan.updatedAt] : []), + ) / 1000, + ), + ); - const debtByRate = new Map( - brackets.data - .filter(({ rate }) => dn.gte(rate, INTEREST_RATE_START) && dn.lte(rate, INTEREST_RATE_END)) - .map((bracket) => [dn.toJSON(bracket.rate), bracket.totalDebt]), + const debtByRate = new Map( + data.brackets + .filter(({ rate }) => dn.gte(rate, INTEREST_RATE_START) && dn.lte(rate, INTEREST_RATE_END)) + .map((bracket) => [dn.toJSON(bracket.rate), bracket.totalDebt(timestamp)]), + ); + + const chartData = []; + let currentRate = dn.from(INTEREST_RATE_START, 18); + let debtInFront = DNUM_0; + let highestDebt = DNUM_0; + + while (dn.lte(currentRate, INTEREST_RATE_END)) { + const nextRate = dn.add( + currentRate, + dn.lt(currentRate, INTEREST_RATE_PRECISE_UNTIL) + ? INTEREST_RATE_INCREMENT_PRECISE + : INTEREST_RATE_INCREMENT_NORMAL, ); - const chartData = []; - let currentRate = dn.from(INTEREST_RATE_START, 18); - let debtInFront = DNUM_0; - let highestDebt = DNUM_0; - - while (dn.lte(currentRate, INTEREST_RATE_END)) { - const nextRate = dn.add( - currentRate, - dn.lt(currentRate, INTEREST_RATE_PRECISE_UNTIL) - ? INTEREST_RATE_INCREMENT_PRECISE - : INTEREST_RATE_INCREMENT_NORMAL, + let aggregatedDebt = DNUM_0; // debt between currentRate and nextRate + let stepRate = currentRate; + while (dn.lt(stepRate, nextRate)) { + aggregatedDebt = dn.add( + aggregatedDebt, + debtByRate.get(dn.toJSON(stepRate)) ?? DNUM_0, ); + stepRate = dn.add(stepRate, INTEREST_RATE_INCREMENT_PRECISE); + } - let aggregatedDebt = DNUM_0; // debt between currentRate and nextRate - let stepRate = currentRate; - while (dn.lt(stepRate, nextRate)) { - aggregatedDebt = dn.add( - aggregatedDebt, - debtByRate.get(dn.toJSON(stepRate)) ?? DNUM_0, - ); - stepRate = dn.add(stepRate, INTEREST_RATE_INCREMENT_PRECISE); - } - - // exclude own debt from debt-in front calculation - if ( - excludedLoan - && dn.gte(excludedLoan.interestRate, currentRate) - && dn.lt(excludedLoan.interestRate, nextRate) - ) { - aggregatedDebt = dn.sub(aggregatedDebt, excludedLoan.indexedDebt); - } + // exclude own debt from debt-in front calculation + if ( + excludedLoan + && dn.gte(excludedLoan.interestRate, currentRate) + && dn.lt(excludedLoan.interestRate, nextRate) + ) { + const updatedAt = BigInt(excludedLoan.updatedAt / 1000); // should be divisible by 1000 + const pendingDebt = dnum18( + dn.from(excludedLoan.recordedDebt, 18)[0] + * dn.from(excludedLoan.interestRate, 18)[0] + * (timestamp - updatedAt) + / ONE_YEAR_D18, + ); + const excludedDebt = dn.add(excludedLoan.recordedDebt, pendingDebt); + aggregatedDebt = dn.sub(aggregatedDebt, excludedDebt); + } - chartData.push({ - debt: aggregatedDebt, - debtInFront, - rate: currentRate, - size: dn.toNumber(aggregatedDebt), - }); + chartData.push({ + debt: aggregatedDebt, + debtInFront, + rate: currentRate, + size: dn.toNumber(aggregatedDebt), + }); - debtInFront = dn.add(debtInFront, aggregatedDebt); - currentRate = nextRate; - if (dn.gt(aggregatedDebt, highestDebt)) highestDebt = aggregatedDebt; - } + debtInFront = dn.add(debtInFront, aggregatedDebt); + currentRate = nextRate; + if (dn.gt(aggregatedDebt, highestDebt)) highestDebt = aggregatedDebt; + } - // normalize size between 0 and 1 - if (highestDebt[0] !== 0n) { - const divisor = dn.toNumber(highestDebt); - for (const datum of chartData) { - datum.size /= divisor; - } + // normalize size between 0 and 1 + if (highestDebt[0] !== 0n) { + const divisor = dn.toNumber(highestDebt); + for (const datum of chartData) { + datum.size /= divisor; } + } - return chartData; - }, - enabled: brackets.isSuccess, - }); + return { status, data: chartData }; + }, [status, data]); } export function findClosestRateIndex( @@ -743,11 +782,6 @@ export const StatsSchema = v.pipe( v.string(), ), boldYield: v.optional(v.nullable(v.array(BoldYieldItem))), - // TODO: phase out in the future, once all frontends update to the "safe" (losely-typed) `prices` schema - otherPrices: v.optional(v.record( - v.string(), - v.string(), - )), branch: v.record( v.string(), v.object({ @@ -766,6 +800,14 @@ export const StatsSchema = v.pipe( value_locked: v.string(), }), ), + yBOLD: v.nullish(v.object({ + protocol: v.string(), + asset: v.string(), + link: v.string(), + weekly_apr: v.number(), + total_apr: v.string(), + tvl: v.number(), + })), }), v.transform((value) => ({ totalBoldSupply: dnumOrNull(value.total_bold_supply, 18), @@ -796,11 +838,7 @@ export const StatsSchema = v.pipe( }), ), prices: Object.fromEntries( - [ - ...Object.entries(value.prices), - // TODO: phase out in the future, once all frontends update to the "safe" (losely-typed) `prices` schema - ...Object.entries(value.otherPrices ?? {}), - ].map(([symbol, price]) => [ + Object.entries(value.prices).map(([symbol, price]) => [ symbol, dnumOrNull(price, 18), ]), @@ -812,6 +850,14 @@ export const StatsSchema = v.pipe( link: i.link, protocol: i.protocol, })), + yBOLD: value.yBOLD && { + protocol: value.yBOLD.protocol, + asset: value.yBOLD.asset, + link: value.yBOLD.link, + weeklyApr: dnumOrNull(value.yBOLD.weekly_apr, 18), + totalApr: value.yBOLD.total_apr, + tvl: dnumOrNull(value.yBOLD.tvl, 18), + }, })), ); @@ -906,9 +952,8 @@ export function useLoan(branchId: BranchId, troveId: TroveId): UseQueryResult({ queryKey: ["TroveById", id], - queryFn: () => ( - id ? fetchLoanById(wagmiConfig, id) : null - ), + queryFn: () => id ? fetchLoanById(wagmiConfig, id) : null, + refetchInterval: 60_000, }); } @@ -1043,7 +1088,8 @@ export async function fetchLoanById( branchId, createdAt: indexedTrove.createdAt, lastUserActionAt: indexedTrove.lastUserActionAt, - indexedDebt: indexedTrove.debt, + updatedAt: indexedTrove.updatedAt, + recordedDebt: indexedTrove.debt, deposit: dnum18(troveData.entireColl), interestRate: dnum18(troveData.annualInterestRate), status: indexedTrove.status, @@ -1052,6 +1098,10 @@ export async function fetchLoanById( redemptionCount: indexedTrove.redemptionCount, redeemedColl: indexedTrove.redeemedColl, redeemedDebt: indexedTrove.redeemedDebt, + liquidatedColl: indexedTrove.liquidatedColl, + liquidatedDebt: indexedTrove.liquidatedDebt, + collSurplus: indexedTrove.collSurplus, + priceAtLiquidation: indexedTrove.priceAtLiquidation, }; } @@ -1307,59 +1357,285 @@ export function useNextOwnerIndex( }); } -export function useDebtPositioning(branchId: BranchId, interestRate: Dnum | null) { - const chartData = useInterestRateChartData(branchId); +const interestRateFloor = (rate: Dnum) => + dn.mul( + dn.floor( + dn.div( + rate, + INTEREST_RATE_INCREMENT_PRECISE, + ), + ), + INTEREST_RATE_INCREMENT_PRECISE, + ); + +function useDebtInFrontOfBracket(branchId: BranchId, bracketRate: Dnum) { + const { status, data } = useInterestRateBrackets(branchId); + + return useMemo(() => { + if (!data) return { status, data }; + + return { + status, + + data: data && ((timestamp: bigint) => { + const brackets = data.brackets.map( + ({ rate, totalDebt }) => ({ + rate, + totalDebt: totalDebt(timestamp), + }), + ); + + const bracketsInFront = brackets.filter( + (bracket) => dn.lt(bracket.rate, bracketRate), + ); + + return { + debtInFront: bracketsInFront.map((bracket) => bracket.totalDebt).reduce((a, b) => dn.add(a, b), DNUM_0), + totalDebt: brackets.map((bracket) => bracket.totalDebt).reduce((a, b) => dn.add(a, b), DNUM_0), + }; + }), + }; + }, [status, data, bracketRate]); +} + +export type UseDebtInFrontOfLoanParams = Readonly< + Pick< + PositionLoanCommitted, + | "branchId" + | "troveId" + | "interestRate" + | "status" + | "isZombie" + > +>; + +export const EMPTY_LOAN: UseDebtInFrontOfLoanParams = { + branchId: 0, + troveId: "0x0", + interestRate: DNUM_0, + status: "closed", + isZombie: true, +}; + +export function useDebtInFrontOfLoan(loan: UseDebtInFrontOfLoanParams) { + const redeemable = (loan.status === "active" || loan.status === "redeemed") && !loan.isZombie; + const ownBracket = interestRateFloor(loan.interestRate); + const debtInFrontOfOwnBracket = useDebtInFrontOfBracket(loan.branchId, ownBracket); + const { contracts: { SortedTroves } } = getBranch(loan.branchId); + const numTroves = useReadContract({ ...SortedTroves, functionName: "getSize", query: { enabled: redeemable } }); + const DebtInFrontHelper = getProtocolContract("DebtInFrontHelper"); + + const debtInFrontOfLoanWithinOwnBracket = useReadContract({ + ...DebtInFrontHelper, + query: { enabled: redeemable && numTroves.data !== undefined }, + functionName: "getDebtBetweenInterestRateAndTrove", + args: [ + BigInt(loan.branchId), // _collIndex + dn.from(ownBracket, 18)[0], // _interestRateLo + dn.add(ownBracket, INTEREST_RATE_INCREMENT_PRECISE, 18)[0], // _interestRateHi + BigInt(loan.troveId), // _troveIdToStopAt + 0n, // _hintId + BigInt(Math.round(Math.sqrt(Number(numTroves.data ?? 0)))), // _numTrials + ], + }); + + const status = redeemable + ? combineStatus(debtInFrontOfOwnBracket.status, debtInFrontOfLoanWithinOwnBracket.status) + : debtInFrontOfOwnBracket.status; return useMemo(() => { - if (!chartData.data || !interestRate) { - return { debtInFront: null, totalDebt: null }; + if (redeemable) { + if (!debtInFrontOfOwnBracket.data || !debtInFrontOfLoanWithinOwnBracket.data) { + return { status, data: undefined }; + } + + const timestamp = debtInFrontOfLoanWithinOwnBracket.data[1]; + const { debtInFront, totalDebt } = debtInFrontOfOwnBracket.data(timestamp); + + return { + status, + data: { + debtInFront: dn.add(debtInFront, dnum18(debtInFrontOfLoanWithinOwnBracket.data[0])), + totalDebt, + }, + }; + } else { + if (!debtInFrontOfOwnBracket.data) return { status, data: undefined }; + + return { + status, + data: { + debtInFront: null, + totalDebt: debtInFrontOfOwnBracket.data(BigInt(Math.floor(Date.now() / 1000))).totalDebt, + }, + }; } + }, [redeemable, status, debtInFrontOfOwnBracket.data, debtInFrontOfLoanWithinOwnBracket.data]); +} - // find the bracket that contains this interest rate - const bracket = chartData.data.find((item) => - dn.lte(item.rate, interestRate) - && dn.lt( - interestRate, - dn.add( - item.rate, - dn.lt(item.rate, INTEREST_RATE_PRECISE_UNTIL) - ? INTEREST_RATE_INCREMENT_PRECISE - : INTEREST_RATE_INCREMENT_NORMAL, - ), - ) - ); +export function useDebtInFrontOfInterestRate( + branchId: BranchId, + interestRate: Dnum, + excludedLoan?: PositionLoanCommitted, +) { + const ownBracket = interestRateFloor(interestRate); + const debtInFrontOfOwnBracket = useDebtInFrontOfBracket(branchId, ownBracket); + const { contracts: { SortedTroves } } = getBranch(branchId); + const atBottom = dn.eq(interestRate, ownBracket); + const numTroves = useReadContract({ ...SortedTroves, functionName: "getSize", query: { enabled: !atBottom } }); + const DebtInFrontHelper = getProtocolContract("DebtInFrontHelper"); + + const debtInFrontOfLoanWithinOwnBracket = useReadContract({ + ...DebtInFrontHelper, + query: { enabled: !atBottom && numTroves.data !== undefined }, + functionName: "getDebtBetweenInterestRates", + args: [ + BigInt(branchId), // _collIndex + dn.from(ownBracket, 18)[0], // _interestRateLo + dn.from(interestRate, 18)[0], // _interestRateHi + BigInt(excludedLoan?.troveId ?? 0), // _excludedTroveId + 0n, // _hintId + BigInt(Math.round(Math.sqrt(Number(numTroves.data ?? 0)))), // _numTrials + ], + }); + + const status = atBottom + ? debtInFrontOfOwnBracket.status + : combineStatus(debtInFrontOfLoanWithinOwnBracket.status, debtInFrontOfOwnBracket.status); + + return useMemo(() => { + if (atBottom) { + if (!debtInFrontOfOwnBracket.data) return { status, data: undefined }; + + return { + status, + data: debtInFrontOfOwnBracket.data(BigInt(Math.floor(Date.now() / 1000))), + }; + } else { + if (!debtInFrontOfOwnBracket.data || !debtInFrontOfLoanWithinOwnBracket.data) { + return { status, data: undefined }; + } + + const timestamp = debtInFrontOfLoanWithinOwnBracket.data[1]; + const { debtInFront, totalDebt } = debtInFrontOfOwnBracket.data(timestamp); - if (!bracket) { - return { debtInFront: null, totalDebt: null }; + return { + status, + data: { + debtInFront: dn.add(debtInFront, dnum18(debtInFrontOfLoanWithinOwnBracket.data[0])), + totalDebt, + }, + }; } + }, [atBottom, status, debtInFrontOfOwnBracket.data, debtInFrontOfLoanWithinOwnBracket.data]); +} - // calculate total debt from all brackets - const totalDebt = chartData.data.reduce( - (sum, item) => dn.add(sum, item.debt), - DNUM_0, - ); +// TODO add the ability to disable `useDebtInFrontOfLoan()` and disable it Trove is a zombie (return "not-applicable") +export function useRedemptionRiskOfLoan(loan: UseDebtInFrontOfLoanParams) { + const { status, data } = useDebtInFrontOfLoan(loan); - return { - debtInFront: bracket.debtInFront, - totalDebt, - }; - }, [chartData.data, interestRate]); + return useMemo(() => { + if (!data) return { status, data }; + if (data.debtInFront === null) return { status, data: "not-applicable" as const }; + return { status, data: getRedemptionRisk(data.debtInFront, data.totalDebt) }; + }, [status, data]); } -export function useRedemptionRisk( - branchId: BranchId, - interestRate: Dnum | null, -): UseQueryResult { - const debtPositioning = useDebtPositioning(branchId, interestRate); +export function useCollateralSurplus(accountAddress: Address | null, branchId: BranchId) { + return useReadContract({ + ...getBranchContract(branchId, "CollSurplusPool"), + functionName: "getCollateral", + args: [accountAddress ?? zeroAddress], + query: { + enabled: Boolean(accountAddress), + select: dnum18, + }, + }); +} - return useQuery({ - queryKey: ["useRedemptionRisk", branchId, jsonStringifyWithDnum(interestRate)], - queryFn: () => { - if (!debtPositioning.debtInFront || !debtPositioning.totalDebt) { - return null; +export function useCollateralSurplusByBranches( + accountAddress: Address | null, + liquidatedBranchIds: BranchId[], +) { + return useReadContracts({ + contracts: liquidatedBranchIds.map((branchId) => { + const branch = CONTRACTS.branches[branchId]; + if (!branch) { + throw new Error(`Invalid branch ID: ${branchId}`); } - return getRedemptionRisk(debtPositioning.debtInFront, debtPositioning.totalDebt); + return { + ...branch.contracts.CollSurplusPool, + functionName: "getCollateral" as const, + args: [accountAddress ?? zeroAddress], + }; + }), + query: { + enabled: Boolean(accountAddress) && liquidatedBranchIds.length > 0, + select: (results) => { + return results.map((result, index) => { + const branchId = liquidatedBranchIds[index]; + if (branchId === undefined) { + throw new Error(`Branch ID at index ${index} not found`); + } + const surplus = result.result ? dnum18(result.result) : DNUM_0; + return { + branchId, + surplus, + }; + }); + }, + }, + }); +} + +export function useRedemptionRiskOfInterestRate( + branchId: BranchId, + interestRate: Dnum, + excludedLoan?: PositionLoanCommitted, +) { + const { status, data } = useDebtInFrontOfInterestRate(branchId, interestRate, excludedLoan); + + return useMemo(() => { + if (!data) return { status, data }; + return { status, data: getRedemptionRisk(data.debtInFront, data.totalDebt) }; + }, [status, data]); +} + +export interface RedemptionSimulationParams { + boldAmount: Dnum; + maxIterationsPerCollateral: number; +} + +export function useRedemptionSimulation(params: RedemptionSimulationParams) { + const boldAmount = dn.from(params.boldAmount, 18)[0]; + const maxIterationsPerCollateral = BigInt(params.maxIterationsPerCollateral); + + const values = useMemo(() => ({ + boldAmount, + maxIterationsPerCollateral, + }), [boldAmount, maxIterationsPerCollateral]); + + const [debounced, bouncing] = useDebounced(values); + const RedemptionHelper = getProtocolContract("RedemptionHelper"); + + // We'd love to use `useReadContract()` for this, but wagmi/viem won't let us + // do that for mutating functions, even though it's a perfectly valid use case. + // We could hack the ABI, but that's yucky. + return useSimulateContract({ + ...RedemptionHelper, + functionName: "truncateRedemption", + args: [debounced.boldAmount, debounced.maxIterationsPerCollateral], + + query: { + refetchInterval: 12_000, + enabled: !bouncing, + + select: ({ result: [truncatedBold, feePct, output] }) => ({ + bouncing, + truncatedBold: dnum18(truncatedBold), + feePct: dnum18(feePct), + collRedeemed: output.map(({ coll }) => dnum18(coll)), + }), }, - enabled: debtPositioning.debtInFront !== null && debtPositioning.totalDebt !== null, }); } diff --git a/frontend/app/src/query-utils.ts b/frontend/app/src/query-utils.ts new file mode 100644 index 00000000..4c074a4e --- /dev/null +++ b/frontend/app/src/query-utils.ts @@ -0,0 +1,17 @@ +export type QueryStatus = "success" | "error" | "pending"; + +// function combineStatus(a: "error", b: QueryStatus): "error"; +// function combineStatus(a: QueryStatus, b: "error"): "error"; +// function combineStatus(a: "pending", b: Exclude): "pending"; +// function combineStatus(a: Exclude, b: "pending"): "pending"; +// function combineStatus(a: "success", b: "success"): "success"; +// function combineStatus( +// a: Exclude, +// b: Exclude, +// ): Exclude; +// function combineStatus(a: QueryStatus, b: QueryStatus): QueryStatus; +export function combineStatus(a: QueryStatus, b: QueryStatus): QueryStatus { + if (a === "error" || b === "error") return "error"; + if (a === "pending" || b === "pending") return "pending"; + return "success"; +} diff --git a/frontend/app/src/react-utils.ts b/frontend/app/src/react-utils.ts index d3be2a8e..681bb76e 100644 --- a/frontend/app/src/react-utils.ts +++ b/frontend/app/src/react-utils.ts @@ -1,20 +1,22 @@ -import { useCallback, useEffect, useState } from "react"; -import { debounce } from "./utils"; +import { useEffect, useState } from "react"; -export function useDebouncedQueryKey( - values: T, - delay: number, -): T { - const [debouncedValue, setDebouncedValue] = useState(values); +export function useDebounced(value: T, delayMs = 200): [debounced: T, bouncing: boolean] { + const [{ debounced, bouncing }, set] = useState({ debounced: value, bouncing: false }); - const debouncedSet = useCallback( - debounce(setDebouncedValue, delay), - [delay], - ); + useEffect(() => { + set((state) => state.bouncing ? state : { ...state, bouncing: true }); + + let timeoutId: ReturnType | null = setTimeout(() => { + timeoutId = null; + set({ debounced: value, bouncing: false }); + }, delayMs); - debouncedSet(values); + return () => { + if (timeoutId !== null) clearTimeout(timeoutId); + }; + }, [value, delayMs]); - return debouncedValue; + return [debounced, bouncing]; } export function useWait(delay: number) { diff --git a/frontend/app/src/screens/BorrowScreen/BorrowScreen.tsx b/frontend/app/src/screens/BorrowScreen/BorrowScreen.tsx index 63839a0c..be4c887a 100644 --- a/frontend/app/src/screens/BorrowScreen/BorrowScreen.tsx +++ b/frontend/app/src/screens/BorrowScreen/BorrowScreen.tsx @@ -11,10 +11,11 @@ import { InterestRateField } from "@/src/comps/InterestRateField/InterestRateFie import { LinkTextButton } from "@/src/comps/LinkTextButton/LinkTextButton"; import { RedemptionInfo } from "@/src/comps/RedemptionInfo/RedemptionInfo"; import { Screen } from "@/src/comps/Screen/Screen"; +import { WarningBox } from "@/src/comps/WarningBox/WarningBox"; import { DEBT_SUGGESTIONS, ETH_MAX_RESERVE, MAX_COLLATERAL_DEPOSITS, MIN_DEBT } from "@/src/constants"; import content from "@/src/content"; import { WHITE_LABEL_CONFIG } from "@/src/white-label.config"; -import { dnum18, dnumMax, dnumMin } from "@/src/dnum-utils"; +import { dnum18, DNUM_0, dnumMax, dnumMin } from "@/src/dnum-utils"; import { useInputFieldValue } from "@/src/form-utils"; import { fmtnum } from "@/src/formatting"; import { getLiquidationRisk, getLoanDetails, getLtv } from "@/src/liquity-math"; @@ -23,14 +24,16 @@ import { getBranches, getCollToken, useBranchCollateralRatios, + useBranchDebt, useNextOwnerIndex, - useRedemptionRisk, + useRedemptionRiskOfInterestRate, } from "@/src/liquity-utils"; import { usePrice } from "@/src/services/Prices"; import { infoTooltipProps } from "@/src/uikit-utils"; import { useAccount, useBalances } from "@/src/wagmi-utils"; import { css } from "@/styled-system/css"; import { + Checkbox, COLLATERALS as KNOWN_COLLATERALS, Dropdown, HFlex, @@ -45,7 +48,7 @@ import { } from "@liquity2/uikit"; import * as dn from "dnum"; import { useParams, useRouter } from "next/navigation"; -import { useState } from "react"; +import { useCallback, useEffect, useId, useState } from "react"; import { maxUint256 } from "viem"; const KNOWN_COLLATERAL_SYMBOLS = KNOWN_COLLATERALS.map(({ symbol }) => symbol); @@ -86,6 +89,14 @@ export function BorrowScreen() { const [interestRate, setInterestRate] = useState(null); const [interestRateMode, setInterestRateMode] = useState("manual"); const [interestRateDelegate, setInterestRateDelegate] = useState
(null); + const [agreeToLiquidationRisk, setAgreeToLiquidationRisk] = useState(false); + + const agreeCheckboxId = useId(); + + const setInterestRateRounded = useCallback((averageInterestRate: Dnum, setValue: (value: string) => void) => { + const rounded = dn.div(dn.round(dn.mul(averageInterestRate, 1e4)), 1e4); + setValue(dn.toString(dn.mul(rounded, 100))); + }, [setInterestRate]); const collPrice = usePrice(collateral.symbol); @@ -98,7 +109,7 @@ export function BorrowScreen() { } const nextOwnerIndex = useNextOwnerIndex(account.address ?? null, branch.id); - const redemptionRisk = useRedemptionRisk(branch.id, interestRate); + const redemptionRisk = useRedemptionRiskOfInterestRate(branch.id, interestRate ?? DNUM_0); const loanDetails = getLoanDetails( deposit.isEmpty ? null : deposit.parsed, @@ -108,6 +119,14 @@ export function BorrowScreen() { collPrice.data ?? null, ); + useEffect(() => { + setAgreeToLiquidationRisk(false); + }, [loanDetails.status]); + + const insufficientColl = deposit.parsed + && collBalance.data + && (dn.gt(deposit.parsed, collBalance.data)); + const debtSuggestions = loanDetails.maxDebt && loanDetails.depositUsd && loanDetails.deposit @@ -153,7 +172,44 @@ export function BorrowScreen() { ); const isBelowMinDebt = debt.parsed && !debt.isEmpty && dn.lt(debt.parsed, MIN_DEBT); + const isAboveMaxLtv = loanDetails.ltv && dn.gt(loanDetails.ltv, loanDetails.maxLtv); + + const branchDebt = useBranchDebt(branch.id); + const newTcr = branchDebt.data + && loanDetails.deposit + && loanDetails.collPrice + && debt.parsed + && dn.gt(debt.parsed, 0) + ? (() => { + if (collateralRatios.data?.tcr === null && dn.eq(branchDebt.data, 0)) { + const loanColl = dn.mul(loanDetails.deposit, loanDetails.collPrice); + return dn.div(loanColl, debt.parsed); + } + + if (!collateralRatios.data?.tcr) { + return null; + } + + const branchColl = dn.mul(collateralRatios.data.tcr, branchDebt.data); + const loanColl = dn.mul(loanDetails.deposit, loanDetails.collPrice); + + const totalCollAfter = dn.add(branchColl, loanColl); + const totalDebtAfter = dn.add(branchDebt.data, debt.parsed); + + return dn.div(totalCollAfter, totalDebtAfter); + })() + : null; + + const isCcrConditionsNotMet = newTcr + && collateralRatios.data?.ccr + && dn.lt(newTcr, collateralRatios.data.ccr); + + const isOldTcrLtCcr = collateralRatios.data?.ccr + && collateralRatios.data?.tcr + && dn.lt(collateralRatios.data.tcr, collateralRatios.data.ccr); + + const isDelegated = interestRateMode === "delegate" && interestRateDelegate; const allowSubmit = account.isConnected && deposit.parsed && dn.gt(deposit.parsed, 0) @@ -161,7 +217,11 @@ export function BorrowScreen() { && dn.gt(debt.parsed, 0) && interestRate && dn.gt(interestRate, 0) - && !isBelowMinDebt; + && !isBelowMinDebt + && !isAboveMaxLtv + && !isCcrConditionsNotMet + && (loanDetails.status !== "at-risk" || (!isDelegated && agreeToLiquidationRisk)) + && !insufficientColl; return ( } + drawer={deposit.isFocused ? null : ( + insufficientColl + ? { + mode: "error", + message: `Insufficient ${collateral.name} balance.`, + } + : null + )} label={content.borrowScreen.depositField.label} placeholder="0.00" secondary={{ @@ -284,10 +352,19 @@ export function BorrowScreen() { label={WHITE_LABEL_CONFIG.tokens.mainToken.symbol} /> } - drawer={debt.isFocused || !isBelowMinDebt ? null : { - mode: "error", - message: `You must borrow at least ${fmtnum(MIN_DEBT, 2)} ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol}.`, - }} + drawer={debt.isFocused ? null : ( + isBelowMinDebt + ? { + mode: "error", + message: `You must borrow at least ${fmtnum(MIN_DEBT, 2)} ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol}.`, + } + : isAboveMaxLtv + ? { + mode: "error", + message: `Your LTV must be lower than ${fmtnum(dn.toNumber(loanDetails.maxLtv), "pct2z")}%`, + } + : null + )} label={content.borrowScreen.borrowField.label} placeholder="0.00" secondary={{ @@ -312,7 +389,7 @@ export function BorrowScreen() { debt.setValue(dn.toString(s.debt, 0)); } }} - warnLevel={s.risk} + warnLevel={s.risk === "not-applicable" ? "low" : s.risk} /> ) ))} @@ -356,7 +433,7 @@ export function BorrowScreen() { inputId="input-interest-rate" interestRate={interestRate} mode={interestRateMode} - onAverageInterestRateLoad={setInterestRate} + onAverageInterestRateLoad={setInterestRateRounded} onChange={setInterestRate} onDelegateChange={setInterestRateDelegate} onModeChange={setInterestRateMode} @@ -415,160 +492,85 @@ export function BorrowScreen() { - {collateralRatios.data?.isBelowCcr && collateralRatios.data?.tcr && ( -
-
- Borrowing Restrictions Active -
-
-
- Current TCR: - -
-
- Critical Threshold (CCR): - -
-
-
-
- When the branch TCR falls below the CCR, these restrictions apply: -
-
    -
  • - Opening a position: only allowed if resulting TCR {">"}{" "} - -
  • -
  • - New borrowing: must bring resulting TCR {">"}{" "} - -
  • -
  • +
    +
    - Collateral withdrawal: must be matched by debt repayment -
  • -
-
- +
- Learn more about borrowing restrictions - - - } - /> -
- )} + {content.ccrWarning.openPosition({ + tcr: , + ccr: , + newTcr: , + isOldTcrLtCcr: Boolean(isOldTcrLtCcr), + })} +
+ + {content.ccrWarning.learnMoreLabel} + + + } + /> +
+ + ) + : loanDetails.status === "at-risk" && ( + + {isDelegated + ? content.atRiskWarning.delegated(`${fmtnum(loanDetails.maxLtvAllowed, "pct2z")}%`) + : ( + <> + {content.atRiskWarning.manual( + `${fmtnum(loanDetails.ltv, "pct2z")}%`, + `${fmtnum(loanDetails.maxLtv, "pct2z")}%`, + ).message} + + + )} + + )} )} + {tab.action === "compound" && ( + + )}
) ))} diff --git a/frontend/app/src/screens/EarnPoolScreen/PanelClaimRewards.tsx b/frontend/app/src/screens/EarnPoolScreen/PanelClaimRewards.tsx index 184855b3..54c6ba5e 100644 --- a/frontend/app/src/screens/EarnPoolScreen/PanelClaimRewards.tsx +++ b/frontend/app/src/screens/EarnPoolScreen/PanelClaimRewards.tsx @@ -1,21 +1,19 @@ import type { BranchId, PositionEarn, TokenSymbol } from "@/src/types"; -import type { Dnum } from "dnum"; -import { ReactNode, useState } from "react"; import { Amount } from "@/src/comps/Amount/Amount"; import { FlowButton } from "@/src/comps/FlowButton/FlowButton"; import content from "@/src/content"; import { WHITE_LABEL_CONFIG } from "@/src/white-label.config"; import { dnum18, DNUM_0 } from "@/src/dnum-utils"; -import { getCollToken, isEarnPositionActive } from "@/src/liquity-utils"; +import { getCollToken } from "@/src/liquity-utils"; import { getBranch } from "@/src/liquity-utils"; import { usePrice } from "@/src/services/Prices"; import { useAccount } from "@/src/wagmi-utils"; import { css } from "@/styled-system/css"; -import { Checkbox, InfoTooltip, TokenIcon } from "@liquity2/uikit"; import * as dn from "dnum"; import { encodeFunctionData } from "viem"; import { useEstimateGas, useGasPrice } from "wagmi"; +import { Rewards } from "./components/Rewards"; export function PanelClaimRewards({ branchId, @@ -25,7 +23,6 @@ export function PanelClaimRewards({ position?: PositionEarn; }) { const account = useAccount(); - const [compound, setCompound] = useState(false); const collateral = getCollToken(branchId); if (!collateral || branchId === null) { @@ -36,12 +33,8 @@ export function PanelClaimRewards({ const boldPriceUsd = usePrice(WHITE_LABEL_CONFIG.tokens.mainToken.symbol as TokenSymbol); const collPriceUsd = usePrice(collateral.symbol); - const isActive = isEarnPositionActive(position ?? null); - - const totalRewards = collPriceUsd.data && boldPriceUsd.data && dn.add( - dn.mul(position?.rewards?.bold ?? DNUM_0, boldPriceUsd.data), - dn.mul(position?.rewards?.coll ?? DNUM_0, collPriceUsd.data), - ); + const boldRewardsUsd = boldPriceUsd.data && dn.mul(position?.rewards?.bold ?? DNUM_0, boldPriceUsd.data); + const collRewardsUsd = collPriceUsd.data && dn.mul(position?.rewards?.coll ?? DNUM_0, collPriceUsd.data); const branch = getBranch(branchId); const gasEstimate = useEstimateGas({ @@ -49,7 +42,7 @@ export function PanelClaimRewards({ data: encodeFunctionData({ abi: branch.contracts.StabilityPool.abi, functionName: "withdrawFromSP", - args: [0n, !compound], // withdraw 0, either claim or compound + args: [0n, true], // withdraw 0, claim }), to: branch.contracts.StabilityPool.address, }); @@ -63,7 +56,10 @@ export function PanelClaimRewards({ const txGasPriceUsd = gasPriceEth && ethPrice.data && dn.mul(gasPriceEth, ethPrice.data); - const allowSubmit = account.isConnected && totalRewards && dn.gt(totalRewards, 0); + const allowSubmit = account.isConnected && ( + dn.gt(position?.rewards?.bold ?? DNUM_0, DNUM_0) + || dn.gt(position?.rewards?.coll ?? DNUM_0, DNUM_0) + ); return (
@@ -101,20 +99,6 @@ export function PanelClaimRewards({ color: "contentAlt", })} > -
-
{content.earnScreen.rewardsPanel.totalUsdLabel}
- -
- {isActive && ( -
-
- - - When enabled, your {WHITE_LABEL_CONFIG.tokens.mainToken.symbol} rewards will be automatically added back to your stability pool deposit, - earning you more rewards over time. Collateral rewards will still be claimed normally. - - ), - }} - /> -
-
- )} -
); } - -function Rewards({ - amount, - label, - symbol, -}: { - amount: Dnum; - label: ReactNode; - symbol: TokenSymbol; -}) { - return ( -
-
{label}
-
- - -
-
- ); -} diff --git a/frontend/app/src/screens/EarnPoolScreen/PanelCompound.tsx b/frontend/app/src/screens/EarnPoolScreen/PanelCompound.tsx new file mode 100644 index 00000000..bf7078ab --- /dev/null +++ b/frontend/app/src/screens/EarnPoolScreen/PanelCompound.tsx @@ -0,0 +1,145 @@ +import type { BranchId, PositionEarn } from "@/src/types"; + +import { Amount } from "@/src/comps/Amount/Amount"; +import { FlowButton } from "@/src/comps/FlowButton/FlowButton"; +import content from "@/src/content"; +import { dnum18, DNUM_0 } from "@/src/dnum-utils"; +import { getCollToken } from "@/src/liquity-utils"; +import { getBranch } from "@/src/liquity-utils"; +import { usePrice } from "@/src/services/Prices"; +import { useAccount } from "@/src/wagmi-utils"; +import { css } from "@/styled-system/css"; +import * as dn from "dnum"; +import { encodeFunctionData } from "viem"; +import { useEstimateGas, useGasPrice } from "wagmi"; +import { Rewards } from "./components/Rewards"; +import { WHITE_LABEL_CONFIG } from "@/src/white-label.config"; + +export function PanelCompound({ + branchId, + position, +}: { + branchId: null | BranchId; + position?: PositionEarn; +}) { + const account = useAccount(); + + const collateral = getCollToken(branchId); + if (!collateral || branchId === null) { + throw new Error(`Invalid branch: ${branchId}`); + } + + const ethPrice = usePrice("ETH"); + const boldPriceUsd = usePrice(WHITE_LABEL_CONFIG.tokens.mainToken.symbol); + const collPriceUsd = usePrice(collateral.symbol); + + const boldRewardsUsd = boldPriceUsd.data && dn.mul(position?.rewards?.bold ?? DNUM_0, boldPriceUsd.data); + const collRewardsUsd = collPriceUsd.data && dn.mul(position?.rewards?.coll ?? DNUM_0, collPriceUsd.data); + + const branch = getBranch(branchId); + const gasEstimate = useEstimateGas({ + account: account.address, + data: encodeFunctionData({ + abi: branch.contracts.StabilityPool.abi, + functionName: "withdrawFromSP", + args: [0n, false], // withdraw 0, don't claim + }), + to: branch.contracts.StabilityPool.address, + }); + + const gasPrice = useGasPrice(); + + const gasPriceEth = gasEstimate.data && gasPrice.data + ? dnum18(gasEstimate.data * gasPrice.data) + : null; + + const txGasPriceUsd = gasPriceEth && ethPrice.data + && dn.mul(gasPriceEth, ethPrice.data); + + const allowSubmit = account.isConnected && dn.gt(position?.rewards?.bold ?? DNUM_0, DNUM_0); + + return ( +
+
+ + + +
+
+
{content.earnScreen.compoundPanel.expectedGasFeeLabel}
+ +
+
+
+ +
+ +
+
+ ); +} diff --git a/frontend/app/src/screens/EarnPoolScreen/PanelUpdateDeposit.tsx b/frontend/app/src/screens/EarnPoolScreen/PanelUpdateDeposit.tsx index bc874446..4a34caef 100644 --- a/frontend/app/src/screens/EarnPoolScreen/PanelUpdateDeposit.tsx +++ b/frontend/app/src/screens/EarnPoolScreen/PanelUpdateDeposit.tsx @@ -35,7 +35,7 @@ export function PanelUpdateDeposit({ const [mode, setMode] = useState("add"); const [value, setValue] = useState(""); const [focused, setFocused] = useState(false); - const [claimRewards, setClaimRewards] = useState(true); + const [claimRewardsState, setClaimRewardsState] = useState(true); const hasDeposit = dn.gt(position?.deposit ?? DNUM_0, 0); const isActive = isEarnPositionActive(position ?? null); @@ -66,6 +66,12 @@ export function PanelUpdateDeposit({ && parsedValue && dn.gt(parsedValue, position?.deposit ?? DNUM_0); + const fullyWithdrawing = mode === "remove" && parsedValue + ? dn.eq(parsedValue, position?.deposit ?? DNUM_0) + : false; + + const claimRewards = fullyWithdrawing ? true : claimRewardsState; + const allowSubmit = account.isConnected && parsedValue && dn.gt(parsedValue, 0) @@ -167,7 +173,6 @@ export function PanelUpdateDeposit({ label={`Max ${fmtnum(position.deposit, 2)} ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol}`} onClick={() => { setValue(dn.toString(position.deposit)); - setClaimRewards(true); }} /> ), @@ -206,15 +211,16 @@ export function PanelUpdateDeposit({ {content.earnScreen.depositPanel.claimCheckbox} @@ -252,6 +258,7 @@ export function PanelUpdateDeposit({ { if (!account.address || (mode === "remove" && !position)) { return null; diff --git a/frontend/app/src/screens/EarnPoolScreen/components/Rewards.tsx b/frontend/app/src/screens/EarnPoolScreen/components/Rewards.tsx new file mode 100644 index 00000000..1ee1ac3c --- /dev/null +++ b/frontend/app/src/screens/EarnPoolScreen/components/Rewards.tsx @@ -0,0 +1,70 @@ +import type { TokenSymbol } from "@/src/types"; + +import { Amount } from "@/src/comps/Amount/Amount"; +import { css } from "@/styled-system/css"; +import { TokenIcon } from "@liquity2/uikit"; +import type { Dnum } from "dnum"; +import { ReactNode } from "react"; + +export function Rewards({ + amount, + amountUsd, + label, + symbol, +}: { + amount: Dnum; + amountUsd: Dnum; + label: ReactNode; + symbol: TokenSymbol; +}) { + return ( +
+
{label}
+
+
+ + +
+ +
+ +
+
+
+ ); +} diff --git a/frontend/app/src/screens/EarnPoolsListScreen/EarnPoolsListScreen.tsx b/frontend/app/src/screens/EarnPoolsListScreen/EarnPoolsListScreen.tsx index 84cd3354..1274cae0 100644 --- a/frontend/app/src/screens/EarnPoolsListScreen/EarnPoolsListScreen.tsx +++ b/frontend/app/src/screens/EarnPoolsListScreen/EarnPoolsListScreen.tsx @@ -4,6 +4,7 @@ import type { BranchId } from "@/src/types"; import { EarnPositionSummary } from "@/src/comps/EarnPositionSummary/EarnPositionSummary"; import { SboldPositionSummary } from "@/src/comps/EarnPositionSummary/SboldPositionSummary"; +import { YboldPositionSummary } from "@/src/comps/EarnPositionSummary/YboldPositionSummary"; import { LinkTextButton } from "@/src/comps/LinkTextButton/LinkTextButton"; import { Screen } from "@/src/comps/Screen/Screen"; import content from "@/src/content"; @@ -11,11 +12,12 @@ import { getBranches, useEarnPosition } from "@/src/liquity-utils"; import { WHITE_LABEL_CONFIG } from "@/src/white-label.config"; import { isSboldEnabled, useSboldPosition } from "@/src/sbold"; import { useAccount } from "@/src/wagmi-utils"; +import { isYboldEnabled } from "@/src/ybold"; import { css } from "@/styled-system/css"; import { TokenIcon } from "@liquity2/uikit"; import { a, useTransition } from "@react-spring/web"; -type PoolId = BranchId | "sbold"; +type PoolId = BranchId | "sbold" | "ybold"; export function EarnPoolsListScreen() { const branches = getBranches(); @@ -27,6 +29,10 @@ export function EarnPoolsListScreen() { pools.push("sbold"); } + if (isYboldEnabled()) { + pools.push("ybold"); + } + const poolsTransition = useTransition(pools, { from: { opacity: 0, transform: "scale(1.1) translateY(64px)" }, enter: { opacity: 1, transform: "scale(1) translateY(0px)" }, @@ -87,6 +93,8 @@ export function EarnPoolsListScreen() { {poolId === "sbold" ? + : poolId === "ybold" + ? : } ))} diff --git a/frontend/app/src/screens/HomeScreen/HomeScreen.tsx b/frontend/app/src/screens/HomeScreen/HomeScreen.tsx index 70c3caf8..6f709f6c 100644 --- a/frontend/app/src/screens/HomeScreen/HomeScreen.tsx +++ b/frontend/app/src/screens/HomeScreen/HomeScreen.tsx @@ -122,9 +122,7 @@ function BorrowTable({ subtitle="You can adjust your loans, including your interest rate, at any time" icon={} columns={columns} - rows={getBranches().map(({ symbol }) => ( - - ))} + rows={getBranches().map(({ symbol }) => )} /> ); @@ -181,7 +179,6 @@ function EarnTable({ return ( ); @@ -375,7 +372,7 @@ function BorrowingRow({ } - title={`Borrow ${collateral?.name} from ${symbol}`} + title={`Borrow ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol} from ${symbol}`} /> @@ -385,10 +382,8 @@ function BorrowingRow({ } function EarnRewardsRow({ - compact, symbol, }: { - compact: boolean; symbol: CollateralSymbol; }) { const branch = getBranch(symbol); @@ -430,30 +425,6 @@ function EarnRewardsRow({ value={earnPool.data?.totalDeposited} /> - {!compact && ( - - - Earn - - - - - - } - title={`Earn ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol} with ${token?.name}`} - /> - - )} ); } diff --git a/frontend/app/src/screens/LeverageScreen/LeverageScreen.tsx b/frontend/app/src/screens/LeverageScreen/LeverageScreen.tsx index 7aedd8b9..d14dd21e 100644 --- a/frontend/app/src/screens/LeverageScreen/LeverageScreen.tsx +++ b/frontend/app/src/screens/LeverageScreen/LeverageScreen.tsx @@ -2,44 +2,52 @@ import type { DelegateMode } from "@/src/comps/InterestRateField/InterestRateField"; import type { Address, Dnum, PositionLoanUncommitted } from "@/src/types"; -import type { ComponentPropsWithoutRef, ReactNode } from "react"; import { Amount } from "@/src/comps/Amount/Amount"; import { Field } from "@/src/comps/Field/Field"; import { WHITE_LABEL_CONFIG } from "@/src/white-label.config"; +import { FlowButton } from "@/src/comps/FlowButton/FlowButton"; import { InterestRateField } from "@/src/comps/InterestRateField/InterestRateField"; import { LeverageField, useLeverageField } from "@/src/comps/LeverageField/LeverageField"; +import { LinkTextButton } from "@/src/comps/LinkTextButton/LinkTextButton"; import { RedemptionInfo } from "@/src/comps/RedemptionInfo/RedemptionInfo"; import { Screen } from "@/src/comps/Screen/Screen"; -import { ETH_MAX_RESERVE, LEVERAGE_MAX_SLIPPAGE, MAX_COLLATERAL_DEPOSITS, MIN_DEBT } from "@/src/constants"; +import { WarningBox } from "@/src/comps/WarningBox/WarningBox"; +import { ETH_MAX_RESERVE, LEVERAGE_FACTOR_DEFAULT, MAX_COLLATERAL_DEPOSITS } from "@/src/constants"; import content from "@/src/content"; -import { dnum18, dnumMax } from "@/src/dnum-utils"; +import { dnum18, DNUM_0, dnumMax } from "@/src/dnum-utils"; import { useInputFieldValue } from "@/src/form-utils"; import { fmtnum } from "@/src/formatting"; -import { useCheckLeverageSlippage } from "@/src/liquity-leverage"; -import { getRedemptionRisk } from "@/src/liquity-math"; -import { getBranch, getBranches, getCollToken, useNextOwnerIndex, useDebtPositioning } from "@/src/liquity-utils"; +import { getLoanDetails } from "@/src/liquity-math"; +import { + getBranch, + getBranches, + getCollToken, + useBranchCollateralRatios, + useBranchDebt, + useNextOwnerIndex, + useRedemptionRiskOfInterestRate, +} from "@/src/liquity-utils"; import { usePrice } from "@/src/services/Prices"; -import { useTransactionFlow } from "@/src/services/TransactionFlow"; import { infoTooltipProps } from "@/src/uikit-utils"; -import { useAccount, useBalance } from "@/src/wagmi-utils"; +import { useAccount, useBalances } from "@/src/wagmi-utils"; import { css } from "@/styled-system/css"; import { ADDRESS_ZERO, - Button, + Checkbox, Dropdown, HFlex, + IconExternal, IconSuggestion, InfoTooltip, InputField, isCollateralSymbol, TextButton, TokenIcon, - VFlex, } from "@liquity2/uikit"; import * as dn from "dnum"; import { useParams, useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useId, useState } from "react"; export function LeverageScreen() { const branches = getBranches(); @@ -53,20 +61,16 @@ export function LeverageScreen() { const router = useRouter(); const account = useAccount(); - const txFlow = useTransactionFlow(); const branch = getBranch(collSymbol); const collaterals = branches.map((b) => getCollToken(b.branchId)); const collateral = getCollToken(branch.id); - - const balances = Object.fromEntries(collaterals.map(({ symbol }) => ( - [symbol, useBalance(account.address, symbol)] as const - ))); + const collPrice = usePrice(collateral.symbol); + const collateralSymbols = collaterals.map((collateral) => collateral.symbol); + const balances = useBalances(account.address, collateralSymbols); const nextOwnerIndex = useNextOwnerIndex(account.address ?? null, branch.id); - const collPrice = usePrice(collateral.symbol); - const maxCollDeposit = MAX_COLLATERAL_DEPOSITS[collSymbol] ?? null; const depositPreLeverage = useInputFieldValue(fmtnum, { validate: (parsed, value) => { @@ -81,26 +85,82 @@ export function LeverageScreen() { const [interestRate, setInterestRate] = useState(null); const [interestRateMode, setInterestRateMode] = useState("manual"); const [interestRateDelegate, setInterestRateDelegate] = useState
(null); + const [agreeToLiquidationRisk, setAgreeToLiquidationRisk] = useState(false); + + const agreeCheckboxId = useId(); + + const setInterestRateRounded = useCallback((averageInterestRate: Dnum, setValue: (value: string) => void) => { + const rounded = dn.div(dn.round(dn.mul(averageInterestRate, 1e4)), 1e4); + setValue(dn.toString(dn.mul(rounded, 100))); + }, []); const leverageField = useLeverageField({ - depositPreLeverage: depositPreLeverage.parsed, - collPrice: collPrice.data ?? dn.from(0, 18), + positionDeposit: depositPreLeverage.parsed, + positionDebt: DNUM_0, + collPrice: collPrice.data ?? null, collToken: collateral, + defaultLeverageFactorAdjustment: LEVERAGE_FACTOR_DEFAULT - 1, }); - // reset leverage when collateral changes + const loanDetails = getLoanDetails( + leverageField.deposit, + leverageField.debt, + interestRate, + collateral.collateralRatio, + collPrice.data ?? null, + ); + useEffect(() => { - leverageField.updateLeverageFactor(leverageField.leverageFactorSuggestions[0] ?? 1.1); - }, [collateral.symbol, leverageField.leverageFactorSuggestions]); + setAgreeToLiquidationRisk(false); + }, [loanDetails.status]); + + const collBalance = balances[collateral.symbol]?.data; + + const insufficientColl = depositPreLeverage.parsed + && collBalance + && (dn.gt(depositPreLeverage.parsed, collBalance)); - const debtPositioning = useDebtPositioning(branch.id, interestRate); - const redemptionRisk = getRedemptionRisk(debtPositioning.debtInFront, debtPositioning.totalDebt); + const redemptionRisk = useRedemptionRiskOfInterestRate(branch.id, interestRate ?? DNUM_0); const depositUsd = depositPreLeverage.parsed && collPrice.data && dn.mul( depositPreLeverage.parsed, collPrice.data, ); - const collBalance = balances[collateral.symbol]?.data; + const branchDebt = useBranchDebt(branch.id); + const collateralRatios = useBranchCollateralRatios(branch.id); + + const newTcr = branchDebt.data + && loanDetails.deposit + && loanDetails.collPrice + && leverageField.debt + && dn.gt(leverageField.debt, 0) + ? (() => { + if (collateralRatios.data?.tcr === null && dn.eq(branchDebt.data, 0)) { + const loanColl = dn.mul(loanDetails.deposit, loanDetails.collPrice); + return dn.div(loanColl, leverageField.debt); + } + + if (!collateralRatios.data?.tcr) { + return null; + } + + const branchColl = dn.mul(collateralRatios.data.tcr, branchDebt.data); + const loanColl = dn.mul(loanDetails.deposit, loanDetails.collPrice); + + const totalCollAfter = dn.add(branchColl, loanColl); + const totalDebtAfter = dn.add(branchDebt.data, leverageField.debt); + + return dn.div(totalCollAfter, totalDebtAfter); + })() + : null; + + const isCcrConditionsNotMet = newTcr + && collateralRatios.data?.ccr + && dn.lt(newTcr, collateralRatios.data.ccr); + + const isOldTcrLtCcr = collateralRatios.data?.ccr + && collateralRatios.data?.tcr + && dn.lt(collateralRatios.data.tcr, collateralRatios.data.ccr); const maxAmount = collBalance && dnumMax( dn.sub(collBalance, collSymbol === "ETH" ? ETH_MAX_RESERVE : 0), // Only keep a reserve for ETH, not LSTs @@ -122,31 +182,16 @@ export function LeverageScreen() { }; const hasDeposit = Boolean(depositPreLeverage.parsed && dn.gt(depositPreLeverage.parsed, 0)); + const isAboveMaxLtv = loanDetails.ltv && dn.gt(loanDetails.ltv, loanDetails.maxLtv); - const leverageSlippage = useCheckLeverageSlippage({ - branchId: branch.id, - initialDeposit: depositPreLeverage.parsed, - leverageFactor: leverageField.leverageFactor, - ownerIndex: nextOwnerIndex.data ?? null, - }); - - const leverageSlippageElements = useSlippageElements( - leverageSlippage, - hasDeposit && account.isConnected, - ); - - const hasAllowedSlippage = leverageSlippage.data - && dn.lte(leverageSlippage.data, LEVERAGE_MAX_SLIPPAGE); - - const leverageFieldDrawer = (hasDeposit && newLoan.borrowed && dn.lt(newLoan.borrowed, MIN_DEBT)) - ? { mode: "error" as const, message: `You must borrow at least ${fmtnum(MIN_DEBT, 2)} ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol}.` } - : leverageSlippageElements.drawer; - + const isDelegated = interestRateMode === "delegate" && interestRateDelegate; const allowSubmit = account.isConnected && hasDeposit - && interestRate && dn.gt(interestRate, 0) - && leverageField.debt && dn.gt(leverageField.debt, 0) - && hasAllowedSlippage; + && leverageField.isValid + && !isAboveMaxLtv + && !isCcrConditionsNotMet + && (loanDetails.status !== "at-risk" || (!isDelegated && agreeToLiquidationRisk)) + && !insufficientColl; return ( -
- ({ - icon: , - label: name, - value: account.isConnected - ? fmtnum(balances[symbol]?.data ?? 0) - : "−", - }))} - menuPlacement="end" - menuWidth={300} - onSelect={(index) => { - setTimeout(() => { - depositPreLeverage.setValue(""); - depositPreLeverage.focus(); - }, 0); - const collToken = collaterals[index]; - if (!collToken) { - throw new Error(`Unknown branch: ${index}`); - } - const { symbol } = collToken; - router.push( - `/multiply/${symbol.toLowerCase()}`, - { scroll: false }, - ); - }} - selected={branch.id} - /> - } - label={content.leverageScreen.depositField.label} - placeholder="0.00" - secondary={{ - start: fmtnum(depositUsd, { prefix: "$", preset: "2z" }), - end: maxAmount - ? ( - { - depositPreLeverage.setValue(dn.toString(maxAmount)); - }} - /> - ) - : "Fetching balance…", - }} - {...depositPreLeverage.inputFieldProps} - /> - } - footer={{ - start: collPrice.data && ( - - ), - end: ( - ({ + icon: , + label: name, + value: account.isConnected + ? fmtnum(balances[symbol]?.data ?? 0) + : "−", + }))} + menuPlacement="end" + menuWidth={300} + onSelect={(index) => { + setTimeout(() => { + depositPreLeverage.setValue(""); + depositPreLeverage.focus(); + }, 0); + const collToken = collaterals[index]; + if (!collToken) { + throw new Error(`Unknown branch: ${index}`); + } + const { symbol } = collToken; + router.push( + `/multiply/${symbol.toLowerCase()}`, + { scroll: false }, + ); + }} + selected={branch.id} /> - ), - }} - /> - - { + depositPreLeverage.setValue(dn.toString(maxAmount)); + }} + /> + ) + : "Fetching balance…", + }} + {...depositPreLeverage.inputFieldProps} + /> + } + footer={{ + start: collPrice.data && ( + + ), + end: ( + - } - footer={{ + ), + }} + /> + + } + footer={[ + { + start: , + end: , + }, + { start: ( - <> - - - + ), + end: ( +
), - }} - /> - - - } - footer={{ + }, + { start: ( - ), - end: ( - + + + } + footer={{ + start: ( + + ), + end: ( +
+
- <>You can adjust this rate at any time +
+
+ You can adjust this rate at any time +
+
- - ), - }} - /> +
+
+ ), + }} + /> - + + {isCcrConditionsNotMet && collateralRatios.data + ? ( + +
+
+ {content.ccrWarning.title} +
+
+ {content.ccrWarning.openPosition({ + tcr: , + ccr: , + newTcr: , + isOldTcrLtCcr: Boolean(isOldTcrLtCcr), + })} +
+ + {content.ccrWarning.learnMoreLabel} + + + } + /> +
+
+ ) + : loanDetails.status === "at-risk" && ( + + {isDelegated + ? content.atRiskWarning.delegated(`${fmtnum(loanDetails.maxLtvAllowed, "pct2z")}%`) + : ( + <> + {content.atRiskWarning.manual( + `${fmtnum(loanDetails.ltv, "pct2z")}%`, + `${fmtnum(loanDetails.maxLtv, "pct2z")}%`, + ).message} + + + )} + + )} + +
+ {/**/}
- {/**/} -
-
+ />
); } - -function useSlippageElements( - leverageSlippage: ReturnType, - ready: boolean, -): { - mode: "error" | "loading" | "success"; - drawer: ComponentPropsWithoutRef["drawer"]; - message?: ReactNode; - onClose: () => void; -} { - const [forceDrawerClosed, setForceDrawerClosed] = useState(false); - - useEffect(() => { - setForceDrawerClosed(false); - }, [leverageSlippage.status]); - - const onClose = () => { - setForceDrawerClosed(true); - }; - - if (forceDrawerClosed || !ready) { - return { - drawer: null, - mode: "success", - onClose, - }; - } - - if (leverageSlippage.status === "error") { - const retry = ( - { - leverageSlippage.refetch(); - }} - /> - ); - return { - drawer: { - mode: "error", - message: ( - -
Slippage calculation failed.
- {retry} -
- ), - }, - message: ( - -
Slippage calculation failed. ({leverageSlippage.error.message})
- {retry} -
- ), - mode: "error", - onClose, - }; - } - - if (leverageSlippage.status === "pending" || leverageSlippage.fetchStatus === "fetching") { - const message = "Calculating slippage…"; - return { - drawer: null, - message, - mode: "loading", - onClose, - }; - } - - if (leverageSlippage.data && dn.gt(leverageSlippage.data, LEVERAGE_MAX_SLIPPAGE)) { - const message = ( - <> - Slippage too high: {fmtnum( - leverageSlippage.data, - "pct2", - )}% (max {fmtnum(LEVERAGE_MAX_SLIPPAGE, "pct2")}%) - - ); - return { - drawer: { mode: "error", message }, - message, - mode: "error", - onClose, - }; - } - - return { - drawer: null, - onClose, - mode: "success", - }; -} diff --git a/frontend/app/src/screens/LoanScreen/LoanScreen.tsx b/frontend/app/src/screens/LoanScreen/LoanScreen.tsx index ac075f30..8cc480c5 100644 --- a/frontend/app/src/screens/LoanScreen/LoanScreen.tsx +++ b/frontend/app/src/screens/LoanScreen/LoanScreen.tsx @@ -6,29 +6,31 @@ import { useBreakpoint } from "@/src/breakpoints"; import { InlineTokenAmount } from "@/src/comps/Amount/InlineTokenAmount"; import { ErrorBox } from "@/src/comps/ErrorBox/ErrorBox"; import { Field } from "@/src/comps/Field/Field"; +import { FlowButton } from "@/src/comps/FlowButton/FlowButton"; import { LinkTextButton } from "@/src/comps/LinkTextButton/LinkTextButton"; import { Screen } from "@/src/comps/Screen/Screen"; import content from "@/src/content"; import { WHITE_LABEL_CONFIG } from "@/src/white-label.config"; -import { getBranchContract } from "@/src/contracts"; -import { dnum18 } from "@/src/dnum-utils"; import { TROVE_EXPLORER_0, TROVE_EXPLORER_1 } from "@/src/env"; import { fmtnum, formatDate } from "@/src/formatting"; -import { getCollToken, getPrefixedTroveId, parsePrefixedTroveId, useLoan } from "@/src/liquity-utils"; +import { + getCollToken, + getPrefixedTroveId, + parsePrefixedTroveId, + useCollateralSurplus, + useLoan, +} from "@/src/liquity-utils"; import { usePrice } from "@/src/services/Prices"; import { useStoredState } from "@/src/services/StoredState"; -import { useTransactionFlow } from "@/src/services/TransactionFlow"; import { isPrefixedtroveId } from "@/src/types"; import { useAccount } from "@/src/wagmi-utils"; import { css } from "@/styled-system/css"; -import { addressesEqual, Button, HFlex, IconExternal, InfoTooltip, Tabs, TextButton, TokenIcon, Tooltip, VFlex } from "@liquity2/uikit"; +import { addressesEqual, Button, HFlex, IconExternal, InfoTooltip, Tabs, TextButton, TokenIcon, VFlex } from "@liquity2/uikit"; import { a, useTransition } from "@react-spring/web"; import * as dn from "dnum"; import { notFound, useRouter, useSearchParams, useSelectedLayoutSegment } from "next/navigation"; import { useState } from "react"; import { match, P } from "ts-pattern"; -import { zeroAddress } from "viem"; -import { useReadContract } from "wagmi"; import { LoanScreenCard } from "./LoanScreenCard"; import { PanelClosePosition } from "./PanelClosePosition"; import { PanelInterestRate } from "./PanelInterestRate"; @@ -77,6 +79,74 @@ const TABS = [ }, ]; +function TroveHistoryLinksDrawer({ + collTokenName, + troveId, +}: { + collTokenName: string; + troveId: bigint; +}) { + return ( + troveExplorers.length > 0 && ( +
+
+ + Loan History: + + {troveExplorers[0] && troveExplorers[1] + ? ( + <> + {" "} + |{" "} + + + ) + : troveExplorers[0] && ( + + )} +
+
+ ) + ); +} + export type LoanLoadingState = | "error" | "loading" @@ -108,20 +178,15 @@ export function LoanScreen() { const fullyRedeemed = loan.data && loan.data.status === "redeemed" - && dn.eq(loan.data.indexedDebt, 0); + && dn.eq(loan.data.recordedDebt, 0); const isLiquidated = loan.data?.status === "liquidated"; const account = useAccount(); - const collSurplus = useReadContract({ - ...getBranchContract(branchId, "CollSurplusPool"), - functionName: "getCollateral", - args: [loan.data?.borrower ?? zeroAddress], - query: { - enabled: Boolean(loan.data?.borrower && isLiquidated), - select: dnum18, - }, - }); + const collSurplus = useCollateralSurplus( + loan.data?.borrower ?? null, + branchId, + ); const loadingState = match([ loan, @@ -177,27 +242,34 @@ export function LoanScreen() { label: "Back", }} heading={ - { - storedState.setState(({ loanModes }) => { - return { - loanModes: { - ...loanModes, - [paramPrefixedId]: loanMode === "borrow" ? "multiply" : "borrow", - }, - }; - }); - }} - onRetry={() => { - loan.refetch(); - }} - troveId={troveId} - /> + <> + { + storedState.setState(({ loanModes }) => { + return { + loanModes: { + ...loanModes, + [paramPrefixedId]: loanMode === "borrow" ? "multiply" : "borrow", + }, + }; + }); + }} + onRetry={() => { + loan.refetch(); + }} + troveId={troveId} + collSurplusOnChain={collSurplus.data ?? null} + /> + + } > {contentTransition((style, contentStatus) => { @@ -465,8 +537,8 @@ export function LoanScreen() { ? : )} - {action === "rate" && } - {action === "close" && } + {action === "rate" && } + {action === "close" && } )} @@ -489,30 +561,23 @@ function ClaimCollateralSurplus({ collSurplus: null | Dnum; loan: PositionLoanCommitted; }) { - const txFlow = useTransactionFlow(); const collToken = getCollToken(loan.branchId); const collPriceUsd = usePrice(collToken.symbol); - const collSurplusUsd = collPriceUsd.data && collSurplus - ? dn.mul(collSurplus, collPriceUsd.data) - : null; - const isOwner = accountAddress && ( addressesEqual(accountAddress, loan.borrower) ); + const collSurplusUsd = collPriceUsd.data && collSurplus + ? dn.mul(collSurplus, collPriceUsd.data) + : null; + if (!collSurplus || dn.eq(collSurplus, 0)) { return null; } return ( -
+ <>
- <> - The collateral has been deducted from this position. - + The collateral has been deducted from this position. {isOwner && ( <> - You can claim back the excess collateral accross your liquidated {collToken.name} loans. + You can claim back the remaining collateral from your liquidated {collToken.name}{" "} + loan. If you have multiple liquidated positions on the same branch, you can claim the remaining collateral + from all of them at once. )}
- -
-
{fmtnum(collSurplus ?? 0)}
-
-
+ {isOwner && ( +
+ - -
{collToken.name}
+
+
{fmtnum(collSurplus)}
+
+
+
+ +
{collToken.name}
+
+
-
-
- } - footer={{ - start: ( - - ), - }} - /> - {isOwner && (() => { - const shouldShowTooltip = !collSurplus || dn.eq(collSurplus, 0); - const button = ( -
+
+ )} + ); } diff --git a/frontend/app/src/screens/LoanScreen/LoanScreenCard.tsx b/frontend/app/src/screens/LoanScreen/LoanScreenCard.tsx index 20fc40f2..72774c67 100644 --- a/frontend/app/src/screens/LoanScreen/LoanScreenCard.tsx +++ b/frontend/app/src/screens/LoanScreen/LoanScreenCard.tsx @@ -1,10 +1,10 @@ -import type { Dnum, LoanDetails, PositionLoan, TroveId } from "@/src/types"; +import type { Dnum, LoanDetails, PositionLoanCommitted, RiskLevel, TroveId } from "@/src/types"; import type { CollateralToken } from "@liquity2/uikit"; import type { ReactNode } from "react"; import type { LoanLoadingState } from "./LoanScreen"; import { useFlashTransition } from "@/src/anim-utils"; -import { INFINITY } from "@/src/characters"; +import { CrossedText } from "@/src/comps/CrossedText/CrossedText"; import { ScreenCard } from "@/src/comps/Screen/ScreenCard"; import { LoanStatusTag } from "@/src/comps/Tag/LoanStatusTag"; import { WHITE_LABEL_CONFIG } from "@/src/white-label.config"; @@ -13,7 +13,7 @@ import { CHAIN_BLOCK_EXPLORER } from "@/src/env"; import { formatRisk } from "@/src/formatting"; import { fmtnum } from "@/src/formatting"; import { getLoanDetails } from "@/src/liquity-math"; -import { shortenTroveId, useRedemptionRisk, useTroveNftUrl } from "@/src/liquity-utils"; +import { EMPTY_LOAN, shortenTroveId, useRedemptionRiskOfLoan, useTroveNftUrl } from "@/src/liquity-utils"; import { riskLevelToStatusMode } from "@/src/uikit-utils"; import { roundToDecimal } from "@/src/utils"; import { css } from "@/styled-system/css"; @@ -27,6 +27,7 @@ import { IconExternal, IconLeverage, IconNft, + InfoTooltip, shortenAddress, StatusDot, TokenIcon, @@ -49,24 +50,24 @@ export function LoanScreenCard({ onLeverageModeChange, onRetry, troveId, + collSurplusOnChain, }: { collateral: CollateralToken | null; collPriceUsd: Dnum | null; loadingState: LoanLoadingState; - loan: PositionLoan | null; + loan: PositionLoanCommitted | null; mode: LoanMode; onLeverageModeChange: (mode: LoanMode) => void; onRetry: () => void; troveId: TroveId; + collSurplusOnChain: Dnum | null; }) { if (loadingState === "success" && !collPriceUsd) { loadingState = "loading"; } - const redemptionRisk = useRedemptionRisk( - loan?.branchId ?? 0, - loan?.interestRate ?? null, - ); + // FIXME should not be rendering this component if loan is not loaded yet! + const redemptionRisk = useRedemptionRiskOfLoan(loan ?? EMPTY_LOAN); const loanDetails = loan && collateral && getLoanDetails( loan.deposit, @@ -91,12 +92,9 @@ export function LoanScreenCard({ const nftUrl = useTroveNftUrl(loan?.branchId ?? null, troveId); const title = mode === "multiply" ? "Multiply" : `${WHITE_LABEL_CONFIG.tokens.mainToken.symbol} loan`; - const fullyRedeemed = loan && loan.status === "redeemed" && dn.eq(loan.borrowed, 0); - return ( () .with("loading", () => "loading") @@ -216,6 +214,7 @@ export function LoanScreenCard({ onLeverageModeChange={onLeverageModeChange} redemptionRisk={redemptionRisk.data ?? null} troveId={troveId} + collSurplusOnChain={collSurplusOnChain} /> ); })} @@ -285,7 +284,7 @@ function GridItem({ title, }: { children: ReactNode; - label: string; + label: ReactNode; title?: string; }) { return ( @@ -326,18 +325,19 @@ function GridItem({ function LoanCard(props: { mode: LoanMode; - loan: PositionLoan; + loan: PositionLoanCommitted; loanDetails: LoanDetails; collateral: CollateralToken; leverageFactor: number | null; depositPreLeverage: Dnum | null; ltv: Dnum | null; maxLtv: Dnum; - liquidationRisk: "low" | "medium" | "high" | null; - redemptionRisk: "low" | "medium" | "high" | null; + liquidationRisk: RiskLevel | null; + redemptionRisk: RiskLevel | null; troveId: TroveId; nftUrl: string | null; onLeverageModeChange: (mode: LoanMode) => void; + collSurplusOnChain: Dnum | null; }) { const cardTransition = useTransition(props, { keys: (props) => props.mode, @@ -373,8 +373,8 @@ function LoanCard(props: { }); const copyTransition = useFlashTransition(); - const closedOrLiquidated = props.loan.status === "liquidated" || props.loan.status === "closed"; - const fullyRedeemed = props.loan.status === "redeemed" && dn.eq(props.loan.borrowed, 0); + const liquidated = props.loan.status === "liquidated"; + const closed = props.loan.status === "closed"; return (
{ const title = mode === "multiply" ? "Multiply" : `${WHITE_LABEL_CONFIG.tokens.mainToken.symbol} loan`; + + const collSurplusFromSubgraph = loan.collSurplus; + const collSurplusCurrently = collSurplusOnChain; + + const collateralWasClaimed = collSurplusFromSubgraph && dn.gt(collSurplusFromSubgraph, 0) + && collSurplusCurrently !== null + && dn.eq(collSurplusCurrently, 0); + return ( - : loan.status === "redeemed" && "indexedDebt" in loan + : loan.status === "redeemed" && "recordedDebt" in loan ? ( @@ -508,6 +517,23 @@ function LoanCard(props: {
} items={[ + { + icon: ( +
+ {mode === "multiply" + ? + : } +
+ ), + + label: mode === "multiply" + ? `Convert to ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol} loan` + : "Convert to Multiply position", + }, { icon: (
{ if (index === 0) { + props.onLeverageModeChange(mode === "multiply" ? "borrow" : "multiply"); + } + if (index === 1) { navigator.clipboard.writeText(window.location.href); copyTransition.flash(); } - if (index === 1) { + if (index === 2) { window.open(`${CHAIN_BLOCK_EXPLORER?.url}address/${loan.borrower}`); } - if (index === 2 && nftUrl) { + if (index === 3 && nftUrl) { window.open(nftUrl); } }} @@ -600,52 +629,52 @@ function LoanCard(props: { {mode === "multiply" ? (
-
{fmtnum(loan.deposit)}
+ + {fmtnum(depositPreLeverage ?? 0)} + + -
-
+ + {leverageFactor !== null && ( +
- {loanDetails.status === "underwater" || leverageFactor === null - ? INFINITY - : `${roundToDecimal(leverageFactor, 1)}x`} + {roundToDecimal(leverageFactor, 1)}x
-
+ )}
) : (
- {fmtnum(loan.borrowed)} + {liquidated + ? {fmtnum(loan.liquidatedDebt ?? loan.borrowed)} + : fmtnum(loan.borrowed)}
)} @@ -657,7 +686,7 @@ function LoanCard(props: { color: "positionContentAlt", })} > - {mode === "multiply" ? "Total exposure" : "Total debt"} + {mode === "multiply" ? "Net value" : liquidated ? "Liquidated debt" : "Total debt"}
@@ -667,33 +696,57 @@ function LoanCard(props: { gap: 12, })} style={{ - gridTemplateColumns: 'repeat(2, 1fr)', + gridTemplateColumns: liquidated ? "repeat(1, 1fr)" : "repeat(2, 1fr)", }} > - {fullyRedeemed + {liquidated ? ( <> - - {fmtnum(loan.deposit)} {collateral.name} + + {loan.liquidatedColl && loan.collSurplus + ? fmtnum(dn.sub(loan.liquidatedColl, loan.collSurplus)) + : loan.liquidatedColl + ? fmtnum(loan.liquidatedColl) + : "−"} {collateral.name} - - {fmtnum(loan.interestRate, "pct2")}% + + Remaining collateral + + + } + > + {loan.collSurplus + ? fmtnum(loan.collSurplus) + : "−"} {collateral.name} + {collateralWasClaimed + ? + : } - - - - {formatRisk(redemptionRisk)} - + + {loan.priceAtLiquidation ? `$${fmtnum(loan.priceAtLiquidation)}` : "−"} ) - : closedOrLiquidated + : closed ? ( <> - N/A - N/A - N/A + N/A + N/A N/A + N/A @@ -710,29 +763,38 @@ function LoanCard(props: { ) : ( <> - {mode === "multiply" - ? ( - - - {fmtnum(depositPreLeverage)} {collateral.name} - - - ) - : ( - -
- {fmtnum(loan.deposit)} {collateral.name} -
-
- )} - + +
+ {fmtnum(loan.deposit)} {collateral.name} +
+
+ - {fmtnum(loanDetails.liquidationPrice, { preset: "2z", prefix: "$" })} + {loanDetails.liquidationPrice + ? fmtnum(loanDetails.liquidationPrice, { preset: "2z", prefix: "$" }) + : <>N/A} + +
+ {fmtnum(ltv, "pct2z")}% +
+
{fmtnum(loan.interestRate, "pct2")}% {loan.batchManager && ( @@ -755,24 +817,6 @@ function LoanCard(props: { )} - -
- {fmtnum(ltv, "pct2z")}% -
-
- + {formatRisk(redemptionRisk)} diff --git a/frontend/app/src/screens/LoanScreen/PanelClosePosition.tsx b/frontend/app/src/screens/LoanScreen/PanelClosePosition.tsx index a8ea1704..7fa3745d 100644 --- a/frontend/app/src/screens/LoanScreen/PanelClosePosition.tsx +++ b/frontend/app/src/screens/LoanScreen/PanelClosePosition.tsx @@ -1,23 +1,29 @@ -import type { PositionLoanCommitted } from "@/src/types"; +import type { PositionLoanCommitted, TokenSymbol } from "@/src/types"; import { Amount } from "@/src/comps/Amount/Amount"; import { ErrorBox } from "@/src/comps/ErrorBox/ErrorBox"; import { Field } from "@/src/comps/Field/Field"; import { FlowButton } from "@/src/comps/FlowButton/FlowButton"; +import { LEVERAGE_SLIPPAGE_TOLERANCE } from "@/src/constants"; import content from "@/src/content"; +import { DNUM_0 } from "@/src/dnum-utils"; import { fmtnum } from "@/src/formatting"; +import { useQuoteExactOutput } from "@/src/liquity-leverage"; import { getBranch, getCollToken } from "@/src/liquity-utils"; import { usePrice } from "@/src/services/Prices"; import { useAccount, useBalance } from "@/src/wagmi-utils"; import { WHITE_LABEL_CONFIG } from "@/src/white-label.config"; import { css } from "@/styled-system/css"; -import { addressesEqual, TokenIcon, TOKENS_BY_SYMBOL, VFlex } from "@liquity2/uikit"; +import { addressesEqual, Dropdown, TokenIcon, VFlex } from "@liquity2/uikit"; import * as dn from "dnum"; +import { useState } from "react"; export function PanelClosePosition({ loan, + loanMode, }: { loan: PositionLoanCommitted; + loanMode: "borrow" | "multiply"; }) { const account = useAccount(); @@ -28,29 +34,35 @@ export function PanelClosePosition({ const boldPriceUsd = usePrice(WHITE_LABEL_CONFIG.tokens.mainToken.symbol); const boldBalance = useBalance(account.address, WHITE_LABEL_CONFIG.tokens.mainToken.symbol); - // const [repayDropdownIndex, setRepayDropdownIndex] = useState(0); - const repayDropdownIndex = 0; + // close from collateral by default for leveraged positions + const [repayDropdownIndex, setRepayDropdownIndex] = useState(loanMode === "multiply" ? 1 : 0); - const repayToken = TOKENS_BY_SYMBOL[repayDropdownIndex === 0 ? WHITE_LABEL_CONFIG.tokens.mainToken.symbol : collateral.symbol]; - if (!repayToken) { - throw new Error(`Repay token not found for symbol: ${repayDropdownIndex === 0 ? WHITE_LABEL_CONFIG.tokens.mainToken.symbol : collateral.symbol}`); - } + const claimOnly = dn.eq(loan.borrowed, DNUM_0); // happens in case the loan got redeemed + const repayWithCollateral = !claimOnly && repayDropdownIndex === 1; + const repayToken = repayWithCollateral ? collateral.symbol : WHITE_LABEL_CONFIG.tokens.mainToken.symbol as TokenSymbol; + const slippageProtection = dn.mul(loan.borrowed, LEVERAGE_SLIPPAGE_TOLERANCE); - // either in main token or in collateral - const amountToRepay = repayToken.symbol === WHITE_LABEL_CONFIG.tokens.mainToken.symbol - ? loan.borrowed - : collPriceUsd.data && dn.div(loan.borrowed, collPriceUsd.data); + const collToRepay = useQuoteExactOutput({ + inputToken: collateral.symbol, + outputToken: WHITE_LABEL_CONFIG.tokens.mainToken.symbol, + outputAmount: dn.add(loan.borrowed, slippageProtection), + }); + + // either in {WHITE_LABEL_CONFIG.tokens.mainToken.symbol} or in collateral + const amountToRepay = repayWithCollateral + ? collToRepay.data?.inputAmount + : loan.borrowed; const amountToRepayUsd = amountToRepay && ( - repayToken.symbol === WHITE_LABEL_CONFIG.tokens.mainToken.symbol - ? boldPriceUsd.data && dn.mul(amountToRepay, boldPriceUsd.data) - : collPriceUsd.data && dn.mul(amountToRepay, collPriceUsd.data) + repayWithCollateral + ? collPriceUsd.data && dn.mul(amountToRepay, collPriceUsd.data) + : boldPriceUsd.data && dn.mul(amountToRepay, boldPriceUsd.data) ); // when repaying with collateral, subtract the amount used to repay - const collToReclaim = repayToken.symbol === WHITE_LABEL_CONFIG.tokens.mainToken.symbol - ? loan.deposit - : amountToRepay && dn.sub(loan.deposit, amountToRepay); + const collToReclaim = repayWithCollateral + ? collToRepay.data?.inputAmount && dn.sub(loan.deposit, collToRepay.data.inputAmount) + : loan.deposit; const collToReclaimUsd = collToReclaim && collPriceUsd.data && dn.mul( collToReclaim, @@ -66,30 +78,27 @@ export function PanelClosePosition({ message: "The current account is not the owner of the loan.", }; } - if ( - isOwner - && repayToken.symbol === WHITE_LABEL_CONFIG.tokens.mainToken.symbol - && amountToRepay - && (!boldBalance.data || dn.lt(boldBalance.data, amountToRepay)) - ) { - return { - name: `Insufficient ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol} balance`, - message: `The balance held by the account (${ - fmtnum(boldBalance.data) - } ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol}) is insufficient to repay the loan.`, - }; + if (repayWithCollateral) { + if (collToRepay.data?.inputAmount === null) { + return { + name: `Insufficient ${collateral.name} liquidity`, + message: "There's not enough liquidity to repay the loan using collateral.", + }; + } + } else { + if (boldBalance.data && dn.lt(boldBalance.data, loan.borrowed)) { + return { + name: `Insufficient ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol} balance`, + message: `The balance held by the account (${ + fmtnum(boldBalance.data) + } ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol}) is insufficient to repay the loan.`, + }; + } } return null; })(); - if (!collPriceUsd.data || !boldPriceUsd.data || !amountToRepay || !collToReclaim) { - return null; - } - - // happens in case the loan got redeemed - const claimOnly = dn.eq(amountToRepay, 0); - - const allowSubmit = error === null; + const allowSubmit = error === null && collToRepay.data; return ( <> @@ -115,81 +124,54 @@ export function PanelClosePosition({ > - { - /* ({ - icon: , + icon: , label: ( <> - {repayToken.name} - - {repayToken.symbol === WHITE_LABEL_CONFIG.tokens.mainToken.symbol ? " account" : " loan"} + {repayToken === WHITE_LABEL_CONFIG.tokens.mainToken.symbol ? WHITE_LABEL_CONFIG.tokens.mainToken.symbol : collateral.name} + + {repayWithCollateral ? " loan" : " account"} ), })} - items={([WHITE_LABEL_CONFIG.tokens.mainToken.symbol, collateral.symbol] as const).map((symbol) => ({ - icon: , - label: ( -
- {TOKENS_BY_SYMBOL[symbol].name} {symbol === WHITE_LABEL_CONFIG.tokens.mainToken.symbol ? "(account)" : "(collateral)"} -
- ), - disabled: symbol !== WHITE_LABEL_CONFIG.tokens.mainToken.symbol, - disabledReason: symbol !== WHITE_LABEL_CONFIG.tokens.mainToken.symbol ? "Coming soon" : undefined, - value: symbol === WHITE_LABEL_CONFIG.tokens.mainToken.symbol ? fmtnum(boldBalance.data) : null, - }))} + items={[ + { + icon: , + label:
{WHITE_LABEL_CONFIG.tokens.mainToken.symbol} (account)
, + value: fmtnum(boldBalance.data), + }, + { + icon: , + label:
{collateral.name} (loan)
, + }, + ]} menuWidth={300} menuPlacement="end" onSelect={setRepayDropdownIndex} selected={repayDropdownIndex} - />*/ - } -
- -
{WHITE_LABEL_CONFIG.tokens.mainToken.symbol}
-
+ /> } footer={{ - start: ( - , + end: repayWithCollateral && ( + ), }} /> )} -
{fmtnum(collToReclaim)}
+
} footer={{ - start: ( - , + + end: repayWithCollateral && ( + ), }} @@ -255,9 +243,9 @@ export function PanelClosePosition({ > {claimOnly ? content.closeLoan.claimOnly - : repayToken.symbol === WHITE_LABEL_CONFIG.tokens.mainToken.symbol - ? content.closeLoan.repayWithBoldMessage - : content.closeLoan.repayWithCollateralMessage} + : repayWithCollateral + ? content.closeLoan.repayWithCollateralMessage(collateral.name) + : content.closeLoan.repayWithBoldMessage}
{error && ( @@ -273,7 +261,7 @@ export function PanelClosePosition({ disabledReason={ !isOwner ? "Only the loan owner can close this position" - : !claimOnly && repayToken.symbol === WHITE_LABEL_CONFIG.tokens.mainToken.symbol + : !claimOnly && repayToken === WHITE_LABEL_CONFIG.tokens.mainToken.symbol && amountToRepay && (!boldBalance.data || dn.lt(boldBalance.data, amountToRepay)) ? `Insufficient ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol} balance to repay the loan` @@ -291,7 +279,9 @@ export function PanelClosePosition({ successLink: ["/", "Go to the dashboard"], successMessage: "The loan position has been closed successfully.", loan, - repayWithCollateral: claimOnly ? false : repayToken.symbol !== WHITE_LABEL_CONFIG.tokens.mainToken.symbol, + repayWithCollateral: repayWithCollateral + ? { flashLoanAmount: collToRepay.data?.inputAmount ?? DNUM_0 } + : undefined, }} /> diff --git a/frontend/app/src/screens/LoanScreen/PanelInterestRate.tsx b/frontend/app/src/screens/LoanScreen/PanelInterestRate.tsx index 7d617b6e..5d7958be 100644 --- a/frontend/app/src/screens/LoanScreen/PanelInterestRate.tsx +++ b/frontend/app/src/screens/LoanScreen/PanelInterestRate.tsx @@ -6,26 +6,37 @@ import { Amount } from "@/src/comps/Amount/Amount"; import { Field } from "@/src/comps/Field/Field"; import { FlowButton } from "@/src/comps/FlowButton/FlowButton"; import { InterestRateField } from "@/src/comps/InterestRateField/InterestRateField"; +import { LinkTextButton } from "@/src/comps/LinkTextButton/LinkTextButton"; import { UpdateBox } from "@/src/comps/UpdateBox/UpdateBox"; +import { WarningBox } from "@/src/comps/WarningBox/WarningBox"; +import { INTEREST_RATE_ADJ_COOLDOWN } from "@/src/constants"; import content from "@/src/content"; import { useInputFieldValue } from "@/src/form-utils"; import { WHITE_LABEL_CONFIG } from "@/src/white-label.config"; import { fmtnum, formatRelativeTime } from "@/src/formatting"; import { formatRisk } from "@/src/formatting"; import { getLoanDetails } from "@/src/liquity-math"; -import { getBranch, getCollToken, useRedemptionRisk, useTroveRateUpdateCooldown } from "@/src/liquity-utils"; +import { + getCollToken, + useBranchCollateralRatios, + useRedemptionRiskOfInterestRate, + useRedemptionRiskOfLoan, + useTroveRateUpdateCooldown, +} from "@/src/liquity-utils"; import { usePrice } from "@/src/services/Prices"; import { infoTooltipProps, riskLevelToStatusMode } from "@/src/uikit-utils"; import { useAccount } from "@/src/wagmi-utils"; import { css } from "@/styled-system/css"; -import { addressesEqual, HFlex, IconSuggestion, InfoTooltip, StatusDot } from "@liquity2/uikit"; +import { addressesEqual, Checkbox, HFlex, IconExternal, IconSuggestion, InfoTooltip, StatusDot } from "@liquity2/uikit"; import * as dn from "dnum"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; export function PanelInterestRate({ loan, + loanMode, }: { loan: PositionLoanCommitted; + loanMode: "borrow" | "multiply"; }) { const account = useAccount(); @@ -39,16 +50,9 @@ export function PanelInterestRate({ defaultValue: dn.toString(loan.borrowed), }); - const { strategies } = getBranch(loan.branchId); - - const { batchManager } = loan; - const isIcpDelegated = batchManager && strategies.some((s) => addressesEqual(s.address, batchManager)); - const [interestRate, setInterestRate] = useState(loan.interestRate); const [interestRateMode, setInterestRateMode] = useState( - isIcpDelegated - ? "strategy" - : loan.batchManager + loan.batchManager ? "delegate" : "manual", ); @@ -56,10 +60,16 @@ export function PanelInterestRate({ loan.batchManager, ); + const [agreeToLiquidationRisk, setAgreeToLiquidationRisk] = useState(false); + const agreeCheckboxId = useId(); + const updateRateCooldown = useUpdateRateCooldown(loan.branchId, loan.troveId); - const currentRedemptionRisk = useRedemptionRisk(loan.branchId, loan.interestRate); - const newRedemptionRisk = useRedemptionRisk(loan.branchId, interestRate); + const collateralRatios = useBranchCollateralRatios(loan.branchId); + const isZombieTrove = loan.isZombie; + + const currentRedemptionRisk = useRedemptionRiskOfLoan(loan); + const newRedemptionRisk = useRedemptionRiskOfInterestRate(loan.branchId, interestRate, loan); const loanDetails = getLoanDetails( loan.deposit, @@ -77,6 +87,10 @@ export function PanelInterestRate({ collPrice.data ?? null, ); + useEffect(() => { + setAgreeToLiquidationRisk(false); + }, [newLoanDetails.status]); + const boldInterestPerYear = interestRate && debt.parsed && dn.mul(debt.parsed, interestRate); @@ -85,19 +99,29 @@ export function PanelInterestRate({ && loan.borrowed && dn.mul(loan.borrowed, loan.interestRate); + const isCcrConditionsNotMet = collateralRatios.data?.tcr + && collateralRatios.data?.ccr + && updateRateCooldown + && dn.lt(collateralRatios.data.tcr, collateralRatios.data.ccr) + && updateRateCooldown.active; + + const isDelegated = interestRateMode === "delegate" && interestRateDelegate; const allowSubmit = Boolean( account.address && addressesEqual( loan.borrower, account.address, ), ) + && !isZombieTrove && deposit.parsed && dn.gt(deposit.parsed, 0) && debt.parsed && dn.gt(debt.parsed, 0) && interestRate && dn.gt(interestRate, 0) && ( !dn.eq(interestRate, loan.interestRate) || loan.batchManager !== interestRateDelegate - ); + ) + && !isCcrConditionsNotMet + && (newLoanDetails.status !== "at-risk" || (!isDelegated && agreeToLiquidationRisk)); return ( <> @@ -233,6 +257,94 @@ export function PanelInterestRate({ ]} />
+ + {isCcrConditionsNotMet && collateralRatios.data + ? ( + +
+
+ {content.ccrWarning.title} +
+
+ {content.ccrWarning.interestRateAdjustment({ + tcr: , + ccr: , + cooldownDays: INTEREST_RATE_ADJ_COOLDOWN / (24 * 60 * 60), + })} +
+ + {content.ccrWarning.learnMoreLabel} + + + } + /> +
+
+ ) + : newLoanDetails.status === "at-risk" && ( + + {isDelegated + ? content.atRiskWarning.delegated(`${fmtnum(newLoanDetails.maxLtvAllowed, "pct2z")}%`) + : ( + <> + {content.atRiskWarning.manual( + `${fmtnum(newLoanDetails.ltv, "pct2z")}%`, + `${fmtnum(newLoanDetails.maxLtv, "pct2z")}%`, + ).message} + + + )} + + )} + + {isZombieTrove && ( + +
+ Interest rate can't be adjusted on loans with debt below 2,000 ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol}. Please adjust your debt first. +
+
+ )} + ("add"); const debtChange = useInputFieldValue((value) => fmtnum(value, "full")); + const [agreeToLiquidationRisk, setAgreeToLiquidationRisk] = useState(false); + const agreeCheckboxId = useId(); + const newDebt = debtChange.parsed && ( debtMode === "remove" ? dn.sub(loan.borrowed, debtChange.parsed) @@ -117,6 +125,15 @@ export function PanelUpdateBorrowPosition({ collPrice.data, ); + useEffect(() => { + setAgreeToLiquidationRisk(false); + }, [newLoanDetails.status]); + + const insufficientColl = depositMode === "add" + && depositChange.parsed + && collBalance.data + && (dn.gt(depositChange.parsed, collBalance.data)); + const maxLtv = dn.div(dn.from(1, 18), collToken.collateralRatio); const isBelowMinDebt = debtChange.parsed && !debtChange.isEmpty && newDebt @@ -130,6 +147,53 @@ export function PanelUpdateBorrowPosition({ && boldBalance.data && dn.gt(debtChange.parsed, boldBalance.data); + const branchDebt = useBranchDebt(loan.branchId); + const collateralRatios = useBranchCollateralRatios(loan.branchId); + + const loanChanges = newDeposit && newDebt && collPrice.data + ? getLoanChanges(loan.deposit, newDeposit, loan.borrowed, newDebt, collPrice.data) + : null; + + // expected TCR after the user updates the position + const newTcr = branchDebt.data + && collateralRatios.data?.tcr + && loanChanges + ? (() => { + const branchColl = dn.mul(collateralRatios.data.tcr, branchDebt.data); + + const totalCollAfter = dn.add(branchColl, loanChanges.loanCollChange); + const totalDebtAfter = dn.add(branchDebt.data, loanChanges.loanDebtChange); + + return dn.div(totalCollAfter, totalDebtAfter); + })() + : null; + + const isNewTcrLtCcr = newTcr + && collateralRatios.data?.ccr + && dn.lt(newTcr, collateralRatios.data.ccr); + + const isNewTcrLteCcr = newTcr + && collateralRatios.data?.ccr + && dn.lte(newTcr, collateralRatios.data.ccr); + + const isOldTcrLtCcr = collateralRatios.data?.ccr + && collateralRatios.data?.tcr + && dn.lt(collateralRatios.data.tcr, collateralRatios.data.ccr); + + const isDebtChangeGteCollChange = dn.gte( + loanChanges?.loanDebtChange ?? dnum18(0), + loanChanges?.loanCollChange ?? dnum18(0), + ); + + const isCcrConditionsNotMet = ((depositChange.parsed && dn.gt(depositChange.parsed, 0)) + || (debtChange.parsed && dn.gt(debtChange.parsed, 0))) && ( + !isOldTcrLtCcr + ? isNewTcrLtCcr + : (debtMode === "add" && dn.gt(debtChange.parsed ?? dnum18(0), dnum18(0))) + ? isNewTcrLteCcr || isDebtChangeGteCollChange + : isDebtChangeGteCollChange + ); + const allowSubmit = account.isConnected // above min. debt && !isBelowMinDebt @@ -143,7 +207,13 @@ export function PanelUpdateBorrowPosition({ || !dn.eq(loanDetails.debt ?? dnum18(0), newLoanDetails.debt ?? dnum18(0)) ) // the LTV is not above the maximum - && !isAboveMaxLtv; + && !isAboveMaxLtv + // TCR must not be below CCR + && !isCcrConditionsNotMet + // at-risk warning agreement (only for non-delegated loans) + && (newLoanDetails.status !== "at-risk" || (!loan.batchManager && agreeToLiquidationRisk)) + // the account must have enough collateral balance + && !insufficientColl; return ( <> @@ -160,6 +230,9 @@ export function PanelUpdateBorrowPosition({ label={collToken.name} /> } + drawer={!depositChange.isFocused && insufficientColl + ? { mode: "error", message: `Insufficient ${collToken.name} balance.` } + : null} label={{ start: depositMode === "remove" ? "Decrease collateral" @@ -286,6 +359,11 @@ export function PanelUpdateBorrowPosition({ } drawer={!debtChange.isFocused && isBelowMinDebt ? { mode: "error", message: `You must borrow at least ${fmtnum(MIN_DEBT, 2)} ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol}.` } + : isAboveMaxLtv + ? { + mode: "error", + message: `Your LTV must be lower than ${fmtnum(dn.toNumber(loanDetails.maxLtv), "pct2z")}%`, + } : insufficientBold ? { mode: "error", message: `Insufficient ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol} balance.` } : null} @@ -428,6 +506,97 @@ export function PanelUpdateBorrowPosition({ /> + + {isCcrConditionsNotMet && collateralRatios.data + ? ( + +
+
+ {content.ccrWarning.title} +
+
+ {!isOldTcrLtCcr + ? content.ccrWarning.updatePushBelow({ + newTcr: , + ccr: , + }) + : debtMode === "add" && dn.gt(debtChange.parsed ?? dnum18(0), dnum18(0)) + ? content.ccrWarning.updateBorrowMore({ + tcr: , + ccr: , + newTcr: , + isNewTcrLteCcr: Boolean(isNewTcrLteCcr), + }) + : content.ccrWarning.updateWithdrawColl({ + tcr: , + ccr: , + })} +
+ + {content.ccrWarning.learnMoreLabel} + + + } + /> +
+
+ ) + : newLoanDetails.status === "at-risk" && ( + + {loan.batchManager + ? content.atRiskWarning.delegated(`${fmtnum(newLoanDetails.maxLtvAllowed, "pct2z")}%`) + : ( + <> + {content.atRiskWarning.manual( + `${fmtnum(newLoanDetails.ltv, "pct2z")}%`, + `${fmtnum(newLoanDetails.maxLtv, "pct2z")}%`, + ).message} + + + )} + + )} + ("add"); - const depositChange = useInputFieldValue((value) => fmtnum(value, "full")); - const [userLeverageFactor, setUserLeverageFactor] = useState( - initialLoanDetails.leverageFactor ?? 1, - ); - - let newDepositPreLeverage = depositChange.parsed - ? ( - depositMode === "remove" - ? dn.sub( - initialLoanDetails.depositPreLeverage ?? dnum18(0), - depositChange.parsed, - ) - : dn.add( - initialLoanDetails.depositPreLeverage ?? dnum18(0), - depositChange.parsed, - ) - ) - : initialLoanDetails.depositPreLeverage; - - if (newDepositPreLeverage && dn.lt(newDepositPreLeverage, 0)) { - newDepositPreLeverage = dnum18(0); - } + const depositChange = useInputFieldValue(formatFull); - const newDeposit = dn.mul( - newDepositPreLeverage ?? dnum18(0), - userLeverageFactor, - ); + const newDeposit = depositChange.parsed + ? (depositMode === "remove" ? dn.sub : dn.add)(loan.deposit, depositChange.parsed) + : loan.deposit; - const totalPositionValue = dn.mul(newDeposit, collPrice.data ?? dnum18(0)); + const newDepositPreLeverage = initialLoanDetails.depositPreLeverage && depositChange.parsed + ? (depositMode === "remove" ? dn.sub : dn.add)(initialLoanDetails.depositPreLeverage, depositChange.parsed) + : initialLoanDetails.depositPreLeverage; - const newDebt = dn.sub( - totalPositionValue, - dn.mul(newDepositPreLeverage ?? dnum18(0), collPrice.data ?? dnum18(0)), - ); + const leverageField = useLeverageField({ + collPrice: collPrice.data ?? null, + collToken, + positionDeposit: newDeposit, + positionDebt: loan.borrowed, + maxLtvAllowedRatio: 1 - MAX_LTV_RESERVE_RATIO, + }); const newLoanDetails = getLoanDetails( - newDeposit, - newDebt, + leverageField.deposit, + leverageField.debt, initialLoanDetails.interestRate, collToken.collateralRatio, collPrice.data ?? null, ); const liquidationPrice = getLiquidationPriceFromLeverage( - userLeverageFactor, - collPrice.data ?? dnum18(0), + leverageField.leverageFactor, + collPrice.data ?? DNUM_0, collToken.collateralRatio, ); - // leverage factor - const leverageField = useLeverageField({ - collPrice: collPrice.data ?? dnum18(0), - collToken, - depositPreLeverage: newDepositPreLeverage, - maxLtvAllowedRatio: 1 - MAX_LTV_RESERVE_RATIO, - }); - const collBalance = useBalance(account.address, collToken.symbol); const collMax = depositMode === "remove" ? null : ( @@ -126,20 +107,6 @@ export function PanelUpdateLeveragePosition({ ) ); - useEffect(() => { - if (leverageField.leverageFactor !== userLeverageFactor) { - setUserLeverageFactor(leverageField.leverageFactor); - } - }, [leverageField.leverageFactor]); - - const initialLeverageFactorSet = useRef(false); - useEffect(() => { - if (initialLoanDetails.leverageFactor && !initialLeverageFactorSet.current) { - leverageField.updateLeverageFactor(initialLoanDetails.leverageFactor); - initialLeverageFactorSet.current = true; - } - }, [leverageField.updateLeverageFactor, initialLoanDetails.leverageFactor]); - const [agreeToLiquidationRisk, setAgreeToLiquidationRisk] = useState(false); useEffect(() => { @@ -148,19 +115,69 @@ export function PanelUpdateLeveragePosition({ const agreeCheckboxId = useId(); + const insufficientColl = depositMode === "add" + && depositChange.parsed + && collBalance.data + && (dn.gt(depositChange.parsed, collBalance.data)); + + const branchDebt = useBranchDebt(loan.branchId); + const collateralRatios = useBranchCollateralRatios(loan.branchId); + + const loanChanges = newDeposit && leverageField.debt && collPrice.data + ? getLoanChanges(loan.deposit, newDeposit, loan.borrowed, leverageField.debt, collPrice.data) + : null; + + const newTcr = branchDebt.data + && collateralRatios.data?.tcr + && loanChanges + ? (() => { + const branchColl = dn.mul(collateralRatios.data.tcr, branchDebt.data); + + const totalCollAfter = dn.add(branchColl, loanChanges.loanCollChange); + const totalDebtAfter = dn.add(branchDebt.data, loanChanges.loanDebtChange); + + return dn.div(totalCollAfter, totalDebtAfter); + })() + : null; + + const isNewTcrLtCcr = newTcr + && collateralRatios.data?.ccr + && dn.lt(newTcr, collateralRatios.data.ccr); + + const isNewTcrLteCcr = newTcr + && collateralRatios.data?.ccr + && dn.lte(newTcr, collateralRatios.data.ccr); + + const isOldTcrLtCcr = collateralRatios.data?.ccr + && collateralRatios.data?.tcr + && dn.lt(collateralRatios.data.tcr, collateralRatios.data.ccr); + + const isDebtChangeGteCollChange = dn.gte( + loanChanges?.loanDebtChange ?? dnum18(0), + loanChanges?.loanCollChange ?? dnum18(0), + ); + + const isCcrConditionsNotMet = ((depositChange.parsed && dn.gt(depositChange.parsed, 0)) + || (leverageField.leverageFactorChange && leverageField.leverageFactorChange !== 0)) && ( + !isOldTcrLtCcr + ? isNewTcrLtCcr + : (leverageField.leverageFactorChange && leverageField.leverageFactorChange > 0) + ? isNewTcrLteCcr || isDebtChangeGteCollChange + : isDebtChangeGteCollChange + ); + const allowSubmit = account.isConnected - && (newLoanDetails.status !== "at-risk" || agreeToLiquidationRisk) + && (newLoanDetails.status !== "at-risk" || (!loan.batchManager && agreeToLiquidationRisk)) && newLoanDetails.status !== "underwater" && newLoanDetails.status !== "liquidatable" && ( // either the deposit or the leverage factor has changed - !dn.eq( - initialLoanDetails.deposit ?? dnum18(0), - newLoanDetails.deposit ?? dnum18(0), - ) || (initialLoanDetails.leverageFactor !== newLoanDetails.leverageFactor) + !dn.eq(initialLoanDetails.deposit ?? DNUM_0, newLoanDetails.deposit ?? DNUM_0) + || initialLoanDetails.leverageFactor !== newLoanDetails.leverageFactor ) - // above the minimum debt - && newLoanDetails.debt && dn.gt(newLoanDetails.debt, MIN_DEBT); + && leverageField.isValid + && !isCcrConditionsNotMet + && !insufficientColl; return ( <> @@ -177,10 +194,13 @@ export function PanelUpdateLeveragePosition({ label={collToken.name} /> } + drawer={!depositChange.isFocused && insufficientColl + ? { mode: "error", message: `Insufficient ${collToken.name} balance.` } + : null} label={{ start: depositMode === "remove" - ? "Decrease your deposit" - : "Increase your deposit", + ? "Decrease deposit" + : "Increase deposit", end: ( } footer={{ - start: , end: initialLoanDetails.depositPreLeverage && newDepositPreLeverage && ( } after={ - - - {fmtnum(newDepositPreLeverage)} {collToken.name} - - - + + {fmtnum(newDepositPreLeverage)} {collToken.name} + } fontSize={14} /> @@ -266,91 +282,44 @@ export function PanelUpdateLeveragePosition({ /> - } + field={} footer={[ { - start: , + start: leverageField.leverageFactorChange === 0 + ? + : leverageField.leverageFactorChange > 0 + ? ( + + ) + : ( + + ), + end: ( - ), - }, - { - start: , - end: ( - - {fmtnum(initialLoanDetails.deposit)} {collToken.name} - - )} - after={newDepositPreLeverage && ( - - {fmtnum(newLoanDetails.deposit)} {collToken.name} - - )} - /> - ), - }, - { - start: , - end: ( - - {initialLoanDetails.status === "underwater" ? INFINITY : ( - `${fmtnum(initialLoanDetails.leverageFactor, 4)}x` - )} - - } - after={ - <> - {fmtnum(userLeverageFactor, "1z")}x - - } + after={fmtnum(liquidationPrice, { preset: "2z", prefix: "$" })} /> ), }, { - start: , - end: ( - - ), + start: leverageField.leverageFactorChange > 0 + ? ( + + ) + : , }, ]} /> @@ -366,7 +335,7 @@ export function PanelUpdateLeveragePosition({ updates={[ { label: "Liquidation risk", - before: initialLoanDetails.liquidationRisk && ( + before: ( <> ), - after: newLoanDetails.liquidationRisk && ( + after: ( <> LTV, - before: initialLoanDetails.ltv && ( - - - + before: , + after: , + }, + { + label: "Multiply", + before: ( + initialLoanDetails.status === "underwater" + ? INFINITY + : + ), + after: , + }, + { + label: "Exposure", + before: ( + ), after: ( - - {newLoanDetails.status === "underwater" - || newLoanDetails.status === "liquidatable" - ? "N/A" - : } - + + ), + }, + { + label: "Debt", + before: ( + + ), + after: ( + ), }, ]} /> - {newLoanDetails.status === "underwater" || newLoanDetails.status === "liquidatable" + {isCcrConditionsNotMet && collateralRatios.data + ? ( + +
+
+ {content.ccrWarning.title} +
+
+ {!isOldTcrLtCcr + ? content.ccrWarning.updatePushBelow({ + newTcr: , + ccr: , + }) + : leverageField.leverageFactorChange && leverageField.leverageFactorChange > 0 + ? content.ccrWarning.updateBorrowMore({ + tcr: , + ccr: , + newTcr: , + isNewTcrLteCcr: Boolean(isNewTcrLteCcr), + }) + : content.ccrWarning.updateWithdrawColl({ + tcr: , + ccr: , + })} +
+ + {content.ccrWarning.learnMoreLabel} + + + } + /> +
+
+ ) + : newLoanDetails.status === "underwater" || newLoanDetails.status === "liquidatable" ? (
@@ -436,29 +475,34 @@ export function PanelUpdateLeveragePosition({ : newLoanDetails.status === "at-risk" ? ( -
- The maximum LTV for the position is{" "} - {fmtnum(newLoanDetails.maxLtv, "pct2z")}%. Your updated position is close and is at risk of being - liquidated. -
- + {loan.batchManager + ? content.atRiskWarning.delegated(`${fmtnum(newLoanDetails.maxLtvAllowed, "pct2z")}%`) + : ( + <> + {content.atRiskWarning.manual( + `${fmtnum(newLoanDetails.ltv, "pct2z")}%`, + `${fmtnum(newLoanDetails.maxLtv, "pct2z")}%`, + ).message} + + + )}
) : null} @@ -476,23 +520,27 @@ export function PanelUpdateLeveragePosition({ successLink: ["/", "Go to the dashboard"], successMessage: "The position has been updated successfully.", - depositChange: (!depositChange.parsed || dn.eq(depositChange.parsed, 0)) - ? null - : dn.mul(depositChange.parsed, depositMode === "remove" ? -1 : 1), + loan: { ...loan, deposit: leverageField.deposit ?? DNUM_0, borrowed: leverageField.debt ?? DNUM_0 }, + prevLoan: loan, + depositChange: depositChange.parsed && !dn.eq(depositChange.parsed, DNUM_0) + ? (depositMode === "remove" ? dnumNeg(depositChange.parsed) : depositChange.parsed) + : null, + debtChange: leverageField.debtChange, + leverageFactorChange: [initialLoanDetails.leverageFactor, leverageField.leverageFactor], - leverageFactorChange: ( - !initialLoanDetails.leverageFactor - || userLeverageFactor === initialLoanDetails.leverageFactor - ) - ? null - : [initialLoanDetails.leverageFactor, userLeverageFactor], - - prevLoan: { ...loan }, - loan: { - ...loan, - deposit: newDeposit, - borrowed: newDebt, - }, + leverage: leverageField.leverageFactorChange !== 0 + ? (leverageField.leverageFactorChange > 0 + ? { + direction: "up", + flashloanAmount: leverageField.depositChange ?? DNUM_0, + boldAmount: leverageField.debtChange ?? DNUM_0, + } + : { + direction: "down", + flashloanAmount: dn.abs(leverageField.depositChange ?? DNUM_0), + minBoldAmount: dn.mul(dn.abs(leverageField.debtChange ?? DNUM_0), 1 - LEVERAGE_SLIPPAGE_TOLERANCE), + }) + : null, }} /> diff --git a/frontend/app/src/screens/RedeemScreen/RedeemScreen.tsx b/frontend/app/src/screens/RedeemScreen/RedeemScreen.tsx index 56f5dced..fcae277f 100644 --- a/frontend/app/src/screens/RedeemScreen/RedeemScreen.tsx +++ b/frontend/app/src/screens/RedeemScreen/RedeemScreen.tsx @@ -1,90 +1,133 @@ "use client"; +import type { BranchId } from "@/src/types"; +import type { Dnum } from "dnum"; +import type { ReactNode } from "react"; + import { Amount } from "@/src/comps/Amount/Amount"; -import { ConnectWarningBox } from "@/src/comps/ConnectWarningBox/ConnectWarningBox"; import { Field } from "@/src/comps/Field/Field"; +import { FlowButton } from "@/src/comps/FlowButton/FlowButton"; +import { LinkTextButton } from "@/src/comps/LinkTextButton/LinkTextButton"; import { Screen } from "@/src/comps/Screen/Screen"; +import { Value } from "@/src/comps/Value/Value"; +import { + REDEMPTION_FEE_HIGH, + REDEMPTION_MAX_ITERATIONS_PER_COLL, + REDEMPTION_SLIPPAGE_TOLERANCE, +} from "@/src/constants"; import content from "@/src/content"; -import { getProtocolContract } from "@/src/contracts"; -import { dnum18 } from "@/src/dnum-utils"; -import { parseInputPercentage, useInputFieldValue } from "@/src/form-utils"; +import { dnum18, DNUM_0 } from "@/src/dnum-utils"; +import { useInputFieldValue } from "@/src/form-utils"; import { fmtnum } from "@/src/formatting"; -import { getBranches, getCollToken } from "@/src/liquity-utils"; -import { useTransactionFlow } from "@/src/services/TransactionFlow"; +import { getBranches, getCollToken, useRedemptionSimulation } from "@/src/liquity-utils"; +import { useCollateralPrices, usePrice } from "@/src/services/Prices"; +import { zipWith } from "@/src/utils"; import { useAccount, useBalance } from "@/src/wagmi-utils"; import { WHITE_LABEL_CONFIG } from "@/src/white-label.config"; import { css } from "@/styled-system/css"; -import { Button, HFlex, InfoTooltip, InputField, TextButton, TokenIcon } from "@liquity2/uikit"; +import { HFlex, IconExternal, InfoTooltip, InputField, TextButton, TokenIcon, VFlex } from "@liquity2/uikit"; import * as dn from "dnum"; -import Link from "next/link"; -import { useRef } from "react"; -import { useReadContract } from "wagmi"; + +const TRUNCATED_THRESHOLD = dnum18(100); // wei +const maxIterationsPerCollateral = REDEMPTION_MAX_ITERATIONS_PER_COLL; +const slippageTolerance = dn.from(REDEMPTION_SLIPPAGE_TOLERANCE); + +const branches = getBranches(); +const collTokens = branches.map((b) => getCollToken(b.branchId)); +const collTokenNames = collTokens.map((collToken) => collToken.name.replace(/^ETH$/, "WETH")); + +const listOfCollTokenNames = [ + ...( + collTokenNames.length > 1 + ? [collTokenNames.slice(0, -1).join(", ")] + : [] + ), + ...collTokenNames.slice(-1), +].join(" and "); + +const zipWithMul = zipWith(dn.mul); export function RedeemScreen() { const account = useAccount(); - const txFlow = useTransactionFlow(); - + const boldBalance = useBalance(account.address, WHITE_LABEL_CONFIG.tokens.mainToken.symbol); + const boldPrice = usePrice(WHITE_LABEL_CONFIG.tokens.mainToken.symbol); + const collPrices = useCollateralPrices(branches.map((b) => b.symbol)); + const boldRedeemed = useInputFieldValue(fmtnum); - const CollateralRegistry = getProtocolContract("CollateralRegistry"); - const redemptionRate = useReadContract({ - ...CollateralRegistry, - functionName: "getRedemptionRateWithDecay", + const simulation = useRedemptionSimulation({ + boldAmount: boldRedeemed.parsed ?? DNUM_0, + maxIterationsPerCollateral, }); - const amount = useInputFieldValue(fmtnum); - const maxFee = useInputFieldValue((value) => `${fmtnum(value, "pct2z")}%`, { - parse: parseInputPercentage, - }); + const boldRedeemedUsd = simulation.data && boldPrice.data + && dn.mul(simulation.data.truncatedBold, boldPrice.data); - const hasUpdatedRedemptionRate = useRef(false); - if (!hasUpdatedRedemptionRate.current && redemptionRate.data) { - if (maxFee.isEmpty) { - maxFee.setValue( - fmtnum( - dn.mul(dnum18(redemptionRate.data), 1.1), - "pct2z", - ), - ); - } - hasUpdatedRedemptionRate.current = true; - } + const collRedeemedUsd = simulation.data && collPrices.data + && zipWithMul(simulation.data.collRedeemed, collPrices.data); + + const totalCollRedeemedUsd = collRedeemedUsd + && collRedeemedUsd.reduce((a, b) => dn.add(a, b)); + + const profitLoss = totalCollRedeemedUsd && boldRedeemedUsd + && dn.sub(totalCollRedeemedUsd, boldRedeemedUsd); + + const isLoss = profitLoss + && dn.lt(profitLoss, DNUM_0); - const branches = getBranches(); + const truncatedAmount = boldRedeemed.parsed && simulation.data?.bouncing === false + && dn.gt(dn.sub(boldRedeemed.parsed, simulation.data.truncatedBold), TRUNCATED_THRESHOLD) + ? simulation.data.truncatedBold + : null; - const allowSubmit = account.isConnected - && amount.parsed - && maxFee.parsed - && boldBalance.data - && dn.gte(boldBalance.data, amount.parsed); + const amount = truncatedAmount ?? boldRedeemed.parsed; + const amountNonZero = amount && dn.gt(amount, DNUM_0); + const balanceSufficient = amount && boldBalance.data && dn.lte(amount, boldBalance.data); + const allowSubmit = account.isConnected && amountNonZero && balanceSufficient; + + const drawer = boldRedeemed.isFocused + ? null + : !balanceSufficient + ? { + mode: "error" as const, + message: `Insufficient ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol} balance. You have ${fmtnum(boldBalance.data)} ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol}.`, + } + : truncatedAmount + ? { + mode: "warning" as const, + message: ( + + Amount capped to avoid excessive costs. + + The number of loans you redeem from will be capped at {maxIterationsPerCollateral}{" "} + per collateral branch. This is to avoid a transaction with unusually large gas usage, which might delay the + execution of your redemption. +
+
+ You will be able to redeem the rest of your ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol} in a follow-up transaction. +
+
+ ), + } + : null; return ( - Redeem {WHITE_LABEL_CONFIG.tokens.mainToken.symbol} for + Redeem + + {WHITE_LABEL_CONFIG.tokens.mainToken.symbol} for - {branches.map((b) => getCollToken(b.branchId)).map(({ symbol }) => ( - - ))} - {" "} - ETH + {collTokens.map(({ symbol }) => )} + + {WHITE_LABEL_CONFIG.tokens.otherTokens.eth.name} ), }} > -
+ } - drawer={amount.isFocused - ? null - : boldBalance.data - && amount.parsed - && dn.gt(amount.parsed, boldBalance.data) - ? { - mode: "error", - message: `Insufficient ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol} balance. You have ${fmtnum(boldBalance.data)} ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol}.`, - } - : null} - label="Redeeming" + drawer={drawer} + label="You pay" placeholder="0.00" secondary={{ - start: `$${ - amount.parsed - ? fmtnum(amount.parsed) - : "0.00" - }`, + start: fmtnum(boldRedeemedUsd, { prefix: "$", preset: "2z" }) || " ", end: ( boldBalance.data && dn.gt(boldBalance.data, 0) && ( { if (boldBalance.data) { - amount.setValue(dn.toString(boldBalance.data)); + boldRedeemed.setValue(dn.toString(boldBalance.data)); } }} /> ) ), }} - {...amount.inputFieldProps} + {...boldRedeemed.inputFieldProps} + // Show trucated amount when input field is not focused + value={!boldRedeemed.isFocused && truncatedAmount + ? fmtnum(truncatedAmount) + : boldRedeemed.inputFieldProps.value} /> } - /> + footer={{ + end: ( + + + + - - } - footer={[ - { - end: ( - - <> - Current redemption rate: - + You will be charged a dynamic redemption fee — the more redemptions, the higher the fee. + During periods of no redemption activity, the fee slowly decreases towards a minimum of + 0.5%. If you see a fee significantly higher than this, it might make sense to try redeeming + at a later time, or to break up your redemption into several smaller ones. + + ), + footerLink: { + label: "Learn more about the fee", + href: "https://docs.liquity.org/v2-faq/redemptions-and-delegation#is-there-a-redemption-fee", + }, + }} /> - - - This is the maximum redemption fee you are willing to pay. The redemption fee is a percentage - of the redeemed amount that is paid to the protocol. The redemption fee must be higher than - the current fee. - - ), - footerLink: { - href: "https://dune.com/queries/4641717/7730245", - label: "Redemption fee on Dune", - }, - }} - /> - - ), - }, - ]} + + } + /> + ), + }} /> -
-
-

- Important note -

-
-

- You will be charged a dynamic redemption fee (the more redemptions, the higher the fee). Trading ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol} on an - exchange could be more favorable.{" "} - - Learn more about redemptions. - -

-
- -
- -
-
+ + + +
); } + +function RedemptionOutput(props: { + branchId: BranchId; + amount: Dnum | null | undefined; + amountUsd: Dnum | null | undefined; +}) { + const collateralToken = getCollToken(props.branchId); + const collateralTokenName = collateralToken.symbol === "ETH" ? "WETH" : collateralToken.name; + + return ( + + + {collateralTokenName} + {collateralTokenName === "WETH" && ( + + You will receive{" "} + WETH, which is an ERC-20 tokenized version of ETH that is equivalent in + value. + + )} + + + + + + + + +
+ +
+
+
+ ); +} + +function InfoBox(props: { + title?: ReactNode; + children?: ReactNode; +}) { + return ( +
+ {props.title && ( +
+

{props.title}

+
+ )} + + {props.children} +
+ ); +} diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/PanelVoting.tsx b/frontend/app/src/screens/StakeScreen/components/PanelVoting/PanelVoting.tsx index f71d1b31..5094705c 100644 --- a/frontend/app/src/screens/StakeScreen/components/PanelVoting/PanelVoting.tsx +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/PanelVoting.tsx @@ -1,14 +1,13 @@ -import { Provider as PanelVotingProvider } from "./providers/PanelVotingProvider"; import { useVotingState } from "@/src/screens/StakeScreen/components/PanelVoting/providers/PanelVotingProvider/hooks"; -import { Loader } from "./components/Loader"; import { css } from "@/styled-system/css"; -import { Header } from "./components/Header"; -import { VotingRoundTimer } from "./components/VotingRoundTimer"; +import { CastVotes } from "./components/CastVotes"; import { CutoffWarning } from "./components/CutoffWarning"; import { EpochInitiativesTable } from "./components/EpochInitiativesTable"; -import { BribeMarketsInfo } from "./components/BribeMarketsInfo"; import { EpochVotingStatus } from "./components/EpochVotingStatus"; -import { CastVotes } from "./components/CastVotes"; +import { Header } from "./components/Header"; +import { Loader } from "./components/Loader"; +import { VotingRoundTimer } from "./components/VotingRoundTimer"; +import { Provider as PanelVotingProvider } from "./providers/PanelVotingProvider"; import type { FC } from "react"; @@ -35,7 +34,6 @@ export const PanelVoting: FC = () => { -
diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/BribeMarketsInfo/BribeMarketsInfo.tsx b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/BribeMarketsInfo/BribeMarketsInfo.tsx deleted file mode 100644 index e327876b..00000000 --- a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/BribeMarketsInfo/BribeMarketsInfo.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { LinkTextButton } from "@/src/comps/LinkTextButton/LinkTextButton"; -import { IconExternal } from "@liquity2/uikit"; -import { css } from "@/styled-system/css"; - -import type { FC } from "react"; - -const url = - "https://www.liquity.org/blog/bribe-markets-in-liquity-v2-strategic-value-for-lqty-stakers"; - -export const BribeMarketsInfo: FC = () => ( -
-
-

- Bribe Markets in Liquity V2 -

-

- Initiatives may offer bribes to incentivize votes, which are displayed - in the table above and can be claimed afterwards on this page. -

-
- - Learn more about bribes - - - } - /> -
-); diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/BribeMarketsInfo/index.ts b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/BribeMarketsInfo/index.ts deleted file mode 100644 index 49e7e151..00000000 --- a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/BribeMarketsInfo/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { BribeMarketsInfo } from './BribeMarketsInfo'; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/CastVotes/CastVotes.tsx b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/CastVotes/CastVotes.tsx index e5ce7a91..c27ab428 100644 --- a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/CastVotes/CastVotes.tsx +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/CastVotes/CastVotes.tsx @@ -1,40 +1,40 @@ -import { useMemo } from "react"; -import { eq, gt } from "dnum"; +import * as dn from "dnum"; +import { type FC, useMemo } from "react"; + import { FlowButton } from "@/src/comps/FlowButton/FlowButton"; -import { - useHasAllocations, - useIsAllocationChanged, -} from "@/src/screens/StakeScreen/components/PanelVoting/hooks"; +import { useHasAllocations, useIsAllocationChanged } from "@/src/screens/StakeScreen/components/PanelVoting/hooks"; import { useRemainingVotingPower } from "@/src/screens/StakeScreen/components/PanelVoting/hooks"; import { useVotingStateContext } from "@/src/screens/StakeScreen/components/PanelVoting/providers/PanelVotingProvider/hooks"; import { filterVoteAllocationsForSubmission } from "@/src/screens/StakeScreen/components/PanelVoting/utils"; - -import type { FC } from "react"; import type { Dnum } from "@/src/types"; export const CastVotes: FC = () => { - const { governanceUserData, inputVoteAllocations, initiativesStatesData } = - useVotingStateContext(); + const { + governanceUserData, + inputVoteAllocations, + initiativesStatesData, + votingInputError, + } = useVotingStateContext(); const isAllocationChanged = useIsAllocationChanged(); const hasAnyAllocations = useHasAllocations(); const remainingVotingPower = useRemainingVotingPower(); const stakedLQTY: Dnum = useMemo( () => [governanceUserData?.stakedLQTY ?? 0n, 18], - [governanceUserData?.stakedLQTY] + [governanceUserData?.stakedLQTY], ); const allowSubmit = useMemo(() => { if (!isAllocationChanged) return false; - const hasVotingPower = gt(stakedLQTY, 0); - const fullyAllocated = eq(remainingVotingPower, 0) && hasAnyAllocations; - const nothingAllocated = eq(remainingVotingPower, 1); + const hasVotingPower = dn.gt(stakedLQTY, 0); + const fullyAllocated = dn.eq(remainingVotingPower, 0) && hasAnyAllocations; + const nothingAllocated = dn.eq(remainingVotingPower, 1); return ( - isAllocationChanged && - hasVotingPower && - (fullyAllocated || nothingAllocated) + isAllocationChanged + && hasVotingPower + && (fullyAllocated || nothingAllocated) ); }, [ stakedLQTY, @@ -63,7 +63,7 @@ export const CastVotes: FC = () => { } } - if (allowSubmit && eq(remainingVotingPower, 1)) { + if (allowSubmit && dn.eq(remainingVotingPower, 1)) { return "Your votes will be reset to 0% for all initiatives."; } @@ -72,7 +72,7 @@ export const CastVotes: FC = () => { return ( 0} footnote={footnote} label="Cast votes" request={{ diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/CutoffWarning/CutoffWarning.tsx b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/CutoffWarning/CutoffWarning.tsx index 3595e1a6..d2f33e17 100644 --- a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/CutoffWarning/CutoffWarning.tsx +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/CutoffWarning/CutoffWarning.tsx @@ -1,12 +1,12 @@ -import type { FC } from "react"; +import { useIsPeriodCutoff } from "@/src/screens/StakeScreen/components/PanelVoting/hooks"; import { css } from "@/styled-system/css"; -import { useIsPeriodCutoff } from '@/src/screens/StakeScreen/components/PanelVoting/hooks'; +import type { FC } from "react"; export const CutoffWarning: FC = () => { const isPeriodCutoff = useIsPeriodCutoff(); - if(!isPeriodCutoff) { - return null + if (!isPeriodCutoff) { + return null; } return ( @@ -36,7 +36,7 @@ export const CutoffWarning: FC = () => { /> -
Only downvotes are accepted today.
+
Only downvotes and decreases in upvotes are permitted today.
); diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/InitiativeRow.tsx b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/InitiativeRow.tsx index d8afabb2..202840ba 100644 --- a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/InitiativeRow.tsx +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/InitiativeRow.tsx @@ -1,13 +1,13 @@ -import { css } from "@/styled-system/css"; import { LinkTextButton } from "@/src/comps/LinkTextButton/LinkTextButton"; -import { isInitiativeStatusActive } from "@/src/screens/StakeScreen/utils"; import { CHAIN_BLOCK_EXPLORER } from "@/src/env"; import { useVotingStateContext } from "@/src/screens/StakeScreen/components/PanelVoting/providers/PanelVotingProvider/hooks"; -import { InitiativeStatusTag } from "./components/InitiativeStatusTag"; -import { shortenAddress, IconExternal } from "@liquity2/uikit"; +import { isInitiativeStatusActive } from "@/src/screens/StakeScreen/utils"; +import { css } from "@/styled-system/css"; +import { IconExternal, shortenAddress } from "@liquity2/uikit"; +import { AmountPresentation } from "./components/AmountPresentation"; import { BribeInfo } from "./components/BribeInfo"; +import { InitiativeStatusTag } from "./components/InitiativeStatusTag"; import { Voting } from "./components/Voting"; -import { AmountPresentation } from "./components/AmountPresentation"; import type { Initiative } from "@/src/types"; import type { FC } from "react"; @@ -22,7 +22,6 @@ export const InitiativeRow: FC = ({ initiative }) => { const isStatusActive = isInitiativeStatusActive( initiativesStatus ?? "nonexistent", ); - const disabled = !isStatusActive; return ( @@ -50,20 +49,22 @@ export const InitiativeRow: FC = ({ initiative }) => { whiteSpace: "nowrap", })} > - {initiative.url ? ( - - {initiative.name ?? "Initiative"} - - - } - /> - ) : ( - (initiative.name ?? "Initiative") - )} + {initiative.url + ? ( + + {initiative.name ?? "Initiative"} + + + } + /> + ) + : ( + initiative.name ?? "Initiative" + )} = ({ initiative }) => { external href={`${CHAIN_BLOCK_EXPLORER?.url}address/${initiative.address}`} title={initiative.address} - label={ - initiative.protocol ?? shortenAddress(initiative.address, 4) - } + label={initiative.group ?? shortenAddress(initiative.address, 4)} className={css({ fontSize: 12, color: "contentAlt!", @@ -91,7 +90,7 @@ export const InitiativeRow: FC = ({ initiative }) => { - + ); diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/BribeInfo/BribeInfo.tsx b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/BribeInfo/BribeInfo.tsx index 73644d3f..1d4c9d1a 100644 --- a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/BribeInfo/BribeInfo.tsx +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/BribeInfo/BribeInfo.tsx @@ -1,17 +1,16 @@ -import { css } from "@/styled-system/css"; -import { gt, mul } from "dnum"; -import { fmtnum } from "@/src/formatting"; +import { type Address, TokenIcon } from "@liquity2/uikit"; +import * as dn from "dnum"; +import type { FC } from "react"; + import { Amount } from "@/src/comps/Amount/Amount"; -import { tokenIconUrl } from "@/src/utils"; import { CHAIN_ID } from "@/src/env"; -import { usePrice } from "@/src/services/Prices"; +import { fmtnum } from "@/src/formatting"; import { useVotingStateContext } from "@/src/screens/StakeScreen/components/PanelVoting/providers/PanelVotingProvider/hooks"; -import { TokenIcon } from "@liquity2/uikit"; +import { usePrice } from "@/src/services/Prices"; +import { tokenIconUrl } from "@/src/utils"; +import { css } from "@/styled-system/css"; import { WHITE_LABEL_CONFIG } from "@/src/white-label.config"; -import type { FC } from "react"; -import type { Address } from "@liquity2/uikit"; - interface BribeInfoProps { initiativeAddress: Address; } @@ -22,12 +21,10 @@ export const BribeInfo: FC = ({ initiativeAddress }) => { const boldPrice = usePrice(bribe ? WHITE_LABEL_CONFIG.tokens.mainToken.symbol : null); const bribeTokenPrice = usePrice(bribe ? bribe.tokenSymbol : null); - if (!bribe || (gt(bribe.boldAmount, 0) && gt(bribe.tokenAmount, 0))) { + if (!bribe || (dn.eq(bribe.boldAmount, 0) && dn.eq(bribe.tokenAmount, 0))) { return null; } - const bribeTokenAmount = gt(bribe.tokenAmount, 0); - return (
= ({ initiativeAddress }) => { flexWrap: "wrap", })} > - {gt(bribe.boldAmount, 0) && ( + {dn.gt(bribe.boldAmount, 0) && (
= ({ initiativeAddress }) => { format="compact" title={null} prefix="($" - value={mul(bribe.boldAmount, boldPrice.data)} + value={dn.mul(bribe.boldAmount, boldPrice.data)} suffix=")" /> )}
)} - {bribeTokenAmount && ( + {dn.gt(bribe.tokenAmount, 0) && (
= ({ initiativeAddress }) => { format="compact" title={null} prefix="($" - value={mul(bribe.tokenAmount, bribeTokenPrice.data)} + value={dn.mul(bribe.tokenAmount, bribeTokenPrice.data)} suffix=")" /> )} diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/Voting.tsx b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/Voting.tsx index 1f322414..b69d3720 100644 --- a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/Voting.tsx +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/Voting.tsx @@ -1,30 +1,34 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo, useState } from "react"; import { css } from "@/styled-system/css"; -import { VoteInput } from "@/src/comps/VoteInput/VoteInput"; +import { VoteAction } from "./components/VoteAction/"; import { Vote } from "./components/Vote"; import { useVotingStateContext } from "@/src/screens/StakeScreen/components/PanelVoting/providers/PanelVotingProvider/hooks"; -import { useIsPeriodCutoff } from "@/src/screens/StakeScreen/components/PanelVoting/hooks"; +import { useValidateVoteInput } from "@/src/screens/StakeScreen/components/PanelVoting/hooks"; import { div, from } from "dnum"; +import { + useGetAllocationsByAddress +} from './hooks'; -import type { Address, Dnum, Vote as VoteType } from "@/src/types"; +import type { Dnum, Vote as VoteType } from "@/src/types"; import type { FC } from "react"; +import type { + VotingProps +} from './types'; -interface VotingProps { - initiativeAddress: Address; - disabled: boolean; -} - -export const Voting: FC = ({ initiativeAddress, disabled }) => { - const { voteAllocations, inputVoteAllocations, setInputVoteAllocations } = - useVotingStateContext(); - const isPeriodCutoff = useIsPeriodCutoff(); - - const inputRef = useRef(null); +export const Voting: FC = ({ + initiativeAddress, + activeVoting, +}) => { const [editIntent, setEditIntent] = useState(false); - const currentInputVoteAllocation = inputVoteAllocations[initiativeAddress]; - const currentVoteAllocation = voteAllocations[initiativeAddress]; - const editMode = (editIntent || !currentVoteAllocation?.vote) && !disabled; + const { + setInputVoteAllocations, + } = useVotingStateContext(); + const { currentVoteAllocation } = + useGetAllocationsByAddress(initiativeAddress); + useValidateVoteInput(initiativeAddress); + + const editMode = (editIntent || !currentVoteAllocation?.vote) && activeVoting; const onVoteInputChange = useCallback( (value: Dnum) => { @@ -56,25 +60,14 @@ export const Voting: FC = ({ initiativeAddress, disabled }) => { setEditIntent(true); }, []); - useEffect(() => { - const isElementActive = inputRef.current && document.activeElement === inputRef.current; - - if(editIntent && !isElementActive) { - inputRef.current?.focus(); - } - }, [editIntent]); - const renderVoteSection = useMemo(() => { if (editMode) { return ( - ); } @@ -83,7 +76,7 @@ export const Voting: FC = ({ initiativeAddress, disabled }) => { return ( @@ -91,25 +84,20 @@ export const Voting: FC = ({ initiativeAddress, disabled }) => { } return ( - {}} onVote={() => {}} - value={null} - vote={null} /> ); }, [ + onVote, onEdit, editMode, - isPeriodCutoff, - disabled, + activeVoting, onVoteInputChange, - onVote, - currentInputVoteAllocation?.value, - currentInputVoteAllocation?.vote, + initiativeAddress, currentVoteAllocation?.vote, currentVoteAllocation?.value, ]); diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/VoteAction.tsx b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/VoteAction.tsx new file mode 100644 index 00000000..6141a315 --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/VoteAction.tsx @@ -0,0 +1,113 @@ +import { dnumMax, dnumMin } from "@/src/dnum-utils"; +import { parseInputFloat } from "@/src/form-utils"; +import { css } from "@/styled-system/css"; +import { from, mul, toString } from "dnum"; +import { useCallback, useState } from "react"; +import { VoteButton } from "./components/VoteButton"; +import { VoteDisplay } from "./components/VoteDisplay"; +import { VoteInput } from "./components/VoteInput"; +import { useGetOutlineStyles, useHasInputError, useVoteController } from "./hooks"; + +import type { VotingProps } from "@/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/types"; +import type { Dnum, Vote } from "@/src/types"; +import type { ChangeEvent } from "react"; + +interface VoteActionProps extends VotingProps { + onChange: (value: Dnum) => void; + onVote: (vote: Vote) => void; +} + +export function VoteAction({ + initiativeAddress, + activeVoting, + onChange: handleOnChange, + onVote, +}: VoteActionProps) { + const [isFocused, setIsFocused] = useState(false); + const { + isSelectedUpVoting, + isSelectedDownVoting, + upVoteButtonDisabled, + downVoteButtonDisabled, + vote, + currentValue, + } = useVoteController({ initiativeAddress, activeVoting }); + const hasError = useHasInputError(initiativeAddress); + const outlineStyles = useGetOutlineStyles({ + isFocused, + hasError, + vote, + }); + + const value = toString(mul(currentValue, 100)); + + const onUpVoteSelect = useCallback(() => { + onVote("for"); + }, [onVote]); + + const onDownVoteSelect = useCallback(() => { + onVote("against"); + }, [onVote]); + + const onChange = useCallback( + (e: ChangeEvent) => { + const parsed = parseInputFloat(e.target.value); + + if (!parsed) return; + + const val = dnumMax(dnumMin(from(100, 18), parsed), from(0, 18)); + + handleOnChange(val); + }, + [handleOnChange], + ); + + return ( +
{ + setIsFocused(true); + }} + onBlur={() => { + setIsFocused(false); + }} + className={css({ + display: "flex", + alignItems: "center", + width: "fit-content", + height: 34, + padding: "0 0 0 8px", + background: "fieldSurface", + border: "1px solid token(colors.fieldBorder)", + borderRadius: 8, + outline: "2px solid transparent", + outlineOffset: -1, + "--outline-focused": "token(colors.fieldBorderFocused)", + "--outline-error": "token(colors.red:500)", + })} + style={outlineStyles} + > + + + {vote + ? ( + + ) + : } +
+ ); +} diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/components/VoteButton/VoteButton.tsx b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/components/VoteButton/VoteButton.tsx new file mode 100644 index 00000000..93c74290 --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/components/VoteButton/VoteButton.tsx @@ -0,0 +1,86 @@ +import { css } from "@/styled-system/css"; +import { a, useTransition } from "@react-spring/web"; +import { IconDownvote, IconUpvote } from "@liquity2/uikit"; + +import type { FC } from "react"; +import type { Vote } from "@/src/types"; + +interface VoteButtonProps { + onSelect: () => void; + selected: boolean; + vote: Vote; + disabled?: boolean; +} + +export const VoteButton: FC = ({ + disabled, + selected, + vote, + onSelect, +}) => { + const selectTransition = useTransition(selected, { + from: { transform: "scale(1.5)" }, + enter: { transform: "scale(1)" }, + leave: { opacity: 0, immediate: true }, + config: { + mass: 1, + tension: 1800, + friction: 120, + }, + }); + + return ( + + ); +}; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/components/VoteButton/index.ts b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/components/VoteButton/index.ts new file mode 100644 index 00000000..f79ad4b4 --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/components/VoteButton/index.ts @@ -0,0 +1 @@ +export { VoteButton } from './VoteButton'; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/components/VoteDisplay/VoteDisplay.tsx b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/components/VoteDisplay/VoteDisplay.tsx new file mode 100644 index 00000000..81931341 --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/components/VoteDisplay/VoteDisplay.tsx @@ -0,0 +1,18 @@ +import { css } from "@/styled-system/css"; + +import type { FC } from 'react'; + +interface DisplayValueProps { + value: string; +} + +export const VoteDisplay: FC = ({value}) => { + return {value}% +} diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/components/VoteDisplay/index.ts b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/components/VoteDisplay/index.ts new file mode 100644 index 00000000..e3508adf --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/components/VoteDisplay/index.ts @@ -0,0 +1 @@ +export { VoteDisplay } from './VoteDisplay'; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/components/VoteInput/VoteInput.tsx b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/components/VoteInput/VoteInput.tsx new file mode 100644 index 00000000..424c1b70 --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/components/VoteInput/VoteInput.tsx @@ -0,0 +1,57 @@ +import { ChangeEvent, useCallback, useState } from "react"; +import { css } from "@/styled-system/css"; +import { useSetInputFocus } from "./hooks"; + +import type { FC } from "react"; +import type { Vote } from "@/src/types"; + +interface VoteInputProps { + value?: string; + placeholder?: string; + disabled?: boolean; + onChange: (e: ChangeEvent) => void; + vote: Vote; +} + +export const VoteInput: FC = ({ + value, + onChange: handleOnChange, + placeholder, + disabled, + vote, +}) => { + const [inputValue, setInputValue] = useState(!!Number(value) ? value : ""); + const { inputRef } = useSetInputFocus({ vote, setInputValue }); + + const onChange = useCallback( + (e: ChangeEvent) => { + handleOnChange(e); + setInputValue(e.target.value.trim()); + }, + [handleOnChange], + ); + + return ( + + ); +}; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/components/VoteInput/hooks/index.ts b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/components/VoteInput/hooks/index.ts new file mode 100644 index 00000000..dfce208c --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/components/VoteInput/hooks/index.ts @@ -0,0 +1 @@ +export { useSetInputFocus } from './useSetInputFocus'; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/components/VoteInput/hooks/useSetInputFocus/index.ts b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/components/VoteInput/hooks/useSetInputFocus/index.ts new file mode 100644 index 00000000..dfce208c --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/components/VoteInput/hooks/useSetInputFocus/index.ts @@ -0,0 +1 @@ +export { useSetInputFocus } from './useSetInputFocus'; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/components/VoteInput/hooks/useSetInputFocus/useSetInputFocus.tsx b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/components/VoteInput/hooks/useSetInputFocus/useSetInputFocus.tsx new file mode 100644 index 00000000..046bca37 --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/components/VoteInput/hooks/useSetInputFocus/useSetInputFocus.tsx @@ -0,0 +1,32 @@ +import { useEffect, useRef } from "react"; + +import type { Vote } from "@/src/types"; +import type { Dispatch, SetStateAction } from "react"; + +interface UseSetInputFocusArgs { + setInputValue: Dispatch>; + vote: Vote | null; +} + +export const useSetInputFocus = ({ + vote, + setInputValue, +}: UseSetInputFocusArgs) => { + const inputRef = useRef(null); + const prevVote = useRef(vote); + + useEffect(() => { + const becameSelected = vote && vote !== prevVote.current; + + if (becameSelected) { + inputRef.current?.focus(); + setInputValue(""); + } + + prevVote.current = vote; + }, [vote, setInputValue]); + + return { + inputRef, + }; +}; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/components/VoteInput/index.ts b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/components/VoteInput/index.ts new file mode 100644 index 00000000..cf445609 --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/components/VoteInput/index.ts @@ -0,0 +1 @@ +export { VoteInput } from './VoteInput'; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/index.ts b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/index.ts new file mode 100644 index 00000000..ed90050f --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/index.ts @@ -0,0 +1,3 @@ +export { useGetOutlineStyles } from "./useGetOutlineStyles"; +export { useHasInputError } from "./useHasInputError"; +export { useVoteController } from "./useVoteController"; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/useGetOutlineStyles/index.ts b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/useGetOutlineStyles/index.ts new file mode 100644 index 00000000..6fe80d3e --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/useGetOutlineStyles/index.ts @@ -0,0 +1 @@ +export { useGetOutlineStyles } from './useGetOutlineStyles'; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/useGetOutlineStyles/useGetOutlineStyles.tsx b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/useGetOutlineStyles/useGetOutlineStyles.tsx new file mode 100644 index 00000000..f7ce4279 --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/useGetOutlineStyles/useGetOutlineStyles.tsx @@ -0,0 +1,28 @@ +import type { Vote } from "@/src/types"; + +interface UseGetOutlineStylesProps { + hasError: boolean; + isFocused: boolean; + vote: Vote | null; +} +export const useGetOutlineStyles = ({ + hasError, + isFocused, + vote, +}: UseGetOutlineStylesProps) => { + if (hasError) { + return { + outlineColor: "var(--outline-error)", + }; + } + + if (isFocused && vote) { + return { + outlineColor: "var(--outline-focused)", + }; + } + + return { + outlineColor: "transparent", + }; +}; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/useHasInputError/index.ts b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/useHasInputError/index.ts new file mode 100644 index 00000000..020a74d7 --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/useHasInputError/index.ts @@ -0,0 +1 @@ +export { useHasInputError } from "./useHasInputError"; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/useHasInputError/useHasInputError.tsx b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/useHasInputError/useHasInputError.tsx new file mode 100644 index 00000000..c301275b --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/useHasInputError/useHasInputError.tsx @@ -0,0 +1,11 @@ +import { + useVotingStateContext, +} from "@/src/screens/StakeScreen/components/PanelVoting/providers/PanelVotingProvider/hooks"; + +import type { Address } from "@/src/types"; + +export const useHasInputError = (initiativeAddress: Address) => { + const { votingInputError } = useVotingStateContext(); + + return votingInputError.has(initiativeAddress); +}; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/useVoteController/hooks/index.ts b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/useVoteController/hooks/index.ts new file mode 100644 index 00000000..50568a12 --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/useVoteController/hooks/index.ts @@ -0,0 +1 @@ +export { useGetVoteButtonStatus } from './useGetVoteButtonStatus'; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/useVoteController/hooks/useGetVoteButtonStatus/index.ts b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/useVoteController/hooks/useGetVoteButtonStatus/index.ts new file mode 100644 index 00000000..50568a12 --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/useVoteController/hooks/useGetVoteButtonStatus/index.ts @@ -0,0 +1 @@ +export { useGetVoteButtonStatus } from './useGetVoteButtonStatus'; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/useVoteController/hooks/useGetVoteButtonStatus/useGetVoteButtonStatus.tsx b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/useVoteController/hooks/useGetVoteButtonStatus/useGetVoteButtonStatus.tsx new file mode 100644 index 00000000..81a2b85c --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/useVoteController/hooks/useGetVoteButtonStatus/useGetVoteButtonStatus.tsx @@ -0,0 +1,35 @@ +import { useIsPeriodCutoff } from "@/src/screens/StakeScreen/components/PanelVoting/hooks"; +import { useGetAllocationsByAddress } from "@/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/hooks"; + +import type { VotingProps as UseGetVoteButtonStatusArgs } from "@/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/types"; + +export const useGetVoteButtonStatus = ({ + initiativeAddress, + activeVoting, +}: UseGetVoteButtonStatusArgs) => { + const isPeriodCutoff = useIsPeriodCutoff(); + const { currentVoteAllocation, currentInputVoteAllocation } = + useGetAllocationsByAddress(initiativeAddress); + + const voteButtonState = { + isSelectedUpVoting: currentInputVoteAllocation?.vote === "for", + isSelectedDownVoting: currentInputVoteAllocation?.vote === "against", + upVoteButtonDisabled: !activeVoting, + downVoteButtonDisabled: !activeVoting, + }; + const isCurrentDownVoteSelected = currentVoteAllocation?.vote === "against"; + const hasVote = !!currentVoteAllocation?.vote; + + if (!currentVoteAllocation && !currentInputVoteAllocation) { + return { + ...voteButtonState, + upVoteButtonDisabled: isPeriodCutoff, + }; + } + + if (isPeriodCutoff && (isCurrentDownVoteSelected || !hasVote)) { + voteButtonState.upVoteButtonDisabled = true; + } + + return voteButtonState; +}; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/useVoteController/index.ts b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/useVoteController/index.ts new file mode 100644 index 00000000..50bc3013 --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/useVoteController/index.ts @@ -0,0 +1 @@ +export { useVoteController } from './useVoteController'; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/useVoteController/useVoteController.tsx b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/useVoteController/useVoteController.tsx new file mode 100644 index 00000000..2c5da714 --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/hooks/useVoteController/useVoteController.tsx @@ -0,0 +1,37 @@ +import { useGetAllocationsByAddress } from "@/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/hooks"; +import { useMemo } from "react"; +import { from } from "dnum"; +import { useGetVoteButtonStatus } from "./hooks"; + +import type { VotingProps as UseVoteControllerArgs } from "@/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/types"; + +export const useVoteController = ({ + initiativeAddress, + activeVoting, +}: UseVoteControllerArgs) => { + const { currentVoteAllocation, currentInputVoteAllocation } = + useGetAllocationsByAddress(initiativeAddress); + const voteButtonsState = useGetVoteButtonStatus({ + activeVoting, + initiativeAddress + }); + const vote = currentInputVoteAllocation?.vote ?? null; + + const currentValue = useMemo(() => { + if (currentInputVoteAllocation?.vote !== currentVoteAllocation?.vote) { + return from(0); + } + + return currentInputVoteAllocation?.value ?? from(0); + }, [ + currentInputVoteAllocation?.value, + currentInputVoteAllocation?.vote, + currentVoteAllocation?.vote, + ]); + + return { + ...voteButtonsState, + vote, + currentValue, + }; +}; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/index.ts b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/index.ts new file mode 100644 index 00000000..d40ec840 --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/components/VoteAction/index.ts @@ -0,0 +1 @@ +export { VoteAction } from './VoteAction' diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/hooks/index.ts b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/hooks/index.ts new file mode 100644 index 00000000..88c9085e --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/hooks/index.ts @@ -0,0 +1 @@ +export { useGetAllocationsByAddress } from './useGetAllocationsByAddress'; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/hooks/useGetAllocationsByAddress/index.ts b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/hooks/useGetAllocationsByAddress/index.ts new file mode 100644 index 00000000..88c9085e --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/hooks/useGetAllocationsByAddress/index.ts @@ -0,0 +1 @@ +export { useGetAllocationsByAddress } from './useGetAllocationsByAddress'; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/hooks/useGetAllocationsByAddress/useGetAllocationsByAddress.tsx b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/hooks/useGetAllocationsByAddress/useGetAllocationsByAddress.tsx new file mode 100644 index 00000000..edf8d266 --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/hooks/useGetAllocationsByAddress/useGetAllocationsByAddress.tsx @@ -0,0 +1,16 @@ +import { useVotingStateContext } from "@/src/screens/StakeScreen/components/PanelVoting/providers/PanelVotingProvider/hooks"; +import { useMemo } from "react"; + +import type { Address } from "@/src/types"; + +export const useGetAllocationsByAddress = (initiativeAddress: Address) => { + const { voteAllocations, inputVoteAllocations } = useVotingStateContext(); + + return useMemo( + () => ({ + currentInputVoteAllocation: inputVoteAllocations[initiativeAddress], + currentVoteAllocation: voteAllocations[initiativeAddress], + }), + [initiativeAddress, inputVoteAllocations, voteAllocations], + ); +}; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/types.ts b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/types.ts new file mode 100644 index 00000000..9a50de72 --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/EpochInitiativesTable/components/TableBody/components/InitiativeRow/components/Voting/types.ts @@ -0,0 +1,6 @@ +import type { Address } from '@/src/types'; + +export interface VotingProps { + initiativeAddress: Address; + activeVoting: boolean; +} diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/Header/Header.tsx b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/Header/Header.tsx index 7319354d..78668038 100644 --- a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/Header/Header.tsx +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/Header/Header.tsx @@ -1,38 +1,56 @@ -import type { FC } from 'react'; -import { css } from '@/styled-system/css'; -import content from '@/src/content.tsx'; +import content from "@/src/content.tsx"; +import { css } from "@/styled-system/css"; +import type { FC } from "react"; +import { VotingResourcesToggle } from "../VotingResources"; export const Header: FC = () => { - return
-

- {content.stakeScreen.votingPanel.title} -

-
+ {content.stakeScreen.votingPanel.title} + +
- {content.stakeScreen.votingPanel.intro} -
-
-} + })} + > + {content.stakeScreen.votingPanel.intro} +
+
+ +
+ + ); +}; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/VotingResources/VotingResources.tsx b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/VotingResources/VotingResources.tsx new file mode 100644 index 00000000..8283f2d3 --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/VotingResources/VotingResources.tsx @@ -0,0 +1,65 @@ +import { LinkTextButton } from "@/src/comps/LinkTextButton/LinkTextButton"; +import content from "@/src/content"; +import { css } from "@/styled-system/css"; +import { IconExternal } from "@liquity2/uikit"; + +import type { FC } from "react"; + +const ResourceSection: FC<{ + linkUrl: string; + linkText: string; + description: string; + showTopBorder?: boolean; +}> = ({ linkUrl, linkText, description, showTopBorder = false }) => ( +
+ + {linkText} + + + } + /> +

+ {description} +

+
+); + +export const VotingResources: FC = () => { + const { resources } = content.stakeScreen.votingPanel; + + return ( +
+ + + + +
+ ); +}; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/VotingResources/VotingResourcesToggle.tsx b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/VotingResources/VotingResourcesToggle.tsx new file mode 100644 index 00000000..a93c16f0 --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/VotingResources/VotingResourcesToggle.tsx @@ -0,0 +1,65 @@ +import { css } from "@/styled-system/css"; +import { IconChevronSmallUp } from "@liquity2/uikit"; +import { useState } from "react"; +import type { FC } from "react"; +import { VotingResources } from "./VotingResources"; + +export const VotingResourcesToggle: FC = () => { + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+ + + {isExpanded && } +
+ ); +}; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/VotingResources/index.ts b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/VotingResources/index.ts new file mode 100644 index 00000000..6ecb4e99 --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/VotingResources/index.ts @@ -0,0 +1,2 @@ +export { VotingResources } from './VotingResources'; +export { VotingResourcesToggle } from './VotingResourcesToggle'; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/VotingRoundTimer/VotingRoundTimer.tsx b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/VotingRoundTimer/VotingRoundTimer.tsx index 9034b296..d903095d 100644 --- a/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/VotingRoundTimer/VotingRoundTimer.tsx +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/components/VotingRoundTimer/VotingRoundTimer.tsx @@ -1,14 +1,10 @@ -import { css } from "@/styled-system/css"; import { Tag } from "@/src/comps/Tag/Tag.tsx"; import { formatDate } from "@/src/formatting.ts"; -import { LinkTextButton } from "@/src/comps/LinkTextButton/LinkTextButton.tsx"; import { useVotingStateContext } from "@/src/screens/StakeScreen/components/PanelVoting/providers/PanelVotingProvider/hooks"; -import { IconExternal } from '@liquity2/uikit'; +import { css } from "@/styled-system/css"; import type { FC } from "react"; -const url = 'https://voting.liquity.org/'; - export const VotingRoundTimer: FC = () => { const { governanceStateData } = useVotingStateContext(); @@ -58,25 +54,6 @@ export const VotingRoundTimer: FC = () => {
)} - -
- - Discuss - - - } - href={url} - external - /> -
); }; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/hooks/index.ts b/frontend/app/src/screens/StakeScreen/components/PanelVoting/hooks/index.ts index 14c064f4..13e6a693 100644 --- a/frontend/app/src/screens/StakeScreen/components/PanelVoting/hooks/index.ts +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/hooks/index.ts @@ -2,3 +2,4 @@ export { useIsPeriodCutoff } from "./useIsPeriodCutoff"; export { useHasAllocations } from "./useHasAllocations"; export { useIsAllocationChanged } from "./useIsAllocationChanged"; export { useRemainingVotingPower } from "./useRemainingVotingPower"; +export { useValidateVoteInput } from "./useValidateVoteInput"; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/hooks/useRemainingVotingPower/useRemainingVotingPower.tsx b/frontend/app/src/screens/StakeScreen/components/PanelVoting/hooks/useRemainingVotingPower/useRemainingVotingPower.tsx index 68bbde9d..4d5c1bef 100644 --- a/frontend/app/src/screens/StakeScreen/components/PanelVoting/hooks/useRemainingVotingPower/useRemainingVotingPower.tsx +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/hooks/useRemainingVotingPower/useRemainingVotingPower.tsx @@ -1,56 +1,20 @@ -import { useMemo } from 'react'; -import { div, from, sub } from 'dnum'; -import type { Address, Dnum, Entries, VoteAllocations } from '@/src/types'; -import { - useVotingStateContext -} from '@/src/screens/StakeScreen/components/PanelVoting/providers/PanelVotingProvider/hooks'; -import { isInitiativeStatusActive } from '@/src/screens/StakeScreen/utils'; +import * as dn from "dnum"; +import { useMemo } from "react"; -type Allocations = Record; +import { DNUM_1 } from "@/src/dnum-utils"; +import { useVotingStateContext } from "@/src/screens/StakeScreen/components/PanelVoting/providers/PanelVotingProvider/hooks"; +import { filterVoteAllocationsForSubmission } from "@/src/screens/StakeScreen/components/PanelVoting/utils"; -// TODO: check functional -export const useRemainingVotingPower= () => { - const { governanceUserData, inputVoteAllocations, initiativesStatesData } = useVotingStateContext(); +export const useRemainingVotingPower = () => { + const { inputVoteAllocations, initiativesStatesData } = useVotingStateContext(); return useMemo(() => { - let remaining = from(1, 18); - - if(!governanceUserData) return remaining; - - const { stakedLQTY, allocations } = governanceUserData - - - // current allocation from user data - const baseAllocations = allocations.reduce((acc, { initiative, voteLQTY, vetoLQTY }) => { - const currentVoteAmount = voteLQTY > 0n ? voteLQTY : vetoLQTY; - if (currentVoteAmount === 0n) return acc; - - acc[initiative] = div([currentVoteAmount, 18], [stakedLQTY, 18]); - return acc; - }, {}); - - // input allocations (takes precedence) - const inputOverrides = (Object.entries(inputVoteAllocations) as Entries) - .filter(([, vote]) => vote.vote !== null) - .reduce((acc, [initiativeAddress, vote]) => { - acc[initiativeAddress] = vote.value; - return acc; - }, {}); - - const combinedAllocations: Allocations = { - ...baseAllocations, - ...inputOverrides, - }; - - // check if the initiative is still active - for (const [address, value] of Object.entries(combinedAllocations) as Entries>) { - const status = initiativesStatesData?.[address as Address]?.status ?? "nonexistent"; - - if (!isInitiativeStatusActive(status)) continue; - - remaining = sub(remaining, value); - } - - return remaining; - }, [governanceUserData, inputVoteAllocations, initiativesStatesData]); + const filteredVoteAllocations = initiativesStatesData + ? filterVoteAllocationsForSubmission(inputVoteAllocations, initiativesStatesData) + : {}; + + return Object.values(filteredVoteAllocations) + .map(({ value }) => value) + .reduce((a, b) => dn.sub(a, b), DNUM_1); + }, [inputVoteAllocations, initiativesStatesData]); }; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/hooks/useValidateVoteInput/index.ts b/frontend/app/src/screens/StakeScreen/components/PanelVoting/hooks/useValidateVoteInput/index.ts new file mode 100644 index 00000000..d876d5bd --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/hooks/useValidateVoteInput/index.ts @@ -0,0 +1 @@ +export { useValidateVoteInput } from './useValidateVoteInput'; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/hooks/useValidateVoteInput/useValidateVoteInput.tsx b/frontend/app/src/screens/StakeScreen/components/PanelVoting/hooks/useValidateVoteInput/useValidateVoteInput.tsx new file mode 100644 index 00000000..624e81e4 --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/hooks/useValidateVoteInput/useValidateVoteInput.tsx @@ -0,0 +1,44 @@ +import type { Address } from "@liquity2/uikit"; +import * as dn from "dnum"; +import { useEffect } from "react"; + +import { useVotingStateContext } from "@/src/screens/StakeScreen/components/PanelVoting/providers/PanelVotingProvider/hooks"; +import { useIsPeriodCutoff } from "../useIsPeriodCutoff"; + +export const useValidateVoteInput = (initiativeAddress: Address) => { + const isPeriodCutoff = useIsPeriodCutoff(); + const { voteAllocations, inputVoteAllocations, setVotingInputError } = useVotingStateContext(); + const input = inputVoteAllocations[initiativeAddress]; + const current = voteAllocations[initiativeAddress]; + + useEffect(() => { + const clearError = () => + setVotingInputError((prev) => { + if (!prev.has(initiativeAddress)) return prev; + + const next = new Set(prev); + next.delete(initiativeAddress); + return next; + }); + + const setError = () => + setVotingInputError((prev) => { + if (prev.has(initiativeAddress)) return prev; + + const next = new Set(prev); + next.add(initiativeAddress); + return next; + }); + + if ( + isPeriodCutoff + && current?.vote === "for" + && input?.vote === "for" + && dn.gt(input.value, current.value) + ) { + setError(); + } else { + clearError(); + } + }, [initiativeAddress, input, current, setVotingInputError]); +}; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/providers/PanelVotingProvider/context.ts b/frontend/app/src/screens/StakeScreen/components/PanelVoting/providers/PanelVotingProvider/context.ts index e9e13058..a76ea13a 100644 --- a/frontend/app/src/screens/StakeScreen/components/PanelVoting/providers/PanelVotingProvider/context.ts +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/providers/PanelVotingProvider/context.ts @@ -1,12 +1,12 @@ -import { - useVotingState -} from './hooks'; -import { createContext } from 'react'; -import { noop } from '@/src/screens/StakeScreen/components/PanelVoting/utils'; +import { noop } from "@/src/utils"; +import { createContext } from "react"; +import { useVotingState } from "./hooks"; -export type Context = Omit, 'isLoading'>; +export type Context = Omit, "isLoading">; export const context = createContext({ + votingInputError: new Set(), + setVotingInputError: noop, voteAllocations: {}, inputVoteAllocations: {}, setInputVoteAllocations: noop, diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/providers/PanelVotingProvider/hooks/useVotingState/hooks/useGetInitiativesSummary/useGetInitiativesSummary.tsx b/frontend/app/src/screens/StakeScreen/components/PanelVoting/providers/PanelVotingProvider/hooks/useVotingState/hooks/useGetInitiativesSummary/useGetInitiativesSummary.tsx index 4f4521d8..381bf7f3 100644 --- a/frontend/app/src/screens/StakeScreen/components/PanelVoting/providers/PanelVotingProvider/hooks/useVotingState/hooks/useGetInitiativesSummary/useGetInitiativesSummary.tsx +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/providers/PanelVotingProvider/hooks/useVotingState/hooks/useGetInitiativesSummary/useGetInitiativesSummary.tsx @@ -1,14 +1,13 @@ -import { useMemo } from "react"; import { useCurrentEpochBribes, useInitiativesStates, useInitiativesVoteTotals, useNamedInitiatives, } from "@/src/liquity-governance.ts"; +import { useMemo } from "react"; export const useGetInitiativesSummary = () => { - const { data: initiativesData, isLoading: isLoadingInitiatives } = - useNamedInitiatives(); + const { data: initiativesData, isLoading: isLoadingInitiatives } = useNamedInitiatives(); const initiativesAddresses = useMemo(() => { if (isLoadingInitiatives || !initiativesData) { @@ -18,12 +17,11 @@ export const useGetInitiativesSummary = () => { return initiativesData.map((i) => i.address) ?? []; }, [initiativesData, isLoadingInitiatives]); - const { data: initiativesStatesData, isLoading: isLoadingInitiativesStates } = - useInitiativesStates(initiativesAddresses); - const { data: currentBribesData, isLoading: isLoadingCurrentBribes } = - useCurrentEpochBribes(initiativesAddresses); - const { data: voteTotalsData, isLoading: isLoadingVoteTotals } = - useInitiativesVoteTotals(initiativesAddresses); + const { data: initiativesStatesData, isLoading: isLoadingInitiativesStates } = useInitiativesStates( + initiativesAddresses, + ); + const { data: currentBribesData, isLoading: isLoadingCurrentBribes } = useCurrentEpochBribes(initiativesData ?? []); + const { data: voteTotalsData, isLoading: isLoadingVoteTotals } = useInitiativesVoteTotals(initiativesAddresses); return { initiativesAddresses, @@ -32,5 +30,5 @@ export const useGetInitiativesSummary = () => { currentBribesData, voteTotalsData, isLoading: isLoadingInitiativesStates || isLoadingCurrentBribes || isLoadingVoteTotals, - } + }; }; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/providers/PanelVotingProvider/hooks/useVotingState/useVotingState.tsx b/frontend/app/src/screens/StakeScreen/components/PanelVoting/providers/PanelVotingProvider/hooks/useVotingState/useVotingState.tsx index 2341f589..f65be21c 100644 --- a/frontend/app/src/screens/StakeScreen/components/PanelVoting/providers/PanelVotingProvider/hooks/useVotingState/useVotingState.tsx +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/providers/PanelVotingProvider/hooks/useVotingState/useVotingState.tsx @@ -1,15 +1,17 @@ -import { - useGetVotingState, - useGetInitiativesSummary, - useGetVoteAllocations, -} from "./hooks"; +import { useState } from "react"; +import { useGetInitiativesSummary, useGetVoteAllocations, useGetVotingState } from "./hooks"; + +import type { Address } from "@liquity2/uikit"; export const useVotingState = () => { + const [votingInputError, setVotingInputError] = useState(new Set
()); + const { governanceStateData, governanceUserData, isLoading: isLoadingVotingState, } = useGetVotingState(); + const { initiativesAddresses, initiativesStatesData, @@ -18,10 +20,14 @@ export const useVotingState = () => { currentBribesData, isLoading: isLoadingInitiativesSummary, } = useGetInitiativesSummary(); - const { voteAllocations, inputVoteAllocations, setInputVoteAllocations } = - useGetVoteAllocations({ governanceStateData, governanceUserData }); + const { voteAllocations, inputVoteAllocations, setInputVoteAllocations } = useGetVoteAllocations({ + governanceStateData, + governanceUserData, + }); return { + votingInputError, + setVotingInputError, voteAllocations, inputVoteAllocations, setInputVoteAllocations, diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/utils/index.ts b/frontend/app/src/screens/StakeScreen/components/PanelVoting/utils/index.ts index 69809e5e..8714536a 100644 --- a/frontend/app/src/screens/StakeScreen/components/PanelVoting/utils/index.ts +++ b/frontend/app/src/screens/StakeScreen/components/PanelVoting/utils/index.ts @@ -1,2 +1 @@ -export { noop } from "./noop"; export { filterVoteAllocationsForSubmission } from "./filterVoteAllocationsForSubmission"; diff --git a/frontend/app/src/screens/StakeScreen/components/PanelVoting/utils/noop.ts b/frontend/app/src/screens/StakeScreen/components/PanelVoting/utils/noop.ts deleted file mode 100644 index 84357407..00000000 --- a/frontend/app/src/screens/StakeScreen/components/PanelVoting/utils/noop.ts +++ /dev/null @@ -1 +0,0 @@ -export const noop: (...args: any[]) => void = () => {}; diff --git a/frontend/app/src/screens/TransactionsScreen/AccountButton.tsx b/frontend/app/src/screens/TransactionsScreen/AccountButton.tsx index 1873bade..02cdcfae 100644 --- a/frontend/app/src/screens/TransactionsScreen/AccountButton.tsx +++ b/frontend/app/src/screens/TransactionsScreen/AccountButton.tsx @@ -9,9 +9,13 @@ import Image from "next/image"; export function AccountButton({ address, + displayName, }: { address: Address; + displayName?: string; }) { + const label = displayName || shortenAddress(address, 4).toLowerCase(); + return ( - {shortenAddress(address, 4).toLowerCase()} + {label} } href={`${CHAIN_BLOCK_EXPLORER?.url}address/${address}`} diff --git a/frontend/app/src/screens/TransactionsScreen/LoanCard/LoanCard.tsx b/frontend/app/src/screens/TransactionsScreen/LoanCard/LoanCard.tsx index fd718187..d6f744af 100644 --- a/frontend/app/src/screens/TransactionsScreen/LoanCard/LoanCard.tsx +++ b/frontend/app/src/screens/TransactionsScreen/LoanCard/LoanCard.tsx @@ -1,13 +1,14 @@ -import { useMemo } from "react"; import { getLoanDetails } from "@/src/liquity-math"; import { getCollToken } from "@/src/liquity-utils"; -import { usePrice } from "@/src/services/Prices"; import { ClosedLoan } from "@/src/screens/TransactionsScreen/LoanCard/components/ClosedLoan"; -import { OpenLoan } from "@/src/screens/TransactionsScreen/LoanCard/components/OpenLoan"; import { LoadingCard } from "@/src/screens/TransactionsScreen/LoanCard/components/LoadingCard"; +import { OpenLoan } from "@/src/screens/TransactionsScreen/LoanCard/components/OpenLoan"; +import { usePrice } from "@/src/services/Prices"; +import { useMemo } from "react"; import type { LoadingState } from "@/src/screens/TransactionsScreen/TransactionsScreen.tsx"; -import type { PositionLoan } from "@/src/types"; +import { useTransactionFlow } from "@/src/services/TransactionFlow"; +import type { PositionLoan, PositionLoanCommitted } from "@/src/types"; const LOAN_CARD_HEIGHT = 290; const LOAN_CARD_HEIGHT_REDUCED = 176; @@ -19,17 +20,28 @@ export function LoanCard({ onRetry, prevLoan, txPreviewMode = false, + displayAllDifferences = true, }: { leverageMode: boolean; loadingState: LoadingState; loan: PositionLoan | null; onRetry: () => void; - prevLoan?: PositionLoan | null; + prevLoan?: PositionLoanCommitted | null; txPreviewMode?: boolean; + displayAllDifferences?: boolean; }) { const branchId = loan?.branchId ?? prevLoan?.branchId ?? null; const collToken = getCollToken(branchId); + const { + currentStep: step, + currentStepIndex, + flow, + } = useTransactionFlow(); + + const isLastStep = flow?.steps && currentStepIndex === flow.steps.length - 1; + const isSuccess = isLastStep && step?.status === "confirmed"; + if (!collToken) { throw new Error(`Collateral token not found: ${branchId}`); } @@ -38,9 +50,8 @@ export function LoanCard({ const isLoanClosing = prevLoan && !loan; - const loanDetails = - loan && - getLoanDetails( + const loanDetails = loan + && getLoanDetails( loan.deposit, loan.borrowed, loan.interestRate, @@ -48,9 +59,8 @@ export function LoanCard({ collPriceUsd.data ?? null, ); - const prevLoanDetails = - prevLoan && - getLoanDetails( + const prevLoanDetails = prevLoan + && getLoanDetails( prevLoan.deposit, prevLoan.borrowed, prevLoan.interestRate, @@ -66,10 +76,10 @@ export function LoanCard({ } = loanDetails || {}; const loanDetailsFilled = Boolean( - typeof leverageFactor === "number" && - depositPreLeverage && - liquidationRisk && - liquidationPrice, + typeof leverageFactor === "number" + && depositPreLeverage + && liquidationRisk + && liquidationPrice, ); const loadingStatus = useMemo(() => { @@ -78,8 +88,8 @@ export function LoanCard({ } if ( - collPriceUsd.status === "pending" || - (!isLoanClosing && !loanDetailsFilled) + collPriceUsd.status === "pending" + || (!isLoanClosing && !loanDetailsFilled) ) { return "loading"; } @@ -101,6 +111,7 @@ export function LoanCard({ leverageMode={leverageMode} collToken={collToken} loanDetails={loanDetails} + isSuccess={!displayAllDifferences && Boolean(isSuccess)} /> ); } @@ -108,12 +119,15 @@ export function LoanCard({ return null; }, [ isLoanClosing, - prevLoan, - collToken, loan, loanDetails, loanDetailsFilled, + prevLoan, + collToken, + prevLoanDetails, leverageMode, + displayAllDifferences, + isSuccess, ]); return ( @@ -123,6 +137,7 @@ export function LoanCard({ loadingState={loadingStatus} onRetry={onRetry} txPreviewMode={txPreviewMode} + isSuccess={!displayAllDifferences && Boolean(isSuccess)} > {content} diff --git a/frontend/app/src/screens/TransactionsScreen/LoanCard/components/LoadingCard/LoadingCard.tsx b/frontend/app/src/screens/TransactionsScreen/LoanCard/components/LoadingCard/LoadingCard.tsx index 478ba8f0..4e3279d3 100644 --- a/frontend/app/src/screens/TransactionsScreen/LoanCard/components/LoadingCard/LoadingCard.tsx +++ b/frontend/app/src/screens/TransactionsScreen/LoanCard/components/LoadingCard/LoadingCard.tsx @@ -1,14 +1,15 @@ -import { a, useSpring } from "@react-spring/web"; -import { css } from "@/styled-system/css"; -import { TagPreview } from "@/src/comps/TagPreview/TagPreview.tsx"; -import { match, P } from "ts-pattern"; import { Spinner } from "@/src/comps/Spinner/Spinner.tsx"; +import { TagPreview } from "@/src/comps/TagPreview/TagPreview.tsx"; +import { css } from "@/styled-system/css"; import { token } from "@/styled-system/tokens"; -import { IconBorrow, IconLeverage, Button } from "@liquity2/uikit"; import { WHITE_LABEL_CONFIG } from "@/src/white-label.config"; +import { Button, IconBorrow, IconLeverage } from "@liquity2/uikit"; +import { a, useSpring } from "@react-spring/web"; +import { match, P } from "ts-pattern"; -import type { FC, PropsWithChildren } from "react"; +import { TagConfirmed } from "@/src/comps/TagConfirmed/TagConfirmed"; import type { LoadingState } from "@/src/screens/TransactionsScreen/TransactionsScreen.tsx"; +import type { FC, PropsWithChildren } from "react"; interface LoadingCardProps extends PropsWithChildren { height: number; @@ -16,6 +17,7 @@ interface LoadingCardProps extends PropsWithChildren { loadingState: LoadingState; onRetry: () => void; txPreviewMode?: boolean; + isSuccess?: boolean; } export const LoadingCard: FC = ({ @@ -25,6 +27,7 @@ export const LoadingCard: FC = ({ leverage, onRetry, children, + isSuccess, }) => { const title = leverage ? "Multiply" : `${WHITE_LABEL_CONFIG.tokens.mainToken.symbol} loan`; @@ -33,12 +36,11 @@ export const LoadingCard: FC = ({ .with(P.union("loading", "error", "not-found"), (s) => ({ cardtransform: "scale3d(0.95, 0.95, 1)", // bottom bar 2 - containerHeight: - window.innerHeight - - 120 - // top bar - 24 * 2 - // padding - 48 - // bottom bar 1 - 40, + containerHeight: window.innerHeight + - 120 // top bar + - 24 * 2 // padding + - 48 // bottom bar 1 + - 40, cardHeight: s === "error" || s === "not-found" ? 180 : 120, cardBackground: token("colors.blue:50"), cardColor: token("colors.blue:950"), @@ -85,7 +87,7 @@ export const LoadingCard: FC = ({ willChange: "transform", }} > - {txPreviewMode && loadingState === "success" && } + {txPreviewMode ? isSuccess ? : loadingState === "success" && : null}

= ({ /> )) - .otherwise(() => ( -
{children}
- ))} + .otherwise(() =>
{children}
)} ); diff --git a/frontend/app/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/OpenLoan.tsx b/frontend/app/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/OpenLoan.tsx index 90da5822..8cd09b83 100644 --- a/frontend/app/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/OpenLoan.tsx +++ b/frontend/app/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/OpenLoan.tsx @@ -1,21 +1,20 @@ -import { TotalDebt } from "@/src/screens/TransactionsScreen/LoanCard/components/components/TotalDebt"; -import { css } from "@/styled-system/css"; -import { GridItem } from "@/src/screens/TransactionsScreen/LoanCard/components/components/GridItem"; +import { CrossedText } from "@/src/comps/CrossedText"; import { Value } from "@/src/comps/Value/Value.tsx"; import { fmtnum, formatRisk } from "@/src/formatting.ts"; -import * as dn from "dnum"; -import { riskLevelToStatusMode } from "@/src/uikit-utils.tsx"; -import { LeveragedExposure } from "./components/LeveragedExposure"; import { getLoanDetails } from "@/src/liquity-math.ts"; -import { useRedemptionRisk } from "@/src/liquity-utils.ts"; +import { EMPTY_LOAN, useRedemptionRiskOfInterestRate, useRedemptionRiskOfLoan } from "@/src/liquity-utils.ts"; +import { GridItem } from "@/src/screens/TransactionsScreen/LoanCard/components/components/GridItem"; +import { GridItemWrapper } from "@/src/screens/TransactionsScreen/LoanCard/components/components/GridItemWrapper"; +import { TotalDebt } from "@/src/screens/TransactionsScreen/LoanCard/components/components/TotalDebt"; +import { CollateralCell } from "@/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/components/CollateralCell"; +import { riskLevelToStatusMode } from "@/src/uikit-utils.tsx"; +import { css } from "@/styled-system/css"; import { HFlex, StatusDot } from "@liquity2/uikit"; +import * as dn from "dnum"; import { useMemo } from "react"; -import { NetValueCell } from "@/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/components/NetValueCell"; -import { CollateralCell } from "@/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/components/CollateralCell"; -import { GridItemWrapper } from "@/src/screens/TransactionsScreen/LoanCard/components/components/GridItemWrapper"; -import { CrossedText } from "@/src/comps/CrossedText"; +import { NetValue } from "./components/NetValue"; -import type { LoanDetails, PositionLoan } from "@/src/types"; +import type { LoanDetails, PositionLoan, PositionLoanCommitted } from "@/src/types"; import type { CollateralToken } from "@liquity2/uikit"; import type { FC } from "react"; @@ -23,9 +22,10 @@ interface OpenLoanProps { loan: PositionLoan; loanDetails: LoanDetails; prevLoanDetails?: ReturnType | null; - prevLoan?: PositionLoan | null; + prevLoan?: PositionLoanCommitted | null; leverageMode: boolean; collToken: CollateralToken; + isSuccess?: boolean; } export const OpenLoan: FC = ({ @@ -35,22 +35,20 @@ export const OpenLoan: FC = ({ leverageMode, collToken, loanDetails, + isSuccess, }) => { - const redemptionRisk = useRedemptionRisk( - loan?.branchId ?? 0, - loan?.interestRate ?? null, - ); - const prevRedemptionRisk = useRedemptionRisk( - prevLoan?.branchId ?? 0, - prevLoan?.interestRate ?? null, + const redemptionRisk = useRedemptionRiskOfInterestRate( + loan.branchId, + loan.interestRate, ); + const prevRedemptionRisk = useRedemptionRiskOfLoan(prevLoan ?? EMPTY_LOAN); + const maxLtv = useMemo(() => { return dn.div(dn.from(1, 18), collToken.collateralRatio); }, [collToken.collateralRatio]); - const { ltv, depositPreLeverage, liquidationRisk, liquidationPrice } = - loanDetails; + const { ltv, depositPreLeverage, liquidationRisk, liquidationPrice } = loanDetails; if (!depositPreLeverage) { return null; @@ -58,15 +56,16 @@ export const OpenLoan: FC = ({ return ( <> - {leverageMode ? ( - - ) : ( - - )} + {leverageMode + ? ( + + ) + : }
= ({ paddingTop: 32, })} > - {leverageMode ? ( - - ) : ( - - )} - + + {fmtnum(liquidationPrice, { preset: "2z", prefix: "$" })} - {liquidationPrice && - prevLoanDetails?.liquidationPrice && - !dn.eq(prevLoanDetails.liquidationPrice, liquidationPrice) && ( - - {fmtnum(prevLoanDetails.liquidationPrice, { - preset: "2z", - prefix: "$", - })} - - )} + {liquidationPrice + && prevLoanDetails?.liquidationPrice + && !dn.eq(prevLoanDetails.liquidationPrice, liquidationPrice) && !isSuccess && ( + + {fmtnum(prevLoanDetails.liquidationPrice, { + preset: "2z", + prefix: "$", + })} + + )} + + +
+ {fmtnum(ltv, "pct2z")}% +
+ {ltv && prevLoanDetails?.ltv && !dn.eq(prevLoanDetails.ltv, ltv) && !isSuccess && ( + {fmtnum(prevLoanDetails.ltv, "pct2z")}% + )}
{fmtnum(loan.interestRate, "pct2z")}%
@@ -129,46 +141,24 @@ export const OpenLoan: FC = ({ {fmtnum(prevLoan.interestRate, "pct2z")}% )}
- -
- {fmtnum(ltv, "pct2z")}% -
- {ltv && prevLoanDetails?.ltv && !dn.eq(prevLoanDetails.ltv, ltv) && ( - {fmtnum(prevLoanDetails.ltv, "pct2z")}% - )} -
{formatRisk(liquidationRisk)} - {prevLoanDetails && - liquidationRisk !== prevLoanDetails.liquidationRisk && ( - <> - - - {formatRisk(prevLoanDetails.liquidationRisk)} - - - )} + {prevLoanDetails + && liquidationRisk !== prevLoanDetails.liquidationRisk && !isSuccess && ( + <> + + + {formatRisk(prevLoanDetails.liquidationRisk)} + + + )} {redemptionRisk.data && ( @@ -179,18 +169,18 @@ export const OpenLoan: FC = ({ size={8} /> {formatRisk(redemptionRisk.data)} - {prevRedemptionRisk.data && - redemptionRisk.data !== prevRedemptionRisk.data && ( - <> - - - {formatRisk(prevRedemptionRisk.data)} - - - )} + {prevLoan && prevRedemptionRisk.data + && redemptionRisk.data !== prevRedemptionRisk.data && ( + <> + + + {formatRisk(prevRedemptionRisk.data)} + + + )} )} diff --git a/frontend/app/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/components/CollateralCell/CollateralCell.tsx b/frontend/app/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/components/CollateralCell/CollateralCell.tsx index 859f9fed..8ccf32a9 100644 --- a/frontend/app/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/components/CollateralCell/CollateralCell.tsx +++ b/frontend/app/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/components/CollateralCell/CollateralCell.tsx @@ -1,24 +1,26 @@ -import { GridItemWrapper } from "@/src/screens/TransactionsScreen/LoanCard/components/components/GridItemWrapper"; -import { CrossedText } from '@/src/comps/CrossedText'; +import { CrossedText } from "@/src/comps/CrossedText"; import { fmtnum } from "@/src/formatting"; +import { GridItemWrapper } from "@/src/screens/TransactionsScreen/LoanCard/components/components/GridItemWrapper"; import * as dn from "dnum"; import type { Dnum } from "dnum"; import type { FC } from "react"; interface CollateralCellProps { + leverageMode: boolean; deposit: Dnum; prevDeposit?: Dnum; collTokenName: string; } export const CollateralCell: FC = ({ + leverageMode, deposit, prevDeposit, collTokenName, }) => { return ( - +
{fmtnum(deposit)} {collTokenName}
diff --git a/frontend/app/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/components/LeveragedExposure/LeveragedExposure.tsx b/frontend/app/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/components/LeveragedExposure/LeveragedExposure.tsx deleted file mode 100644 index a752d5c0..00000000 --- a/frontend/app/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/components/LeveragedExposure/LeveragedExposure.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { getCollToken } from "@/src/liquity-utils"; -import { css } from "@/styled-system/css"; -import { fmtnum } from "@/src/formatting"; -import { Value } from "@/src/comps/Value/Value"; -import { INFINITY } from "@/src/characters"; -import { roundToDecimal } from "@/src/utils"; -import { getLoanDetails } from "@/src/liquity-math"; -import { TokenIcon } from "@liquity2/uikit"; -import { CrossedText } from '@/src/comps/CrossedText'; - -import type { FC } from "react"; -import type { PositionLoan } from "@/src/types"; - -interface LeveragedExposureProps { - loan: PositionLoan; - loanDetails: ReturnType; - prevLoanDetails: null | ReturnType; -} - -export const LeveragedExposure: FC = ({ - loanDetails, - loan, - prevLoanDetails, -}) => { - const collToken = getCollToken(loan.branchId); - - if (!collToken) { - return null; - } - - return ( -
-
-
-
{fmtnum(loan.deposit)}
- -
- - {loanDetails.status === "underwater" || - loanDetails.leverageFactor === null - ? INFINITY - : `${roundToDecimal(loanDetails.leverageFactor, 1)}x`} - - {prevLoanDetails && - prevLoanDetails.leverageFactor !== loanDetails.leverageFactor && ( - - {prevLoanDetails.leverageFactor === null - ? INFINITY - : `${roundToDecimal(prevLoanDetails.leverageFactor, 1)}x`} - - )} -
-
-
-
- ); -}; diff --git a/frontend/app/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/components/LeveragedExposure/index.ts b/frontend/app/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/components/LeveragedExposure/index.ts deleted file mode 100644 index 6b645a48..00000000 --- a/frontend/app/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/components/LeveragedExposure/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { LeveragedExposure } from './LeveragedExposure'; diff --git a/frontend/app/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/components/NetValue/NetValue.tsx b/frontend/app/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/components/NetValue/NetValue.tsx new file mode 100644 index 00000000..b2b40d94 --- /dev/null +++ b/frontend/app/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/components/NetValue/NetValue.tsx @@ -0,0 +1,100 @@ +import { CrossedText } from "@/src/comps/CrossedText"; +import { Value } from "@/src/comps/Value/Value"; +import { fmtnum } from "@/src/formatting"; +import { getLoanDetails } from "@/src/liquity-math"; +import { getCollToken } from "@/src/liquity-utils"; +import { roundToDecimal } from "@/src/utils"; +import { css } from "@/styled-system/css"; +import { TokenIcon } from "@liquity2/uikit"; +import * as dn from "dnum"; + +import type { PositionLoan } from "@/src/types"; +import type { FC } from "react"; +import { WHITE_LABEL_CONFIG } from "@/src/white-label.config"; + +interface NetValueProps { + loan: PositionLoan; + loanDetails: ReturnType; + prevLoanDetails: null | ReturnType; + isSuccess?: boolean; +} + +export const NetValue: FC = ({ + loanDetails, + loan, + prevLoanDetails, + isSuccess, +}) => { + const collToken = getCollToken(loan.branchId); + + if (!collToken) { + return null; + } + + return ( +
+
+
+
{fmtnum(loanDetails.depositPreLeverage)}
+ + {prevLoanDetails?.depositPreLeverage && loanDetails.depositPreLeverage + && !dn.eq(prevLoanDetails.depositPreLeverage, loanDetails.depositPreLeverage) && ( + + {fmtnum(prevLoanDetails.depositPreLeverage)} + + )} + + + +
+ {loanDetails.leverageFactor !== null && ( + + {roundToDecimal(loanDetails.leverageFactor, 1)}x + + )} + + {prevLoanDetails + && prevLoanDetails.leverageFactor !== null + && prevLoanDetails.leverageFactor !== loanDetails.leverageFactor && !isSuccess && ( + + {roundToDecimal(prevLoanDetails.leverageFactor, 1)}x + + )} +
+
+
+
+ ); +}; diff --git a/frontend/app/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/components/NetValue/index.ts b/frontend/app/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/components/NetValue/index.ts new file mode 100644 index 00000000..e832e1ab --- /dev/null +++ b/frontend/app/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/components/NetValue/index.ts @@ -0,0 +1 @@ +export { NetValue } from "./NetValue"; diff --git a/frontend/app/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/components/NetValueCell/NetValueCell.tsx b/frontend/app/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/components/NetValueCell/NetValueCell.tsx deleted file mode 100644 index e7e7c107..00000000 --- a/frontend/app/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/components/NetValueCell/NetValueCell.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Value } from "@/src/comps/Value/Value"; -import * as dn from "dnum"; -import { fmtnum } from "@/src/formatting"; -import { GridItemWrapper } from "@/src/screens/TransactionsScreen/LoanCard/components/components/GridItemWrapper"; -import { CrossedText } from "@/src/comps/CrossedText"; - -import type { Dnum } from "dnum"; -import type { LoanDetails } from "@/src/types"; -import type { FC } from "react"; - -interface NetValueCellProps { - depositPreLeverage: Dnum; - prevDepositPreLeverage?: LoanDetails["depositPreLeverage"]; - collTokenName: string; - isUnderwater: boolean; -} - -export const NetValueCell: FC = ({ - depositPreLeverage, - prevDepositPreLeverage, - collTokenName, - isUnderwater, -}) => ( - - - {fmtnum(depositPreLeverage)} {collTokenName} - - - {prevDepositPreLeverage && - !dn.eq(prevDepositPreLeverage, depositPreLeverage) && ( - - {fmtnum(prevDepositPreLeverage)} {collTokenName} - - )} - -); diff --git a/frontend/app/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/components/NetValueCell/index.ts b/frontend/app/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/components/NetValueCell/index.ts deleted file mode 100644 index 211a1255..00000000 --- a/frontend/app/src/screens/TransactionsScreen/LoanCard/components/OpenLoan/components/NetValueCell/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { NetValueCell } from './NetValueCell'; diff --git a/frontend/app/src/services/Prices.tsx b/frontend/app/src/services/Prices.tsx index f38c6730..1242afe1 100644 --- a/frontend/app/src/services/Prices.tsx +++ b/frontend/app/src/services/Prices.tsx @@ -10,23 +10,15 @@ import { dnum18, jsonStringifyWithDnum } from "@/src/dnum-utils"; import { useLiquityStats } from "@/src/liquity-utils"; import { isCollateralSymbol } from "@liquity2/uikit"; import { useQuery } from "@tanstack/react-query"; -import { useConfig as useWagmiConfig } from "wagmi"; +import { useConfig as useWagmiConfig, useReadContracts } from "wagmi"; import { readContract } from "wagmi/actions"; async function fetchCollateralPrice( symbol: CollateralSymbol, config: ReturnType, ): Promise { - const PriceFeed = getBranchContract(symbol, "PriceFeed"); - - const FetchPriceAbi = PriceFeed.abi.find((fn) => fn.name === "fetchPrice"); - if (!FetchPriceAbi) { - throw new Error("fetchPrice ABI not found"); - } - const [price] = await readContract(config, { - abi: [{ ...FetchPriceAbi, stateMutability: "view" }] as const, - address: PriceFeed.address, + ...getBranchContract(symbol, "PriceFeed"), functionName: "fetchPrice", }); @@ -59,3 +51,19 @@ export function usePrice(symbol: string | null): UseQueryResult { refetchInterval: PRICE_REFRESH_INTERVAL, }); } + +export function useCollateralPrices(symbols: CollateralSymbol[]) { + return useReadContracts({ + allowFailure: false, + + contracts: symbols.map((symbol) => ({ + ...getBranchContract(symbol, "PriceFeed"), + functionName: "fetchPrice", + } as const)), + + query: { + select: (data) => data.map(([price]) => dnum18(price)), + refetchInterval: 12_000, + }, + }); +} diff --git a/frontend/app/src/subgraph.ts b/frontend/app/src/subgraph.ts index 179ce6d6..7faaa177 100644 --- a/frontend/app/src/subgraph.ts +++ b/frontend/app/src/subgraph.ts @@ -3,13 +3,12 @@ import * as dn from "dnum"; import type { TypedDocumentString } from "@/src/graphql/graphql"; import type { Address, BranchId, Dnum, TroveId, TroveStatus } from "@/src/types"; -import { dnum18 } from "@/src/dnum-utils"; +import { ONE_YEAR_D18 } from "@/src/constants"; +import { dnum18, dnum36 } from "@/src/dnum-utils"; import { SUBGRAPH_URL } from "@/src/env"; import { graphql } from "@/src/graphql"; import { subgraphIndicator } from "@/src/indicators/subgraph-indicator"; import { getPrefixedTroveId } from "@/src/liquity-utils"; -import { getTrovesByAccount, getTroveById, getAllDebtPerInterestRate } from "./contract-read-calls"; -import { TroveStatus as TroveStatusEnum } from "./types"; export type IndexedTrove = { id: string; @@ -17,12 +16,17 @@ export type IndexedTrove = { closedAt: number | null; createdAt: number; lastUserActionAt: number; + updatedAt: number; mightBeLeveraged: boolean; status: TroveStatus; debt: Dnum; redemptionCount: number; redeemedColl: Dnum; redeemedDebt: Dnum; + liquidatedColl: Dnum | null; + liquidatedDebt: Dnum | null; + collSurplus: Dnum | null; + priceAtLiquidation: Dnum | null; }; async function tryFetch(...args: Parameters) { @@ -115,7 +119,7 @@ const TrovesByAccountQuery = graphql(` where: { or: [ { previousOwner: $account, status: liquidated }, - { borrower: $account, status_in: [active,redeemed] } + { borrower: $account, status_in: [active, redeemed] } ], } orderBy: updatedAt @@ -125,75 +129,46 @@ const TrovesByAccountQuery = graphql(` closedAt createdAt lastUserActionAt + updatedAt mightBeLeveraged status debt redemptionCount redeemedColl redeemedDebt + liquidatedColl + liquidatedDebt + collSurplus + priceAtLiquidation } } `); export async function getIndexedTrovesByAccount(account: Address): Promise { - try { - const { troves } = await graphQuery(TrovesByAccountQuery, { - account: account.toLowerCase(), - }); - return troves.map((trove) => ({ - id: trove.id, - borrower: account, - closedAt: trove.closedAt === null || trove.closedAt === undefined - ? null - : Number(trove.closedAt) * 1000, - createdAt: Number(trove.createdAt) * 1000, - lastUserActionAt: Number(trove.lastUserActionAt) * 1000, - mightBeLeveraged: trove.mightBeLeveraged, - status: trove.status, - debt: dnum18(trove.debt), - redemptionCount: trove.redemptionCount, - redeemedColl: dnum18(trove.redeemedColl), - redeemedDebt: dnum18(trove.redeemedDebt), - })); - } catch (error) { - console.warn("Subgraph query failed, attempting backup read calls:", error); - - try { - const readCallTroves = await getTrovesByAccount(account); - - return readCallTroves.map((trove) => { - let status: TroveStatus; - if (trove.status === TroveStatusEnum.active) { - status = "active" as TroveStatus; - } else if (trove.status === TroveStatusEnum.closedByOwner) { - status = "closed" as TroveStatus; - } else if (trove.status === TroveStatusEnum.closedByLiquidation) { - status = "liquidated" as TroveStatus; - } else if (trove.status === TroveStatusEnum.zombie) { - status = "redeemed" as TroveStatus; - } else { - status = "closed" as TroveStatus; - } - - return { - id: trove.id, - borrower: account, - closedAt: status === "active" ? null : 0, - createdAt: 0, - lastUserActionAt: 0, - mightBeLeveraged: false, - status, - debt: dnum18(trove.debt), - redemptionCount: 0, - redeemedColl: dnum18(0), - redeemedDebt: dnum18(0), - }; - }); - } catch (fallbackError) { - console.error("Backup read calls also failed:", fallbackError); - return []; - } - } + const { troves } = await graphQuery(TrovesByAccountQuery, { + account: account.toLowerCase(), + }); + return troves.map((trove) => ({ + id: trove.id, + borrower: account, + // TODO: eliminate conversion to milliseconds + closedAt: trove.closedAt === null || trove.closedAt === undefined + ? null + : Number(trove.closedAt) * 1000, + createdAt: Number(trove.createdAt) * 1000, + lastUserActionAt: Number(trove.lastUserActionAt) * 1000, + updatedAt: Number(trove.updatedAt) * 1000, + mightBeLeveraged: trove.mightBeLeveraged, + status: trove.status, + debt: dnum18(trove.debt), + redemptionCount: trove.redemptionCount, + redeemedColl: dnum18(trove.redeemedColl), + redeemedDebt: dnum18(trove.redeemedDebt), + liquidatedColl: dnum18(trove.liquidatedColl), + liquidatedDebt: dnum18(trove.liquidatedDebt), + collSurplus: dnum18(trove.collSurplus), + priceAtLiquidation: dnum18(trove.priceAtLiquidation), + })); } const TroveByIdQuery = graphql(` @@ -204,6 +179,7 @@ const TroveByIdQuery = graphql(` closedAt createdAt lastUserActionAt + updatedAt mightBeLeveraged previousOwner status @@ -211,6 +187,10 @@ const TroveByIdQuery = graphql(` redemptionCount redeemedColl redeemedDebt + liquidatedColl + liquidatedDebt + collSurplus + priceAtLiquidation } } `); @@ -220,67 +200,29 @@ export async function getIndexedTroveById( troveId: TroveId, ): Promise { const prefixedTroveId = getPrefixedTroveId(branchId, troveId); - - try { - const { trove } = await graphQuery(TroveByIdQuery, { id: prefixedTroveId }); - return !trove ? null : { - id: trove.id, - borrower: ( - trove.status === "liquidated" ? trove.previousOwner : trove.borrower - ) as Address, - closedAt: trove.closedAt === null || trove.closedAt === undefined - ? null - : Number(trove.closedAt) * 1000, - createdAt: Number(trove.createdAt) * 1000, - lastUserActionAt: Number(trove.lastUserActionAt) * 1000, - mightBeLeveraged: trove.mightBeLeveraged, - status: trove.status, - debt: dnum18(trove.debt), - redemptionCount: trove.redemptionCount, - redeemedColl: dnum18(trove.redeemedColl), - redeemedDebt: dnum18(trove.redeemedDebt), - }; - } catch (error) { - console.warn("Subgraph query failed for getTroveById, attempting backup read calls:", error); - - try { - const readCallTrove = await getTroveById(prefixedTroveId); - - if (!readCallTrove) { - return null; - } - - let status: TroveStatus; - if (readCallTrove.status === TroveStatusEnum.active) { - status = "active" as TroveStatus; - } else if (readCallTrove.status === TroveStatusEnum.closedByOwner) { - status = "closed" as TroveStatus; - } else if (readCallTrove.status === TroveStatusEnum.closedByLiquidation) { - status = "liquidated" as TroveStatus; - } else if (readCallTrove.status === TroveStatusEnum.zombie) { - status = "redeemed" as TroveStatus; - } else { - status = "closed" as TroveStatus; - } - - return { - id: prefixedTroveId, - borrower: readCallTrove.borrower, - closedAt: status === "active" ? null : 0, - createdAt: 0, - lastUserActionAt: 0, - mightBeLeveraged: false, - status, - debt: dnum18(readCallTrove.debt), - redemptionCount: 0, - redeemedColl: dnum18(0), - redeemedDebt: dnum18(0), - }; - } catch (fallbackError) { - console.error("Backup read calls also failed:", fallbackError); - return null; - } - } + const { trove } = await graphQuery(TroveByIdQuery, { id: prefixedTroveId }); + return !trove ? null : { + id: trove.id, + borrower: ( + trove.status === "liquidated" ? trove.previousOwner : trove.borrower + ) as Address, + closedAt: trove.closedAt === null || trove.closedAt === undefined + ? null + : Number(trove.closedAt) * 1000, + createdAt: Number(trove.createdAt) * 1000, + lastUserActionAt: Number(trove.lastUserActionAt) * 1000, + updatedAt: Number(trove.updatedAt) * 1000, + mightBeLeveraged: trove.mightBeLeveraged, + status: trove.status, + debt: dnum18(trove.debt), + redemptionCount: trove.redemptionCount, + redeemedColl: dnum18(trove.redeemedColl), + redeemedDebt: dnum18(trove.redeemedDebt), + liquidatedColl: dnum18(trove.liquidatedColl), + liquidatedDebt: dnum18(trove.liquidatedDebt), + collSurplus: dnum18(trove.collSurplus), + priceAtLiquidation: dnum18(trove.priceAtLiquidation), + }; } const InterestBatchesQuery = graphql(` @@ -327,39 +269,52 @@ const AllInterestRateBracketsQuery = graphql(` } rate totalDebt + sumDebtTimesRateD36 + pendingDebtTimesOneYearD36 + updatedAt } } `); export async function getAllInterestRateBrackets() { - try { - const { interestRateBrackets } = await graphQuery(AllInterestRateBracketsQuery); - return interestRateBrackets - .map((bracket) => ({ - branchId: bracket.collateral.collIndex, - rate: dnum18(bracket.rate), - totalDebt: dnum18(bracket.totalDebt), - })) - .sort((a, b) => dn.cmp(a.rate, b.rate)); - } catch (error) { - console.warn("Subgraph query failed for getAllInterestRateBrackets, attempting backup read calls:", error); - - try { - const debtPerInterestRate = await getAllDebtPerInterestRate(); - - // Convert read call format to subgraph format - return Object.entries(debtPerInterestRate).flatMap(([branchId, rates]) => { - return rates.map((data) => ({ - branchId: Number(branchId) as BranchId, - rate: dnum18(data.interestRate), - totalDebt: dnum18(data.debt), - })); - }).sort((a, b) => dn.cmp(a.rate, b.rate)); - } catch (fallbackError) { - console.error("Backup read calls also failed:", fallbackError); - return []; - } - } + const result = await graphQuery(AllInterestRateBracketsQuery); + + const brackets = result.interestRateBrackets + .map((bracket) => ({ + branchId: bracket.collateral.collIndex, + rate: dnum18(bracket.rate), + totalDebt: BigInt(bracket.totalDebt), + sumDebtTimesRateD36: BigInt(bracket.sumDebtTimesRateD36), + pendingDebtTimesOneYearD36: BigInt(bracket.pendingDebtTimesOneYearD36), + updatedAt: BigInt(bracket.updatedAt), + })) + .sort((a, b) => dn.cmp(a.rate, b.rate)); + + const lastUpdatedAt = brackets + .map((bracket) => bracket.updatedAt) + .reduce((a, b) => a > b ? a : b); + + return { + lastUpdatedAt, + + brackets: brackets.map((bracket) => ({ + branchId: bracket.branchId, + rate: bracket.rate, + totalWeightedRate: dnum36(bracket.sumDebtTimesRateD36), + + totalDebt: (timestamp: bigint) => { + if (timestamp < lastUpdatedAt) timestamp = lastUpdatedAt; + + return dnum18( + bracket.totalDebt + + ( + bracket.pendingDebtTimesOneYearD36 + + bracket.sumDebtTimesRateD36 * (timestamp - bracket.updatedAt) + ) / ONE_YEAR_D18, + ); + }, + })), + }; } const GovernanceGlobalDataQuery = graphql(` diff --git a/frontend/app/src/tx-flows/allocateVotingPower.tsx b/frontend/app/src/tx-flows/allocateVotingPower.tsx index ad58b35a..e8569966 100644 --- a/frontend/app/src/tx-flows/allocateVotingPower.tsx +++ b/frontend/app/src/tx-flows/allocateVotingPower.tsx @@ -271,7 +271,7 @@ function VoteAllocation({ key="end" title={initiative.address} > - {initiative.protocol ?? } + {initiative.group ?? }
, ]} value={[ diff --git a/frontend/app/src/tx-flows/claimCollateralSurplus.tsx b/frontend/app/src/tx-flows/claimCollateralSurplus.tsx index ac0f346f..872892a4 100644 --- a/frontend/app/src/tx-flows/claimCollateralSurplus.tsx +++ b/frontend/app/src/tx-flows/claimCollateralSurplus.tsx @@ -152,7 +152,7 @@ export const claimCollateralSurplus: FlowDeclaration

- This will claim all available collateral surplus from your unclaimed liquidated positions for the{" "} + This will claim all available remaining collateral from your unclaimed liquidated positions for the{" "} {collateral?.name} collateral. The total amount will be sent to your wallet.

diff --git a/frontend/app/src/tx-flows/closeLoanPosition.tsx b/frontend/app/src/tx-flows/closeLoanPosition.tsx index d46dbd84..47804efc 100644 --- a/frontend/app/src/tx-flows/closeLoanPosition.tsx +++ b/frontend/app/src/tx-flows/closeLoanPosition.tsx @@ -3,19 +3,20 @@ import type { FlowDeclaration } from "@/src/services/TransactionFlow"; import { Amount } from "@/src/comps/Amount/Amount"; import { ETH_GAS_COMPENSATION } from "@/src/constants"; import { fmtnum } from "@/src/formatting"; -import { getCloseFlashLoanAmount } from "@/src/liquity-leverage"; import { getBranch, getCollToken } from "@/src/liquity-utils"; import { LoanCard } from "@/src/screens/TransactionsScreen/LoanCard"; import { TransactionDetailsRow } from "@/src/screens/TransactionsScreen/TransactionsScreen"; import { TransactionStatus } from "@/src/screens/TransactionsScreen/TransactionStatus"; -import { usePrice } from "@/src/services/Prices"; import { getIndexedTroveById } from "@/src/subgraph"; import { sleep } from "@/src/utils"; -import { vPositionLoanCommited } from "@/src/valibot-utils"; +import { vDnum, vPositionLoanCommited } from "@/src/valibot-utils"; +import { css } from "@/styled-system/css"; +import { InfoTooltip } from "@liquity2/uikit"; import * as dn from "dnum"; import * as v from "valibot"; import { maxUint256 } from "viem"; import { readContract, readContracts } from "wagmi/actions"; +import { useSlippageRefund } from "../liquity-leverage"; import { createRequestSchema, verifyTransaction } from "./shared"; import { WHITE_LABEL_CONFIG } from "@/src/white-label.config"; @@ -23,7 +24,11 @@ const RequestSchema = createRequestSchema( "closeLoanPosition", { loan: vPositionLoanCommited(), - repayWithCollateral: v.boolean(), + repayWithCollateral: v.optional( + v.object({ + flashLoanAmount: vDnum(), + }), + ), }, ); @@ -41,22 +46,19 @@ export const closeLoanPosition: FlowDeclaration = { prevLoan={request.loan} onRetry={() => {}} txPreviewMode + displayAllDifferences={false} /> ); }, - Details({ request }) { + Details({ request, account, steps }) { const { loan, repayWithCollateral } = request; const collateral = getCollToken(loan.branchId); - const collPrice = usePrice(collateral.symbol); - - if (!collPrice.data) { - return null; - } + const slippageRefund = useSlippageRefund(loan.branchId, account, steps, !!repayWithCollateral); const amountToRepay = repayWithCollateral - ? (dn.div(loan.borrowed ?? dn.from(0), collPrice.data)) - : (loan.borrowed ?? dn.from(0)); + ? repayWithCollateral.flashLoanAmount + : loan.borrowed; const collToReclaim = repayWithCollateral ? dn.sub(loan.deposit, amountToRepay) @@ -66,7 +68,7 @@ export const closeLoanPosition: FlowDeclaration = { <> {dn.gt(amountToRepay, 0) && ( = { /> )} , ]} /> @@ -97,6 +99,33 @@ export const closeLoanPosition: FlowDeclaration = { , ]} /> + {slippageRefund.data && ( + + Slippage refund + + Excess ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol} was acquired to repay your debt and accommodate for slippage. This is the left over amount + that has been refunded to your wallet. + + + } + value={[ + , + ]} + /> + )} ); }, @@ -145,11 +174,12 @@ export const closeLoanPosition: FlowDeclaration = { Status: TransactionStatus, async commit(ctx) { - const { loan } = ctx.request; + const { loan, repayWithCollateral } = ctx.request; + const deposit = dn.from(loan.deposit, 18)[0]; const branch = getBranch(loan.branchId); // repay with ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol} => get ETH - if (!ctx.request.repayWithCollateral && branch.symbol === "ETH") { + if (!repayWithCollateral && branch.symbol === "ETH") { return ctx.writeContract({ ...branch.contracts.LeverageWETHZapper, functionName: "closeTroveToRawETH", @@ -158,7 +188,7 @@ export const closeLoanPosition: FlowDeclaration = { } // repay with ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol} => get LST - if (!ctx.request.repayWithCollateral) { + if (!repayWithCollateral) { return ctx.writeContract({ ...branch.contracts.LeverageLSTZapper, functionName: "closeTroveToRawETH", @@ -168,22 +198,14 @@ export const closeLoanPosition: FlowDeclaration = { // from here, we are repaying with the collateral - const closeFlashLoanAmount = await getCloseFlashLoanAmount( - loan.branchId, - loan.troveId, - ctx.wagmiConfig, - ); - - if (closeFlashLoanAmount === null) { - throw new Error("The flash loan amount could not be calculated."); - } + const closeFlashLoanAmount = dn.from(repayWithCollateral.flashLoanAmount, 18)[0]; // repay with collateral => get ETH if (branch.symbol === "ETH") { return ctx.writeContract({ ...branch.contracts.LeverageWETHZapper, functionName: "closeTroveFromCollateral", - args: [BigInt(loan.troveId), closeFlashLoanAmount], + args: [BigInt(loan.troveId), closeFlashLoanAmount, deposit - closeFlashLoanAmount], }); } @@ -191,7 +213,7 @@ export const closeLoanPosition: FlowDeclaration = { return ctx.writeContract({ ...branch.contracts.LeverageLSTZapper, functionName: "closeTroveFromCollateral", - args: [BigInt(loan.troveId), closeFlashLoanAmount], + args: [BigInt(loan.troveId), closeFlashLoanAmount, deposit - closeFlashLoanAmount], }); }, diff --git a/frontend/app/src/tx-flows/earnClaimRewards.tsx b/frontend/app/src/tx-flows/earnClaimRewards.tsx index bc4167f5..7b0c021e 100644 --- a/frontend/app/src/tx-flows/earnClaimRewards.tsx +++ b/frontend/app/src/tx-flows/earnClaimRewards.tsx @@ -79,7 +79,7 @@ export const earnClaimRewards: FlowDeclaration = { return ( <> = { />, ]} /> - , - , - ]} - /> + {!compound && ( + , + , + ]} + /> + )} ); }, steps: { claimRewards: { - name: (ctx) => ctx.request.compound ? "Compound rewards" : "Claim rewards", + name: (ctx) => ctx.request.compound ? `Compound ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol} rewards` : "Claim rewards", Status: TransactionStatus, async commit(ctx) { diff --git a/frontend/app/src/tx-flows/earnUpdate.tsx b/frontend/app/src/tx-flows/earnUpdate.tsx index 1e370c45..4394fc56 100644 --- a/frontend/app/src/tx-flows/earnUpdate.tsx +++ b/frontend/app/src/tx-flows/earnUpdate.tsx @@ -117,7 +117,7 @@ export const earnUpdate: FlowDeclaration = { , = { const { branchId, interestRateDelegate, boldAmount } = request; const delegate = useInterestBatchDelegate(branchId, interestRateDelegate); + const delegateDisplayName = useDelegateDisplayName(interestRateDelegate); const yearlyBoldInterest = dn.mul( boldAmount, dn.add(request.annualInterestRate, delegate.data?.fee ?? 0), @@ -154,6 +156,7 @@ export const openBorrowPosition: FlowDeclaration = { ,
{delegate.isLoading diff --git a/frontend/app/src/tx-flows/openLeveragePosition.tsx b/frontend/app/src/tx-flows/openLeveragePosition.tsx index 23d69496..88230ebf 100644 --- a/frontend/app/src/tx-flows/openLeveragePosition.tsx +++ b/frontend/app/src/tx-flows/openLeveragePosition.tsx @@ -1,12 +1,19 @@ import type { FlowDeclaration } from "@/src/services/TransactionFlow"; +import type { TroveId } from "@/src/types"; import { Amount } from "@/src/comps/Amount/Amount"; import { ETH_GAS_COMPENSATION, MAX_UPFRONT_FEE } from "@/src/constants"; import { WHITE_LABEL_CONFIG } from "@/src/white-label.config"; import { dnum18 } from "@/src/dnum-utils"; import { fmtnum } from "@/src/formatting"; -import { getOpenLeveragedTroveParams } from "@/src/liquity-leverage"; -import { getBranch, getCollToken, getTroveOperationHints, usePredictOpenTroveUpfrontFee } from "@/src/liquity-utils"; +import { useDelegateDisplayName } from "@/src/liquity-delegate"; +import { + getBranch, + getCollToken, + getPrefixedTroveId, + getTroveOperationHints, + usePredictOpenTroveUpfrontFee, +} from "@/src/liquity-utils"; import { AccountButton } from "@/src/screens/TransactionsScreen/AccountButton"; import { LoanCard } from "@/src/screens/TransactionsScreen/LoanCard"; import { TransactionDetailsRow } from "@/src/screens/TransactionsScreen/TransactionsScreen"; @@ -14,21 +21,24 @@ import { TransactionStatus } from "@/src/screens/TransactionsScreen/TransactionS import { usePrice } from "@/src/services/Prices"; import { getIndexedTroveById } from "@/src/subgraph"; import { noop, sleep } from "@/src/utils"; -import { vPositionLoanUncommited } from "@/src/valibot-utils"; +import { vDnum, vPositionLoanUncommited } from "@/src/valibot-utils"; import { css } from "@/styled-system/css"; import { ADDRESS_ZERO, InfoTooltip } from "@liquity2/uikit"; import * as dn from "dnum"; import * as v from "valibot"; import { maxUint256, parseEventLogs } from "viem"; import { readContract } from "wagmi/actions"; +import { useSlippageRefund } from "../liquity-leverage"; import { createRequestSchema, verifyTransaction } from "./shared"; const RequestSchema = createRequestSchema( "openLeveragePosition", { ownerIndex: v.number(), - leverageFactor: v.number(), loan: vPositionLoanUncommited(), + initialDeposit: vDnum(), + flashloanAmount: vDnum(), + boldAmount: vDnum(), }, ); @@ -45,11 +55,12 @@ export const openLeveragePosition: FlowDeclaration loan={request.loan} onRetry={noop} txPreviewMode + displayAllDifferences={false} /> ); }, - Details({ request }) { + Details({ request, account, steps }) { const { loan } = request; const collToken = getCollToken(loan.branchId); if (!collToken) { @@ -57,30 +68,26 @@ export const openLeveragePosition: FlowDeclaration } const collPrice = usePrice(collToken.symbol); - const upfrontFee = usePredictOpenTroveUpfrontFee( - loan.branchId, - loan.borrowed, - loan.interestRate, - ); - - const initialDeposit = dn.div(loan.deposit, request.leverageFactor); + const upfrontFee = usePredictOpenTroveUpfrontFee(loan.branchId, loan.borrowed, loan.interestRate); + const delegateDisplayName = useDelegateDisplayName(loan.batchManager); const yearlyBoldInterest = dn.mul(loan.borrowed, loan.interestRate); const borrowedWithFee = upfrontFee.data && dn.add(loan.borrowed, upfrontFee.data); + const slippageRefund = useSlippageRefund(loan.branchId, account, steps); return ( <> , + ,
{fmtnum(loan.interestRate, "pctfull")}% ({fmtnum(yearlyBoldInterest, { digits: 4, @@ -142,6 +153,41 @@ export const openLeveragePosition: FlowDeclaration "Only used in case of liquidation", ]} /> + {slippageRefund.data && ( + + Slippage refund + + Excess collateral was needed to create the desired exposure and accommodate for slippage. This is the + left over amount that has been refunded to your wallet. + +
+ } + value={[ + , + collPrice.data && ( + + ), + ]} + /> + )} ); }, @@ -160,7 +206,6 @@ export const openLeveragePosition: FlowDeclaration ), async commit(ctx) { const { loan } = ctx.request; - const initialDeposit = dn.div(loan.deposit, ctx.request.leverageFactor); const branch = getBranch(loan.branchId); const { LeverageLSTZapper, CollToken } = branch.contracts; return ctx.writeContract({ @@ -170,7 +215,7 @@ export const openLeveragePosition: FlowDeclaration LeverageLSTZapper.address, ctx.preferredApproveMethod === "approve-infinite" ? maxUint256 // infinite approval - : initialDeposit[0], // exact amount + : dn.from(ctx.request.initialDeposit, 18)[0], // exact amount ], }); }, @@ -185,17 +230,9 @@ export const openLeveragePosition: FlowDeclaration async commit(ctx) { const { loan } = ctx.request; - const initialDeposit = dn.div(loan.deposit, ctx.request.leverageFactor); const branch = getBranch(loan.branchId); const { LeverageLSTZapper, LeverageWETHZapper } = branch.contracts; - const openLeveragedParams = await getOpenLeveragedTroveParams( - loan.branchId, - initialDeposit[0], - ctx.request.leverageFactor, - ctx.wagmiConfig, - ); - const { upperHint, lowerHint } = await getTroveOperationHints({ wagmiConfig: ctx.wagmiConfig, contracts: ctx.contracts, @@ -206,9 +243,9 @@ export const openLeveragePosition: FlowDeclaration const txParams = { owner: loan.borrower, ownerIndex: BigInt(ctx.request.ownerIndex), - collAmount: initialDeposit[0], - flashLoanAmount: openLeveragedParams.flashLoanAmount, - boldAmount: openLeveragedParams.effectiveBoldAmount, + collAmount: dn.from(ctx.request.initialDeposit, 18)[0], + flashLoanAmount: dn.from(ctx.request.flashloanAmount, 18)[0], + boldAmount: dn.from(ctx.request.boldAmount, 18)[0], upperHint, lowerHint, annualInterestRate: loan.batchManager ? 0n : loan.interestRate[0], @@ -225,7 +262,7 @@ export const openLeveragePosition: FlowDeclaration ...LeverageWETHZapper, functionName: "openLeveragedTroveWithRawETH", args: [txParams], - value: initialDeposit[0] + ETH_GAS_COMPENSATION[0], + value: dn.from(ctx.request.initialDeposit, 18)[0] + ETH_GAS_COMPENSATION[0], }); } @@ -256,12 +293,24 @@ export const openLeveragePosition: FlowDeclaration throw new Error("Failed to extract trove ID from transaction"); } + const troveId: TroveId = `0x${troveOperation.args._troveId.toString(16)}`; + const prefixedTroveId = getPrefixedTroveId(branch.branchId, troveId); + + // Workaround for https://github.com/liquity/bold/issues/1134: + // Explicitly save this as a Multiply position so it doesn't turn + // into a Borrow position when making a collateral-only adjustment + ctx.storedState.setState(({ loanModes }) => ({ + loanModes: { + ...loanModes, + [prefixedTroveId]: "multiply", + }, + })); + // Wait for trove to appear in subgraph + // TODO: is this still needed? In `verifyTransaction` we wait for the + // subgraph to index up to the block in which the TX was included. while (true) { - const trove = await getIndexedTroveById( - branch.branchId, - `0x${troveOperation.args._troveId.toString(16)}`, - ); + const trove = await getIndexedTroveById(branch.branchId, troveId); if (trove !== null) { break; } @@ -296,8 +345,7 @@ export const openLeveragePosition: FlowDeclaration const steps: string[] = []; - const initialDeposit = dn.div(loan.deposit, ctx.request.leverageFactor); - if (dn.lt(allowance, initialDeposit)) { + if (dn.lt(allowance, ctx.request.initialDeposit)) { steps.push("approveLst"); } diff --git a/frontend/app/src/tx-flows/redeemCollateral.tsx b/frontend/app/src/tx-flows/redeemCollateral.tsx index fcf74fbc..34fcc63f 100644 --- a/frontend/app/src/tx-flows/redeemCollateral.tsx +++ b/frontend/app/src/tx-flows/redeemCollateral.tsx @@ -1,20 +1,17 @@ import type { FlowDeclaration } from "@/src/services/TransactionFlow"; -import type { Address } from "@/src/types"; import { Amount } from "@/src/comps/Amount/Amount"; -import { LOCAL_STORAGE_PREFIX } from "@/src/constants"; import { getProtocolContract } from "@/src/contracts"; -import { dnum18, jsonParseWithDnum, jsonStringifyWithDnum } from "@/src/dnum-utils"; -import { getBranches } from "@/src/liquity-utils"; +import { dnum18, DNUM_1 } from "@/src/dnum-utils"; +import { getBranches, getCollToken } from "@/src/liquity-utils"; import { TransactionDetailsRow } from "@/src/screens/TransactionsScreen/TransactionsScreen"; import { TransactionStatus } from "@/src/screens/TransactionsScreen/TransactionStatus"; +import { useCollateralPrices, usePrice } from "@/src/services/Prices"; import { vDnum } from "@/src/valibot-utils"; -import { useQuery } from "@tanstack/react-query"; +import { HFlex, InfoTooltip } from "@liquity2/uikit"; import * as dn from "dnum"; -import { Fragment } from "react"; import * as v from "valibot"; -import { createPublicClient } from "viem"; -import { http, useConfig as useWagmiConfig } from "wagmi"; +import { REDEMPTION_SLIPPAGE_TOLERANCE } from "../constants"; import { createRequestSchema, verifyTransaction } from "./shared"; import { WHITE_LABEL_CONFIG } from "@/src/white-label.config"; @@ -22,7 +19,10 @@ const RequestSchema = createRequestSchema( "redeemCollateral", { amount: vDnum(), - maxFee: vDnum(), + maxIterationsPerCollateral: v.number(), + feePct: vDnum(), + collRedeemed: v.array(vDnum()), + slippageTolerance: vDnum(), }, ); @@ -31,55 +31,63 @@ export type RedeemCollateralRequest = v.InferOutput; export const redeemCollateral: FlowDeclaration = { title: "Review & Send Transaction", Summary: () => null, + Details(ctx) { - const estimatedGains = useSimulatedBalancesChange(ctx); + const { amount, collRedeemed } = ctx.request; const branches = getBranches(); - const boldChange = estimatedGains.data?.find(({ symbol }) => symbol === WHITE_LABEL_CONFIG.tokens.mainToken.symbol)?.change; - const collChanges = estimatedGains.data?.filter(({ symbol }) => symbol !== WHITE_LABEL_CONFIG.tokens.mainToken.symbol); + const boldPrice = usePrice(WHITE_LABEL_CONFIG.tokens.mainToken.symbol); + const collPrices = useCollateralPrices(branches.map((b) => b.symbol)); + return ( <> , + + + + This is the estimated fee you will pay. The actual fee may be up to{" "} + {" "} + higher than this due to slippage. + + , ]} /> + , - - Estimated ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol} that will be redeemed. - , + + + + This is the estimated amount of {WHITE_LABEL_CONFIG.tokens.mainToken.symbol} you will pay. The actual amount may be slightly lower than this. + + , + boldPrice.data && , ]} /> - {branches.map(({ symbol }) => { - const collChange = collChanges?.find((change) => symbol === change.symbol)?.change; - const symbol_ = symbol === "ETH" ? "WETH" : symbol; + + {branches.map(({ branchId }, i) => { + const collateralToken = getCollToken(branchId); + const collateralTokenName = collateralToken.symbol === "ETH" ? "WETH" : collateralToken.name; + return ( 1 ? ` #${i + 1}` : "")} value={[ - , - - Estimated {symbol_} you will receive. - , + + + + This is the estimated amount of {collateralTokenName}{" "} + you will receive. The actual amount may be up to{" "} + {" "} + lower than this due to slippage. + + , + collRedeemed[branchId] && collPrices.data?.[branchId] && ( + + ), ]} /> ); @@ -87,37 +95,43 @@ export const redeemCollateral: FlowDeclaration = { ); }, + steps: { approve: { name: () => `Approve ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol}`, Status: TransactionStatus, + async commit({ request, writeContract }) { - const CollateralRegistry = getProtocolContract("CollateralRegistry"); + const RedemptionHelper = getProtocolContract("RedemptionHelper"); const BoldToken = getProtocolContract("BoldToken"); return writeContract({ ...BoldToken, functionName: "approve", - args: [CollateralRegistry.address, request.amount[0]], + args: [RedemptionHelper.address, request.amount[0]], }); }, async verify(ctx, hash) { await verifyTransaction(ctx.wagmiConfig, hash, ctx.isSafe); }, }, + redeemCollateral: { name: () => `Redeem ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol}`, Status: TransactionStatus, + async commit({ request, writeContract }) { - const CollateralRegistry = getProtocolContract("CollateralRegistry"); + const bold = dn.from(request.amount, 18)[0]; + const maxIterationsPerCollateral = BigInt(request.maxIterationsPerCollateral); + const maxFeePct = dn.add(request.feePct, request.slippageTolerance, 18)[0]; + const slippageFactor = dn.sub(DNUM_1, request.slippageTolerance); + const minCollRedeemed = request.collRedeemed.map((collRedeemed) => dn.mul(collRedeemed, slippageFactor, 18)[0]); + const RedemptionHelper = getProtocolContract("RedemptionHelper"); + return writeContract({ - ...CollateralRegistry, + ...RedemptionHelper, functionName: "redeemCollateral", - args: [ - request.amount[0], - 0n, - request.maxFee[0], - ], + args: [bold, maxIterationsPerCollateral, maxFeePct, minCollRedeemed], }); }, async verify(ctx, hash) { @@ -135,15 +149,15 @@ export const redeemCollateral: FlowDeclaration = { functionName: "allowance", args: [ ctx.account, - getProtocolContract("CollateralRegistry").address, + getProtocolContract("RedemptionHelper").address, ], }); + if (dn.gt(ctx.request.amount, dnum18(boldAllowance))) { steps.push("approve"); } steps.push("redeemCollateral"); - return steps; }, @@ -159,115 +173,3 @@ export const StoredBalancesChangeSchema = v.object({ change: vDnum(), })), }); - -export function useSimulatedBalancesChange({ - account, - request, -}: { - account: Address; - request: RedeemCollateralRequest; -}) { - const wagmiConfig = useWagmiConfig(); - return useQuery({ - queryKey: ["simulatedBalancesChange", account, jsonStringifyWithDnum(request)], - queryFn: async () => { - const CollateralRegistry = getProtocolContract("CollateralRegistry"); - const BoldToken = getProtocolContract("BoldToken"); - - let stored: v.InferOutput | null = null; - try { - stored = v.parse( - StoredBalancesChangeSchema, - jsonParseWithDnum( - localStorage.getItem( - `${LOCAL_STORAGE_PREFIX}:simulatedBalancesChange`, - ) ?? "", - ), - ); - } catch (_) { - stored = null; - } - - if (stored && stored.stringifiedRequest === jsonStringifyWithDnum(request)) { - return stored.balanceChanges; - } - - const [chain] = wagmiConfig.chains; - const [rpcUrl] = chain.rpcUrls.default.http; - const client = createPublicClient({ chain, transport: http(rpcUrl) }); - - const branches = getBranches(); - const branchesBalanceCalls = branches.map((branch) => ({ - to: branch.contracts.CollToken.address, - abi: branch.contracts.CollToken.abi, - functionName: "balanceOf" as const, - args: [account], - } as const)); - - const boldBalanceCall = { - to: BoldToken.address, - abi: BoldToken.abi, - functionName: "balanceOf" as const, - args: [account], - } as const; - - const simulation = await client.simulateCalls({ - account, - calls: [ - // 1. get balances before - boldBalanceCall, - ...branchesBalanceCalls, - - // 2. redeem - { - to: CollateralRegistry.address, - abi: CollateralRegistry.abi, - functionName: "redeemCollateral", - args: [request.amount[0], 0n, request.maxFee[0]], - }, - - // 3. get balances after - boldBalanceCall, - ...branchesBalanceCalls, - ], - - // This is needed to avoid a “nonce too low” error with certain RPCs - stateOverrides: [{ address: account, nonce: 0 }], - }); - - const getBalancesFromSimulated = (position: number) => { - return simulation.results - .slice(position, position + branches.length + 1) - .map((result, index) => { - const symbol = index === 0 ? WHITE_LABEL_CONFIG.tokens.mainToken.symbol : branches[index - 1]?.symbol; - return { - symbol, - balance: dnum18(result.data ?? 0n), - }; - }); - }; - - const balancesBefore = getBalancesFromSimulated(0); - const balancesAfter = getBalancesFromSimulated(branches.length + 2); - - const balanceChanges = balancesBefore.map((balanceBefore, index) => { - const balanceAfter = balancesAfter[index]; - if (!balanceAfter) throw new Error(); - return { - symbol: balanceBefore.symbol, - change: dn.sub(balanceAfter.balance, balanceBefore.balance), - }; - }); - - localStorage.setItem( - `${LOCAL_STORAGE_PREFIX}:simulatedBalancesChange`, - jsonStringifyWithDnum({ - stringifiedRequest: jsonStringifyWithDnum(request), - balanceChanges, - }), - ); - - return balanceChanges; - }, - }); -} diff --git a/frontend/app/src/tx-flows/shared.ts b/frontend/app/src/tx-flows/shared.ts index c11d166f..c9c4ae7c 100644 --- a/frontend/app/src/tx-flows/shared.ts +++ b/frontend/app/src/tx-flows/shared.ts @@ -39,7 +39,7 @@ export async function verifyTransaction( // safe tx ? waitForSafeTransaction(hash).then((txHash) => ( // return the same object than a non-safe tx - (waitForTransactionReceipt(wagmiConfig, { hash: txHash as `0x${string}` })) + waitForTransactionReceipt(wagmiConfig, { hash: txHash as `0x${string}` }) )) // normal tx : waitForTransactionReceipt(wagmiConfig, { @@ -56,11 +56,11 @@ export async function verifyTransaction( } export async function verifyBlockNumberIndexation(blockNumber: bigint) { - while (true) { + for (let i = 0;; ++i) { const indexedBlockNumber = await getIndexedBlockNumber(); - if (indexedBlockNumber >= blockNumber) { - break; - } - await sleep(1000); + if (indexedBlockNumber >= blockNumber) break; + + console.log(`Waiting for subgraph to catch up... (${blockNumber - indexedBlockNumber} blocks behind)`); + await sleep((2 ** Math.min(i, 4)) * 1000); } } diff --git a/frontend/app/src/tx-flows/unstakeDeposit.tsx b/frontend/app/src/tx-flows/unstakeDeposit.tsx index 67ac38e0..436817b3 100644 --- a/frontend/app/src/tx-flows/unstakeDeposit.tsx +++ b/frontend/app/src/tx-flows/unstakeDeposit.tsx @@ -1,8 +1,10 @@ +import type { UserAllocation, UserState } from "@/src/liquity-governance"; import type { FlowDeclaration } from "@/src/services/TransactionFlow"; +import type { Address } from "@/src/types"; import { Amount } from "@/src/comps/Amount/Amount"; import { StakePositionSummary } from "@/src/comps/StakePositionSummary/StakePositionSummary"; -import { getUserAllocatedInitiatives } from "@/src/liquity-governance"; +import { getUserAllocations, getUserStates } from "@/src/liquity-governance"; import { TransactionDetailsRow } from "@/src/screens/TransactionsScreen/TransactionsScreen"; import { TransactionStatus } from "@/src/screens/TransactionsScreen/TransactionStatus"; import { usePrice } from "@/src/services/Prices"; @@ -10,9 +12,72 @@ import { vDnum, vPositionStake } from "@/src/valibot-utils"; import * as dn from "dnum"; import * as v from "valibot"; import { WHITE_LABEL_CONFIG } from "@/src/white-label.config"; -import { encodeFunctionData } from "viem"; +import { Abi, encodeFunctionData } from "viem"; import { createRequestSchema, verifyTransaction } from "./shared"; +function calculateUnstakingStrategy( + userState: UserState, + userAllocations: UserAllocation[], + unstakeAmount: bigint, +): { + needsReallocation: boolean; + newAllocations?: Record; + withdrawFromUnallocated: bigint; + remainingStakedLQTY: bigint; +} { + const { unallocatedLQTY, stakedLQTY } = userState; + const remainingStakedLQTY = stakedLQTY - unstakeAmount; + const hasAllocations = userAllocations.length > 0; + + // Simple case: no allocations or sufficient unallocated LQTY + if (!hasAllocations || unallocatedLQTY >= unstakeAmount) { + return { + needsReallocation: false, + withdrawFromUnallocated: unstakeAmount, + remainingStakedLQTY, + }; + } + + // Complex case: need to reallocate with preserved percentages + const totalAllocatedLQTY = userAllocations.reduce( + (sum, alloc) => sum + alloc.voteLQTY + alloc.vetoLQTY, + 0n, + ); + + const allocationEntries = userAllocations + .map((alloc) => { + const allocatedAmount = alloc.voteLQTY + alloc.vetoLQTY; + const percentage = (allocatedAmount * 10n ** 18n) / totalAllocatedLQTY; + return { + address: alloc.initiative, + percentage, + vote: alloc.voteLQTY > alloc.vetoLQTY ? "for" as const : "against" as const, + }; + }) + .filter(({ percentage }) => percentage > 0n) + .sort((a, b) => a.percentage > b.percentage ? -1 : 1); + + let remainingLQTY = remainingStakedLQTY; + let remainingPercentage = allocationEntries.reduce((sum, { percentage }) => sum + percentage, 0n); + const newAllocations: Record = {}; + + for (const entry of allocationEntries) { + const { address, percentage, vote } = entry; + const amount = remainingLQTY * percentage / remainingPercentage; + + newAllocations[address] = { amount, vote }; + remainingLQTY -= amount; + remainingPercentage -= percentage; + } + + return { + needsReallocation: true, + newAllocations, + withdrawFromUnallocated: unallocatedLQTY, + remainingStakedLQTY, + }; +} + const RequestSchema = createRequestSchema( "unstakeDeposit", { @@ -24,6 +89,66 @@ const RequestSchema = createRequestSchema( export type UnstakeDepositRequest = v.InferOutput; +function generateUnstakeTransactions( + userState: UserState, + userAllocations: UserAllocation[], + unstakeAmount: bigint, + governanceAbi: Abi, +) { + const inputs: `0x${string}`[] = []; + const allocatedInitiatives = userAllocations + .filter(({ voteLQTY, vetoLQTY }) => (voteLQTY + vetoLQTY) > 0n) + .map(({ initiative }) => initiative); + + const isFullUnstake = unstakeAmount === userState.stakedLQTY; + + if (userAllocations.length > 0) { + if (isFullUnstake) { + inputs.push(encodeFunctionData({ + abi: governanceAbi, + functionName: "resetAllocations", + args: [allocatedInitiatives, true], + })); + } else { + const strategy = calculateUnstakingStrategy(userState, userAllocations, unstakeAmount); + if (strategy.needsReallocation && strategy.newAllocations) { + const initiativeAddresses = Object.keys(strategy.newAllocations) as Address[]; + const [votes, vetos] = initiativeAddresses.reduce( + ([v, ve], address, index) => { + const allocation = strategy.newAllocations![address]; + if (!allocation) return [v, ve]; + if (allocation.vote === "for") { + v[index] = allocation.amount; + } else if (allocation.vote === "against") { + ve[index] = allocation.amount; + } + return [v, ve]; + }, + [ + Array.from({ length: initiativeAddresses.length }).fill(0n), + Array.from({ length: initiativeAddresses.length }).fill(0n), + ], + ); + + inputs.push(encodeFunctionData({ + abi: governanceAbi, + functionName: "allocateLQTY", + args: [allocatedInitiatives, initiativeAddresses, votes, vetos], + })); + // else: Partial unstake with enough unallocated LQTY - preserve allocations + } + } + } + + inputs.push(encodeFunctionData({ + abi: governanceAbi, + functionName: "withdrawLQTY", + args: [unstakeAmount], + })); + + return inputs; +} + export const unstakeDeposit: FlowDeclaration = { title: "Review & Send Transaction", @@ -39,11 +164,15 @@ export const unstakeDeposit: FlowDeclaration = { Details({ request }) { const lqtyPrice = usePrice(WHITE_LABEL_CONFIG.tokens.governanceToken.symbol); + const isFullUnstake = dn.eq(request.lqtyAmount, request.stakePosition.deposit); + return ( = { Status: TransactionStatus, async commit(ctx) { const { Governance } = ctx.contracts; - const inputs: `0x${string}`[] = []; + const unstakeAmount = ctx.request.lqtyAmount[0]; - const allocatedInitiatives = await getUserAllocatedInitiatives( - ctx.wagmiConfig, - ctx.account, - ); - - // reset allocations if the user has any - if (allocatedInitiatives.length > 0) { - inputs.push(encodeFunctionData({ - abi: Governance.abi, - functionName: "resetAllocations", - args: [allocatedInitiatives, true], - })); - } + const [userState, userAllocations] = await Promise.all([ + getUserStates(ctx.wagmiConfig, ctx.account), + getUserAllocations(ctx.wagmiConfig, ctx.account), + ]); - // withdraw governance tokens - inputs.push(encodeFunctionData({ - abi: Governance.abi, - functionName: "withdrawLQTY", - args: [ctx.request.lqtyAmount[0]], - })); + const inputs = generateUnstakeTransactions( + userState, + userAllocations, + unstakeAmount, + Governance.abi, + ); return ctx.writeContract({ ...Governance, diff --git a/frontend/app/src/tx-flows/updateBorrowPosition.tsx b/frontend/app/src/tx-flows/updateBorrowPosition.tsx index 4f5c5cbc..bd6d043e 100644 --- a/frontend/app/src/tx-flows/updateBorrowPosition.tsx +++ b/frontend/app/src/tx-flows/updateBorrowPosition.tsx @@ -9,6 +9,8 @@ import { TransactionDetailsRow } from "@/src/screens/TransactionsScreen/Transact import { TransactionStatus } from "@/src/screens/TransactionsScreen/TransactionStatus"; import { usePrice } from "@/src/services/Prices"; import { vDnum, vPositionLoanCommited } from "@/src/valibot-utils"; +import { css } from "@/styled-system/css"; +import { InfoTooltip } from "@liquity2/uikit"; import * as dn from "dnum"; import { match, P } from "ts-pattern"; import * as v from "valibot"; @@ -113,13 +115,25 @@ export const updateBorrowPosition: FlowDeclaration suffix={` ${WHITE_LABEL_CONFIG.tokens.mainToken.symbol}`} />, upfrontFeeData.data?.upfrontFee && dn.gt(upfrontFeeData.data.upfrontFee, 0n) && ( - + className={css({ + display: "flex", + alignItems: "center", + gap: 4, + })} + > + + + This fee is charged when you open a new loan or increase your debt. It corresponds to 7 days of + average interest for the respective collateral asset. + +
), ]} /> diff --git a/frontend/app/src/tx-flows/updateLeveragePosition.tsx b/frontend/app/src/tx-flows/updateLeveragePosition.tsx index 1c6e7b2d..72c26ba1 100644 --- a/frontend/app/src/tx-flows/updateLeveragePosition.tsx +++ b/frontend/app/src/tx-flows/updateLeveragePosition.tsx @@ -3,59 +3,69 @@ import type { FlowDeclaration } from "@/src/services/TransactionFlow"; import { Amount } from "@/src/comps/Amount/Amount"; import { MAX_UPFRONT_FEE } from "@/src/constants"; -import { dnum18 } from "@/src/dnum-utils"; +import { dnum18, DNUM_0 } from "@/src/dnum-utils"; import { fmtnum } from "@/src/formatting"; -import { getLeverDownTroveParams, getLeverUpTroveParams } from "@/src/liquity-leverage"; +import { useSlippageRefund } from "@/src/liquity-leverage"; import { getBranch, getCollToken, usePredictAdjustTroveUpfrontFee } from "@/src/liquity-utils"; import { LoanCard } from "@/src/screens/TransactionsScreen/LoanCard"; import { TransactionDetailsRow } from "@/src/screens/TransactionsScreen/TransactionsScreen"; import { TransactionStatus } from "@/src/screens/TransactionsScreen/TransactionStatus"; import { usePrice } from "@/src/services/Prices"; import { vDnum, vPositionLoanCommited } from "@/src/valibot-utils"; -import { ADDRESS_ZERO } from "@liquity2/uikit"; +import { css } from "@/styled-system/css"; +import { ADDRESS_ZERO, InfoTooltip } from "@liquity2/uikit"; import * as dn from "dnum"; import { match, P } from "ts-pattern"; import * as v from "valibot"; import { maxUint256 } from "viem"; +import type { BranchId, TroveId } from "../types"; import { createRequestSchema, verifyTransaction } from "./shared"; import { WHITE_LABEL_CONFIG } from "@/src/white-label.config"; const RequestSchema = createRequestSchema( "updateLeveragePosition", { - depositChange: v.union([v.null(), vDnum()]), - // set to null to indicate no multiply change - leverageFactorChange: v.union([ - v.null(), - v.tuple([ - v.number(), // prev multiply - v.number(), // new multiply - ]), - ]), - prevLoan: vPositionLoanCommited(), loan: vPositionLoanCommited(), + prevLoan: vPositionLoanCommited(), + depositChange: v.nullable(vDnum()), + debtChange: v.nullable(vDnum()), + leverageFactorChange: v.tuple([v.nullable(v.number()), v.number()]), + + leverage: v.nullable( + v.union([ + v.object({ + direction: v.literal("up"), + flashloanAmount: vDnum(), + boldAmount: vDnum(), + }), + v.object({ + direction: v.literal("down"), + flashloanAmount: vDnum(), + minBoldAmount: vDnum(), + }), + ]), + ), }, ); export type UpdateLeveragePositionRequest = v.InferOutput; function useUpfrontFeeData( - loan: UpdateLeveragePositionRequest["loan"], - prevLoan: UpdateLeveragePositionRequest["prevLoan"], + branchId: BranchId, + troveId: TroveId, + debtChange: dn.Dnum | null, ) { - const debtChange = dn.sub(loan.borrowed, prevLoan.borrowed); - const isBorrowing = dn.gt(debtChange, 0); + const isBorrowing = debtChange && dn.gt(debtChange, DNUM_0); const upfrontFee = usePredictAdjustTroveUpfrontFee( - loan.branchId, - loan.troveId, - isBorrowing ? debtChange : [0n, 18], + branchId, + troveId, + isBorrowing ? debtChange : DNUM_0, ); return { ...upfrontFee, data: !upfrontFee.data ? null : { - isBorrowing, debtChangeWithFee: isBorrowing ? dn.add(debtChange, upfrontFee.data) : debtChange, @@ -68,9 +78,9 @@ export const updateLeveragePosition: FlowDeclaration() .with({ status: "error" }, () => "error") @@ -94,21 +104,22 @@ export const updateLeveragePosition: FlowDeclaration ); }, - Details({ request }) { - const { loan, prevLoan, depositChange, leverageFactorChange } = request; + Details({ request, account, steps }) { + const { loan, depositChange, debtChange, leverageFactorChange } = request; const branch = getBranch(loan.branchId); const collateral = getCollToken(branch.id); const collPrice = usePrice(collateral.symbol); - const upfrontFeeData = useUpfrontFeeData(loan, prevLoan); + const upfrontFeeData = useUpfrontFeeData(loan.branchId, loan.troveId, debtChange); + const slippageRefund = useSlippageRefund(loan.branchId, account, steps); const debtChangeWithFee = upfrontFeeData.data?.debtChangeWithFee; - const isBorrowing = upfrontFeeData.data?.isBorrowing; return ( <> @@ -126,50 +137,102 @@ export const updateLeveragePosition: FlowDeclaration, ]} /> )} - {leverageFactorChange && ( - - {fmtnum(leverageFactorChange[1] - leverageFactorChange[0], { - digits: 2, - signDisplay: "exceptZero", - })}x - , -
- {fmtnum(leverageFactorChange[1], 2)}x multiply -
, - ]} - /> - )} + {leverageFactorChange[0] + ? ( + <> + {fmtnum(leverageFactorChange[1] - (leverageFactorChange[0]), { + digits: 1, + signDisplay: "exceptZero", + })}x + + ) + : <>N/A} + , +
+ {fmtnum(leverageFactorChange[1], 1)}x +
, + ]} + /> + , upfrontFeeData.data?.upfrontFee && dn.gt(upfrontFeeData.data.upfrontFee, 0) && ( - + className={css({ + display: "flex", + alignItems: "center", + gap: 4, + })} + > + + + This fee is charged when you open a new loan or increase your debt. It corresponds to 7 days of + average interest for the respective collateral asset. + + ), ]} /> + {slippageRefund.data && ( + + Slippage refund + + Excess collateral was needed to create the desired exposure and accommodate for slippage. This is the + left over amount that has been refunded to your wallet. + + + } + value={[ + , + collPrice.data && ( + + ), + ]} + /> + )} ); }, @@ -211,7 +274,7 @@ export const updateLeveragePosition: FlowDeclaration "Increase Deposit", + name: () => "Deposit", Status: TransactionStatus, async commit(ctx) { @@ -245,7 +308,7 @@ export const updateLeveragePosition: FlowDeclaration "Decrease Deposit", + name: () => "Withdraw", Status: TransactionStatus, async commit(ctx) { @@ -283,30 +346,20 @@ export const updateLeveragePosition: FlowDeclaration "Increase Multiplier", + name: () => "Multiply", Status: TransactionStatus, async commit(ctx) { - if (!ctx.request.leverageFactorChange) { - throw new Error("Invalid step: leverageFactorChange is required with leverUpTrove"); - } - - const params = await getLeverUpTroveParams( - ctx.request.loan.branchId, - ctx.request.loan.troveId, - ctx.request.leverageFactorChange[1], - ctx.wagmiConfig, - ); - if (!params) { - throw new Error("Couldn't fetch trove lever up params"); + if (ctx.request.leverage?.direction !== "up") { + throw new Error("Invalid step: leverUpTrove"); } const branch = getBranch(ctx.request.loan.branchId); const args = [{ troveId: BigInt(ctx.request.loan.troveId), - flashLoanAmount: params.flashLoanAmount, - boldAmount: params.effectiveBoldAmount, + flashLoanAmount: dn.from(ctx.request.leverage.flashloanAmount, 18)[0], + boldAmount: dn.from(ctx.request.leverage.boldAmount, 18)[0], maxUpfrontFee: MAX_UPFRONT_FEE, }] as const; @@ -333,30 +386,20 @@ export const updateLeveragePosition: FlowDeclaration "Decrease Multiplier", + name: () => "Multiply", Status: TransactionStatus, async commit(ctx) { - if (!ctx.request.leverageFactorChange) { - throw new Error("Invalid step: leverageFactorChange is required with leverDownTrove"); - } - - const params = await getLeverDownTroveParams( - ctx.request.loan.branchId, - ctx.request.loan.troveId, - ctx.request.leverageFactorChange[1], - ctx.wagmiConfig, - ); - if (!params) { - throw new Error("Couldn't fetch trove lever down params"); + if (ctx.request.leverage?.direction !== "down") { + throw new Error("Invalid step: leverDownTrove"); } const branch = getBranch(ctx.request.loan.branchId); const args = [{ troveId: BigInt(ctx.request.loan.troveId), - flashLoanAmount: params.flashLoanAmount, - minBoldAmount: params.minBoldAmount, + flashLoanAmount: dn.from(ctx.request.leverage.flashloanAmount, 18)[0], + minBoldAmount: dn.from(ctx.request.leverage.minBoldAmount)[0], }] as const; if (branch.symbol === "ETH") { @@ -381,7 +424,7 @@ export const updateLeveragePosition: FlowDeclaration oldLeverage ? "leverUpTrove" : "leverDownTrove"); + if (leverage?.direction === "up") { + steps.push("leverUpTrove"); } return steps; diff --git a/frontend/app/src/tx-flows/updateLoanInterestRate.tsx b/frontend/app/src/tx-flows/updateLoanInterestRate.tsx index 25021703..01e59414 100644 --- a/frontend/app/src/tx-flows/updateLoanInterestRate.tsx +++ b/frontend/app/src/tx-flows/updateLoanInterestRate.tsx @@ -5,6 +5,7 @@ import { Amount } from "@/src/comps/Amount/Amount"; import { dnum18 } from "@/src/dnum-utils"; import { WHITE_LABEL_CONFIG } from "@/src/white-label.config"; import { fmtnum } from "@/src/formatting"; +import { useDelegateDisplayName } from "@/src/liquity-delegate"; import { getBranch, getTroveOperationHints, @@ -29,6 +30,7 @@ const RequestSchema = createRequestSchema( { prevLoan: vPositionLoanCommited(), loan: vPositionLoanCommited(), + leverageMode: v.boolean(), }, ); @@ -38,7 +40,7 @@ export const updateLoanInterestRate: FlowDeclaration, + ,
{delegate.isLoading ? "Loading…" @@ -160,7 +164,7 @@ export const updateLoanInterestRate: FlowDeclaration - +
,
(errorMessage: string): T { throw new Error(errorMessage); } + +export const zipWith = (f: (t: T, u: U) => V) => (ts: T[], us: U[]) => + ts.slice(0, us.length).map((t, i) => f(t, us[i]!)); diff --git a/frontend/app/src/valibot-utils.ts b/frontend/app/src/valibot-utils.ts index 58fd68f9..d2525d7e 100644 --- a/frontend/app/src/valibot-utils.ts +++ b/frontend/app/src/valibot-utils.ts @@ -185,26 +185,31 @@ const VPositionLoanBase = v.object({ branchId: vBranchId(), deposit: vDnum(), interestRate: vDnum(), - status: v.union([ - v.literal("active"), - v.literal("closed"), - v.literal("liquidated"), - v.literal("redeemed"), - ]), }); export function vPositionLoanCommited() { return v.intersect([ VPositionLoanBase, v.object({ + status: v.union([ + v.literal("active"), + v.literal("closed"), + v.literal("liquidated"), + v.literal("redeemed"), + ]), troveId: vTroveId(), createdAt: v.number(), lastUserActionAt: v.number(), + updatedAt: v.number(), isZombie: v.boolean(), - indexedDebt: vDnum(), + recordedDebt: vDnum(), redemptionCount: v.number(), redeemedColl: vDnum(), redeemedDebt: vDnum(), + liquidatedColl: v.nullish(vDnum(), null), + liquidatedDebt: v.nullish(vDnum(), null), + collSurplus: v.nullish(vDnum(), null), + priceAtLiquidation: v.nullish(vDnum(), null), }), ]); } @@ -213,6 +218,7 @@ export function vPositionLoanUncommited() { return v.intersect([ VPositionLoanBase, v.object({ + status: v.literal("active"), troveId: v.null(), }), ]); diff --git a/frontend/app/src/white-label.config.ts b/frontend/app/src/white-label.config.ts index 80028f62..333db877 100644 --- a/frontend/app/src/white-label.config.ts +++ b/frontend/app/src/white-label.config.ts @@ -233,6 +233,7 @@ export const WHITE_LABEL_CONFIG = { // Navigation configuration navigation: { showBorrow: true, + showMultiply: false, showEarn: true, showStake: false, }, diff --git a/frontend/app/src/ybold.ts b/frontend/app/src/ybold.ts new file mode 100644 index 00000000..058b3762 --- /dev/null +++ b/frontend/app/src/ybold.ts @@ -0,0 +1,5 @@ +import { YBOLD } from "@/src/env"; + +export function isYboldEnabled() { + return YBOLD; +} diff --git a/frontend/uikit/src/InputField/InputField.tsx b/frontend/uikit/src/InputField/InputField.tsx index 6d019150..d5143e61 100644 --- a/frontend/uikit/src/InputField/InputField.tsx +++ b/frontend/uikit/src/InputField/InputField.tsx @@ -12,7 +12,7 @@ const diffSpringConfig = { friction: 120, }; -type Drawer = { +export type Drawer = { mode: "error" | "loading" | "success" | "warning"; message: ReactNode; autoClose?: number; diff --git a/frontend/uikit/src/Radio/Radio.tsx b/frontend/uikit/src/Radio/Radio.tsx index 98125583..0953f0ec 100644 --- a/frontend/uikit/src/Radio/Radio.tsx +++ b/frontend/uikit/src/Radio/Radio.tsx @@ -56,7 +56,7 @@ export function Radio({ firstRender.current = false; }, [checked, inRadioGroup]); - const checkTransition = useTransition(checked, { + const checkTransition = useTransition([checked, disabled], { config: { mass: 1, tension: 2400, diff --git a/frontend/uikit/src/Slider/Slider.tsx b/frontend/uikit/src/Slider/Slider.tsx index 08805a1c..06c75c2a 100644 --- a/frontend/uikit/src/Slider/Slider.tsx +++ b/frontend/uikit/src/Slider/Slider.tsx @@ -26,6 +26,7 @@ export function Slider({ disabled, gradient, gradientMode = "low-to-high", + handleColor, keyboardStep, onChange, onDragEnd, @@ -35,6 +36,7 @@ export function Slider({ disabled?: boolean; gradient?: [number, number]; gradientMode?: GradientMode; + handleColor?: 0 | 1 | 2; chart?: Chart; keyboardStep?: (value: number, direction: Direction) => number; onChange: (value: number) => void; @@ -55,7 +57,7 @@ export function Slider({ const mainElement = useRef(null); const document = useRef(null); - const getRect = useCallback(() => { + const getRect /* LOL */ = useCallback(() => { const now = Date.now(); // Cache the rect if the last poll was less than a second ago @@ -140,11 +142,13 @@ export function Slider({ to: { value, handleColor: gradient && chart - ? value <= gradient[0] - ? gradientColors[0] - : value <= gradient[1] - ? gradientColors[2] - : gradientColors[4] + ? handleColor !== undefined + ? gradientColors[handleColor * 2] + : (value <= gradient[0] + ? gradientColors[0] + : value <= gradient[1] + ? gradientColors[2] + : gradientColors[4]) : token("colors.controlSurface"), }, }); diff --git a/frontend/uikit/src/icons/IconCheckmark.tsx b/frontend/uikit/src/icons/IconCheckmark.tsx new file mode 100644 index 00000000..4f4ac6f4 --- /dev/null +++ b/frontend/uikit/src/icons/IconCheckmark.tsx @@ -0,0 +1,17 @@ +// this file was generated by scripts/update-icons.ts +// please do not edit it manually + +export function IconCheckmark({ + size = 24, +}: { + size?: number; +}) { + return ( + + + + ); +} diff --git a/frontend/uikit/src/icons/index.ts b/frontend/uikit/src/icons/index.ts index 5ea8a420..81059022 100644 --- a/frontend/uikit/src/icons/index.ts +++ b/frontend/uikit/src/icons/index.ts @@ -5,6 +5,7 @@ export { IconAccount } from "./IconAccount"; export { IconArrowBack } from "./IconArrowBack"; export { IconArrowRight } from "./IconArrowRight"; export { IconBorrow } from "./IconBorrow"; +export { IconCheckmark } from "./IconCheckmark"; export { IconChevronDown } from "./IconChevronDown"; export { IconDiscord } from "./IconDiscord"; export { IconX } from "./IconX"; diff --git a/frontend/uikit/src/icons/svg/checkmark.svg b/frontend/uikit/src/icons/svg/checkmark.svg new file mode 100644 index 00000000..496c9c75 --- /dev/null +++ b/frontend/uikit/src/icons/svg/checkmark.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/uikit/src/index.ts b/frontend/uikit/src/index.ts index 5fe8a0a1..829f47a2 100644 --- a/frontend/uikit/src/index.ts +++ b/frontend/uikit/src/index.ts @@ -20,7 +20,7 @@ export { HFlex } from "./Flex/HFlex"; export { VFlex } from "./Flex/VFlex"; export { FormField } from "./FormField/FormField"; export { TextInput } from "./Input/TextInput"; -export { InputField } from "./InputField/InputField"; +export { type Drawer, InputField } from "./InputField/InputField"; export { LoadingSurface } from "./LoadingSurface/LoadingSurface"; export { Modal } from "./Modal/Modal"; export { PillButton } from "./PillButton/PillButton"; diff --git a/frontend/uikit/src/token-icons/ybold.svg b/frontend/uikit/src/token-icons/ybold.svg new file mode 100644 index 00000000..397760f8 --- /dev/null +++ b/frontend/uikit/src/token-icons/ybold.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97a69fb3..2c099198 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,6 +174,9 @@ importers: '@graphql-codegen/schema-ast': specifier: ^4.1.0 version: 4.1.0(graphql@16.11.0) + '@next/env': + specifier: ^15.5.2 + version: 15.5.2 '@pandacss/dev': specifier: ^0.54.0 version: 0.54.0(jsdom@26.1.0)(typescript@5.8.3) @@ -4268,6 +4271,10 @@ packages: resolution: {integrity: sha512-ZkdYzBseS6UjYzz6ylVKPOK+//zLWvD6Ta+vpoye8cW11AjiQjGYVibF0xuvT4L0iJfAPfZLFidaEzAOywyOAQ==} dev: false + /@next/env@15.5.2: + resolution: {integrity: sha512-Qe06ew4zt12LeO6N7j8/nULSOe3fMXE4dM6xgpBQNvdzyK1sv5y4oAP3bq4LamrvGCZtmRYnW8URFCeX5nFgGg==} + dev: true + /@next/swc-darwin-arm64@15.3.4: resolution: {integrity: sha512-z0qIYTONmPRbwHWvpyrFXJd5F9YWLCsw3Sjrzj2ZvMYy9NPQMPZ1NjOJh4ojr4oQzcGYwgJKfidzehaNa1BpEg==} engines: {node: '>= 10'} diff --git a/subgraph/schema.graphql b/subgraph/schema.graphql index ce8e82df..048b20ff 100644 --- a/subgraph/schema.graphql +++ b/subgraph/schema.graphql @@ -1,4 +1,4 @@ -type Collateral @entity(immutable: false) { +type Collateral @entity(immutable: true) { id: ID! # "collIndex", e.g. "0" collIndex: Int! minCollRatio: BigInt! @@ -6,7 +6,7 @@ type Collateral @entity(immutable: false) { addresses: CollateralAddresses! @derivedFrom(field: "collateral") } -type CollateralAddresses @entity(immutable: true) { +type CollateralAddresses @entity(immutable: false) { id: ID! # "collIndex", e.g. "0" collateral: Collateral! borrowerOperations: Bytes! @@ -15,6 +15,7 @@ type CollateralAddresses @entity(immutable: true) { token: Bytes! troveManager: Bytes! troveNft: Bytes! + collSurplusPool: Bytes } type InterestRateBracket @entity(immutable: false) { @@ -54,6 +55,10 @@ type Trove @entity(immutable: false) { redemptionCount: Int! redeemedColl: BigInt! redeemedDebt: BigInt! + liquidatedColl: BigInt + liquidatedDebt: BigInt + collSurplus: BigInt + priceAtLiquidation: BigInt } type BorrowerInfo @entity(immutable: false) { @@ -61,6 +66,8 @@ type BorrowerInfo @entity(immutable: false) { nextOwnerIndexes: [Int!]! troves: Int! trovesByCollateral: [Int!]! + collSurplusBalance: [BigInt!]! + lastCollSurplusClaimAt: [BigInt!]! # a zero element means a claim has never been made in that branch } type InterestBatch @entity(immutable: false) { diff --git a/subgraph/src/BoldToken.mapping.ts b/subgraph/src/BoldToken.mapping.ts index 5653303b..356ad8de 100644 --- a/subgraph/src/BoldToken.mapping.ts +++ b/subgraph/src/BoldToken.mapping.ts @@ -60,18 +60,11 @@ export function handleCollateralRegistryAddressChanged(event: CollateralRegistry let tokenAddress = Address.fromBytes(registry.getToken(BigInt.fromI32(index))); let troveManagerAddress = Address.fromBytes(registry.getTroveManager(BigInt.fromI32(index))); - if (tokenAddress.toHex() === Address.zero().toHex() || troveManagerAddress.toHex() === Address.zero().toHex()) { - break; - } - - // we use the token address as the id for the collateral - if (!Collateral.load(tokenAddress.toHexString())) { - addCollateral( - index, - totalCollaterals, - tokenAddress, - troveManagerAddress, - ); - } + addCollateral( + index, + totalCollaterals, + tokenAddress, + troveManagerAddress, + ); } -} \ No newline at end of file +} diff --git a/subgraph/src/CollSurplusPool.mapping.ts b/subgraph/src/CollSurplusPool.mapping.ts new file mode 100644 index 00000000..a25f532b --- /dev/null +++ b/subgraph/src/CollSurplusPool.mapping.ts @@ -0,0 +1,97 @@ +import { Address, BigInt, Bytes, dataSource, DataSourceContext } from "@graphprotocol/graph-ts"; +import { BorrowerInfo, CollateralAddresses } from "../generated/schema"; +import { CollSurplusPool as CollSurplusPoolTemplate } from "../generated/templates"; +import { CollBalanceUpdated as CollBalanceUpdatedEvent } from "../generated/templates/CollSurplusPool/CollSurplusPool"; +import { TroveOperation as TroveOperationEvent } from "../generated/templates/TroveManager/TroveManager"; +import { decodeAddress, decodeUint256 } from "./shared/decoding"; + +const COLL_BALANCE_UPDATED_TOPIC = Bytes.fromHexString( + // keccak256("CollBalanceUpdated(address,uint256)") + "0xf0393a34d05e6567686ad4e097f9d9d2781565957394f1f0d984e5d8e6378f20", +); + +function getCollSurplusBalanceChange(account: Address, newBalance: BigInt): BigInt { + let borrowerInfoId = account.toHexString(); + let borrowerInfo = BorrowerInfo.load(borrowerInfoId); + + if (!borrowerInfo) { + throw new Error("BorrowerInfo not found: " + borrowerInfoId); + } + + let collIndex = dataSource.context().getI32("collIndex"); + let collSurplusBalance = borrowerInfo.collSurplusBalance; + let balanceChange = newBalance.minus(collSurplusBalance[collIndex]); + collSurplusBalance[collIndex] = newBalance; + borrowerInfo.collSurplusBalance = collSurplusBalance; + borrowerInfo.save(); + + return balanceChange; +} + +export function getCollSurplusFrom(troveOperationEvent: TroveOperationEvent): BigInt { + let receipt = troveOperationEvent.receipt; + + if (!receipt) { + throw new Error("Missing TX receipt"); + } + + let collBalanceUpdatedLogIndex = -1; + + for (let i = 0; i < receipt.logs.length; ++i) { + if (receipt.logs[i].logIndex.equals(troveOperationEvent.logIndex.minus(BigInt.fromI32(4)))) { + if (receipt.logs[i].topics.length > 0 && receipt.logs[i].topics[0].equals(COLL_BALANCE_UPDATED_TOPIC)) { + collBalanceUpdatedLogIndex = i; + } + break; + } + } + + if (collBalanceUpdatedLogIndex < 0) { + return BigInt.zero(); + } + + let collBalanceUpdatedLog = receipt.logs[collBalanceUpdatedLogIndex]; + let collId = dataSource.context().getString("collId"); + let addresses = CollateralAddresses.load(collId); + + if (!addresses) { + throw new Error("CollateralAddresses not found: " + collId); + } + + // XXX extremely ugly hack: there's no easy way to get the CollSurplusPool address, + // so we lazily set it here, and more importantly: instantiate a data source for it + if (!addresses.collSurplusPool) { + let context = new DataSourceContext(); + context.setI32("collIndex", dataSource.context().getI32("collIndex")); + CollSurplusPoolTemplate.createWithContext(collBalanceUpdatedLog.address, context); + + addresses.collSurplusPool = collBalanceUpdatedLog.address; + addresses.save(); + } + + return getCollSurplusBalanceChange( + decodeAddress(collBalanceUpdatedLog.topics[1]).toAddress(), + decodeUint256(collBalanceUpdatedLog.data).toBigInt(), + ); +} + +export function handleCollBalanceUpdated(event: CollBalanceUpdatedEvent): void { + // Top-ups are handled by `getCollSurplusFrom(troveOperationEvent)` + if (event.params._newBalance.notEqual(BigInt.zero())) return; + + let borrowerInfoId = event.params._account.toHexString(); + let borrowerInfo = BorrowerInfo.load(borrowerInfoId); + + if (!borrowerInfo) { + throw new Error("BorrowerInfo not found: " + borrowerInfoId); + } + + let collIndex = dataSource.context().getI32("collIndex"); + let collSurplusBalance = borrowerInfo.collSurplusBalance; + let lastCollSurplusClaimAt = borrowerInfo.lastCollSurplusClaimAt; + collSurplusBalance[collIndex] = BigInt.zero(); + lastCollSurplusClaimAt[collIndex] = event.block.timestamp; + borrowerInfo.collSurplusBalance = collSurplusBalance; + borrowerInfo.lastCollSurplusClaimAt = lastCollSurplusClaimAt; + borrowerInfo.save(); +} diff --git a/subgraph/src/TroveManager.mapping.ts b/subgraph/src/TroveManager.mapping.ts index cb8ed5b8..4bb2ba2f 100644 --- a/subgraph/src/TroveManager.mapping.ts +++ b/subgraph/src/TroveManager.mapping.ts @@ -1,4 +1,4 @@ -import { Address, BigInt, Bytes, dataSource, ethereum } from "@graphprotocol/graph-ts"; +import { BigInt, Bytes, dataSource, ethereum } from "@graphprotocol/graph-ts"; import { InterestBatch, InterestRateBracket, Trove } from "../generated/schema"; import { BatchedTroveUpdated as BatchedTroveUpdatedEvent, @@ -6,6 +6,8 @@ import { TroveOperation as TroveOperationEvent, TroveUpdated as TroveUpdatedEvent, } from "../generated/templates/TroveManager/TroveManager"; +import { getCollSurplusFrom } from "./CollSurplusPool.mapping"; +import { decodeAddress, decodeUint256, decodeUint8 } from "./shared/decoding"; // see Operation enum in // contracts/src/Interfaces/ITroveEvents.sol @@ -26,31 +28,10 @@ const FLASH_LOAN_TOPIC = Bytes.fromHexString( "0x0d7d75e01ab95780d3cd1c8ec0dd6c2ce19e3a20427eec8bf53283b6fb8e95f0", ); -function decodeAddress(data: Bytes, i: i32 = 0): ethereum.Value { - return ethereum.Value.fromAddress( - Address.fromBytes( - Bytes.fromUint8Array( - data.subarray(i * 32 + 12, i * 32 + 32), - ), - ), - ); -} - -function decodeUint8(data: Bytes, i: i32 = 0): ethereum.Value { - return ethereum.Value.fromI32( - data[i * 32 + 31], - ); -} - -function decodeUint256(data: Bytes, i: i32 = 0): ethereum.Value { - return ethereum.Value.fromUnsignedBigInt( - BigInt.fromUnsignedBytes( - Bytes.fromUint8Array( - data.subarray(i * 32, i * 32 + 32).reverse(), - ), - ), - ); -} +const LIQUIDATION_TOPIC = Bytes.fromHexString( + // keccak256("Liquidation(uint256,uint256,uint256,uint256,uint256,uint256,uint256,uint256,uint256,uint256)") + "0x7243af9a1cff94d3429b2ee00b78c1c10589259f20dc167cb67704f38f9e824e", +); function getBatchUpdatedEventFrom(batchedTroveUpdatedEvent: BatchedTroveUpdatedEvent): BatchUpdatedEvent { let receipt = batchedTroveUpdatedEvent.receipt; @@ -95,6 +76,33 @@ function getBatchUpdatedEventFrom(batchedTroveUpdatedEvent: BatchedTroveUpdatedE ); } +function getPriceAtLiquidationFrom(troveOperationEvent: TroveOperationEvent): BigInt { + let receipt = troveOperationEvent.receipt; + + if (!receipt) { + throw new Error("Missing TX receipt"); + } + + let liquidationLogIndex = -1; + + for (let i = 0; i < receipt.logs.length; ++i) { + if ( + receipt.logs[i].logIndex.gt(troveOperationEvent.logIndex) + && receipt.logs[i].topics.length > 0 + && receipt.logs[i].topics[0].equals(LIQUIDATION_TOPIC) + ) { + liquidationLogIndex = i; + break; + } + } + + if (liquidationLogIndex < 0) { + throw new Error("Missing Liquidation log"); + } + + return decodeUint256(receipt.logs[liquidationLogIndex].data, 9).toBigInt(); +} + export function handleTroveUpdated(event: TroveUpdatedEvent): void { let collId = dataSource.context().getString("collId"); let troveId = event.params._troveId; @@ -174,6 +182,7 @@ export function handleTroveOperation(event: TroveOperationEvent): void { // Opening if (operation === OP_OPEN_TROVE || operation === OP_OPEN_TROVE_AND_JOIN_BATCH) { trove.createdAt = timestamp; + trove.mightBeLeveraged = inferLeverage(event); } // Closing @@ -202,11 +211,10 @@ export function handleTroveOperation(event: TroveOperationEvent): void { // Liquidation if (operation === OP_LIQUIDATE) { trove.status = "liquidated"; - } - - // Infer leverage flag on opening & adjustment - if (operation === OP_OPEN_TROVE || operation === OP_OPEN_TROVE_AND_JOIN_BATCH || operation === OP_ADJUST_TROVE) { - trove.mightBeLeveraged = inferLeverage(event); + trove.liquidatedColl = event.params._collChangeFromOperation.neg(); + trove.liquidatedDebt = event.params._debtChangeFromOperation.neg(); + trove.collSurplus = getCollSurplusFrom(event); + trove.priceAtLiquidation = getPriceAtLiquidationFrom(event); } trove.save(); diff --git a/subgraph/src/TroveNFT.mapping.ts b/subgraph/src/TroveNFT.mapping.ts index f76210b4..e8732b3f 100644 --- a/subgraph/src/TroveNFT.mapping.ts +++ b/subgraph/src/TroveNFT.mapping.ts @@ -71,6 +71,8 @@ function updateBorrowerTrovesCount(delta: i32, borrower: Bytes, collIndex: i32): borrowerInfo.troves = 0; borrowerInfo.trovesByCollateral = (new Array(collateralsCount)).fill(0); borrowerInfo.nextOwnerIndexes = (new Array(collateralsCount)).fill(0); + borrowerInfo.collSurplusBalance = (new Array(collateralsCount)).fill(BigInt.zero()); + borrowerInfo.lastCollSurplusClaimAt = (new Array(collateralsCount)).fill(BigInt.zero()); } // track the amount of troves per collateral diff --git a/subgraph/src/shared/decoding.ts b/subgraph/src/shared/decoding.ts new file mode 100644 index 00000000..db76f08b --- /dev/null +++ b/subgraph/src/shared/decoding.ts @@ -0,0 +1,27 @@ +import { Address, BigInt, Bytes, ethereum } from "@graphprotocol/graph-ts"; + +export function decodeAddress(data: Bytes, i: i32 = 0): ethereum.Value { + return ethereum.Value.fromAddress( + Address.fromBytes( + Bytes.fromUint8Array( + data.subarray(i * 32 + 12, i * 32 + 32), + ), + ), + ); +} + +export function decodeUint8(data: Bytes, i: i32 = 0): ethereum.Value { + return ethereum.Value.fromI32( + data[i * 32 + 31], + ); +} + +export function decodeUint256(data: Bytes, i: i32 = 0): ethereum.Value { + return ethereum.Value.fromUnsignedBigInt( + BigInt.fromUnsignedBytes( + Bytes.fromUint8Array( + data.subarray(i * 32, i * 32 + 32).reverse(), + ), + ), + ); +} diff --git a/subgraph/subgraph.yaml b/subgraph/subgraph.yaml index 18c7458e..87aaab64 100644 --- a/subgraph/subgraph.yaml +++ b/subgraph/subgraph.yaml @@ -80,6 +80,7 @@ templates: - InterestBatch - BorrowerInfo - Collateral + - CollateralAddresses abis: - name: TroveManager file: ../contracts/out/TroveManager.sol/TroveManager.json @@ -119,3 +120,21 @@ templates: eventHandlers: - event: Transfer(indexed address,indexed address,indexed uint256) handler: handleTransfer + - name: CollSurplusPool + kind: ethereum/contract + network: mainnet + source: + abi: CollSurplusPool + mapping: + kind: ethereum/events + apiVersion: 0.0.9 + language: wasm/assemblyscript + file: ./src/CollSurplusPool.mapping.ts + entities: + - BorrowerInfo + abis: + - name: CollSurplusPool + file: ../contracts/out/CollSurplusPool.sol/CollSurplusPool.json + eventHandlers: + - event: CollBalanceUpdated(indexed address,uint256) + handler: handleCollBalanceUpdated