diff --git a/contracts/utils/deployment-manifest-to-app-env.ts b/contracts/utils/deployment-manifest-to-app-env.ts index 5756109c0..2b54c3367 100644 --- a/contracts/utils/deployment-manifest-to-app-env.ts +++ b/contracts/utils/deployment-manifest-to-app-env.ts @@ -30,12 +30,23 @@ 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, boldToken: ZAddress, hintHelpers: ZAddress, multiTroveGetter: ZAddress, + exchangeHelpers: ZAddress, + + governance: z.object({ + LQTYToken: ZAddress, + LQTYStaking: ZAddress.default(ZERO_ADDRESS), + governance: ZAddress, + uniV4DonationsInitiative: ZAddress, + curveV2GaugeRewardsInitiative: ZAddress, + }), branches: z.array( z.object({ @@ -46,7 +57,6 @@ const ZDeploymentManifest = z.object({ collToken: ZAddress, defaultPool: ZAddress, gasPool: ZAddress, - interestRouter: ZAddress, leverageZapper: ZAddress, metadataNFT: ZAddress, priceFeed: ZAddress, @@ -128,7 +138,7 @@ function deployedContractsToAppEnvVariables(manifest: DeploymentManifest) { NEXT_PUBLIC_CONTRACT_WETH: manifest.branches[0].collToken, }; - const { branches, ...protocol } = manifest; + const { branches, governance, ...protocol } = manifest; // protocol contracts for (const [contractName, address] of Object.entries(protocol)) { @@ -138,7 +148,7 @@ function deployedContractsToAppEnvVariables(manifest: DeploymentManifest) { } } - // collateral contracts + // branches contracts for (const [index, contract] of Object.entries(branches)) { for (const [contractName, address] of Object.entries(contract)) { const envVarName = contractNameToAppEnvVariable(contractName, `COLL_${index}_CONTRACT`); @@ -148,6 +158,17 @@ function deployedContractsToAppEnvVariables(manifest: DeploymentManifest) { } } + // governance contracts + for (const [contractName, address] of Object.entries(governance)) { + const envVarName = contractNameToAppEnvVariable( + contractName, + contractName.endsWith("Initiative") ? "INITIATIVE" : "CONTRACT", + ); + if (envVarName) { + appEnvVariables[envVarName] = address; + } + } + return appEnvVariables; } @@ -163,6 +184,8 @@ function contractNameToAppEnvVariable(contractName: string, prefix: string = "") return `${prefix}_HINT_HELPERS`; case "multiTroveGetter": return `${prefix}_MULTI_TROVE_GETTER`; + case "exchangeHelpers": + return `${prefix}_EXCHANGE_HELPERS`; // collateral contracts case "activePool": @@ -189,6 +212,20 @@ function contractNameToAppEnvVariable(contractName: string, prefix: string = "") return `${prefix}_TROVE_MANAGER`; case "troveNFT": return `${prefix}_TROVE_NFT`; + + // governance contracts + case "LQTYToken": + return `${prefix}_LQTY_TOKEN`; + case "LQTYStaking": + return `${prefix}_LQTY_STAKING`; + case "governance": + return `${prefix}_GOVERNANCE`; + + // governance initiatives + case "uniV4DonationsInitiative": + return `${prefix}_UNI_V4_DONATIONS`; + case "curveV2GaugeRewardsInitiative": + return `${prefix}_CURVE_V2_GAUGE_REWARDS`; } return null; } diff --git a/frontend/app/.env b/frontend/app/.env index a425163d2..062097e2f 100644 --- a/frontend/app/.env +++ b/frontend/app/.env @@ -1,6 +1,16 @@ NEXT_PUBLIC_DEMO_MODE=false NEXT_PUBLIC_VERCEL_ANALYTICS=false NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=1 +# NEXT_PUBLIC_DEPLOYMENT_FLAVOR= + +# use demo|API_KEY (demo API) or pro|API_KEY (pro API) +NEXT_PUBLIC_COINGECKO_API_KEY= + +# the BLOCKING_LIST contract must implement isBlackListed(address)(bool) +# NEXT_PUBLIC_BLOCKING_LIST=0x97044531D0fD5B84438499A49629488105Dc58e6 + +# format: {vpnapi.io key}|{comma separated country codes} e.g. 1234|US,CA +# NEXT_PUBLIC_BLOCKING_VPNAPI= # Ethereum (mainnet) # NEXT_PUBLIC_CHAIN_ID=1 @@ -11,6 +21,8 @@ NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=1 # NEXT_PUBLIC_CHAIN_CONTRACT_ENS_REGISTRY=0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e # NEXT_PUBLIC_CHAIN_CONTRACT_ENS_RESOLVER=0xce01f8eee7E479C928F8919abD53E553a36CeF67|19258213 # NEXT_PUBLIC_CHAIN_CONTRACT_MULTICALL=0xca11bde05977b3631167028862be2a173976ca11|14353601 +# NEXT_PUBLIC_LIQUITY_STATS_URL= +# NEXT_PUBLIC_KNOWN_INITIATIVES_URL= # Hardhat / Anvil (local) # NEXT_PUBLIC_CHAIN_ID=31337 @@ -25,6 +37,7 @@ NEXT_PUBLIC_CHAIN_CURRENCY=Ether|ETH|18 NEXT_PUBLIC_CHAIN_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com NEXT_PUBLIC_CHAIN_BLOCK_EXPLORER=Etherscan Sepolia|https://sepolia.etherscan.io/ NEXT_PUBLIC_CHAIN_CONTRACT_MULTICALL=0xcA11bde05977b3631167028862bE2a173976CA11 +NEXT_PUBLIC_KNOWN_INITIATIVES_URL=https://liquity2-sepolia.vercel.app/known-initiatives/sepolia.json NEXT_PUBLIC_LIQUITY_STATS_URL=https://api.liquity.org/v2/testnet/sepolia.json NEXT_PUBLIC_SUBGRAPH_URL=https://api.studio.thegraph.com/query/42403/liquity2-sepolia/version/latest diff --git a/frontend/app/next.config.js b/frontend/app/next.config.js index 6fcf7a779..754fbe729 100644 --- a/frontend/app/next.config.js +++ b/frontend/app/next.config.js @@ -29,4 +29,17 @@ export default withBundleAnalyzer({ eslint: { ignoreDuringBuilds: true, }, + async headers() { + return [ + { + source: "/known-initiatives/:path*", + headers: [ + { key: "Access-Control-Allow-Credentials", value: "true" }, + { key: "Access-Control-Allow-Origin", value: "*" }, + { key: "Access-Control-Allow-Methods", value: "GET,OPTIONS" }, + { key: "Access-Control-Allow-Headers", value: "Origin, Content-Type, Accept" }, + ], + }, + ]; + }, }); diff --git a/frontend/app/panda.config.ts b/frontend/app/panda.config.ts index cc8d2dc60..b14c3b94f 100644 --- a/frontend/app/panda.config.ts +++ b/frontend/app/panda.config.ts @@ -5,6 +5,7 @@ import { defineConfig, defineGlobalStyles, definePreset } from "@pandacss/dev"; export default defineConfig({ preflight: true, // CSS reset + jsxFramework: "react", // needed for panda to extract props named `css` presets: [ liquityUiKitPreset as Preset, // `as Preset` prevents a type error: "Expression produces a union type that is too complex to represent." definePreset({ diff --git a/frontend/app/scripts/update-liquity-abis.ts b/frontend/app/scripts/update-liquity-abis.ts index 2a530e914..c7a90527f 100644 --- a/frontend/app/scripts/update-liquity-abis.ts +++ b/frontend/app/scripts/update-liquity-abis.ts @@ -94,10 +94,9 @@ async function main() { await Promise.all(ABIS.map(async (possibleNames) => { const abiName = possibleNames[0]; - const abi = await readJsonFromDir(`${artifactsTmpDir}`, possibleNames); - if (!abi) { + if (!abi || !abiName) { throw new Error(`Could not find ABI for ${possibleNames.join(", ")}`); } diff --git a/frontend/app/src/abi/Erc2612.ts b/frontend/app/src/abi/Erc2612.ts new file mode 100644 index 000000000..6b7e5c09e --- /dev/null +++ b/frontend/app/src/abi/Erc2612.ts @@ -0,0 +1,39 @@ +import { erc20Abi } from "viem"; + +// https://eips.ethereum.org/EIPS/eip-2612 +const permitAbiExtension = [ + { + name: "permit", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "deadline", type: "uint256" }, + { name: "v", type: "uint8" }, + { name: "r", type: "bytes32" }, + { name: "s", type: "bytes32" }, + ], + outputs: [], + }, + { + name: "nonces", + type: "function", + stateMutability: "view", + inputs: [{ name: "owner", type: "address" }], + outputs: [{ type: "uint256" }], + }, + { + name: "DOMAIN_SEPARATOR", + type: "function", + stateMutability: "view", + inputs: [], + outputs: [{ type: "bytes32" }], + }, +] as const; + +export default [ + ...erc20Abi, + ...permitAbiExtension, +] as const; diff --git a/frontend/app/src/abi/Governance.ts b/frontend/app/src/abi/Governance.ts index 5782cdb76..cef7cb30d 100644 --- a/frontend/app/src/abi/Governance.ts +++ b/frontend/app/src/abi/Governance.ts @@ -11,19 +11,19 @@ export const Governance = [ "type": "tuple", "internalType": "struct IGovernance.Configuration", "components": [ - { "name": "registrationFee", "type": "uint128", "internalType": "uint128" }, - { "name": "registrationThresholdFactor", "type": "uint128", "internalType": "uint128" }, - { "name": "unregistrationThresholdFactor", "type": "uint128", "internalType": "uint128" }, - { "name": "registrationWarmUpPeriod", "type": "uint16", "internalType": "uint16" }, - { "name": "unregistrationAfterEpochs", "type": "uint16", "internalType": "uint16" }, - { "name": "votingThresholdFactor", "type": "uint128", "internalType": "uint128" }, - { "name": "minClaim", "type": "uint88", "internalType": "uint88" }, - { "name": "minAccrual", "type": "uint88", "internalType": "uint88" }, - { "name": "epochStart", "type": "uint32", "internalType": "uint32" }, - { "name": "epochDuration", "type": "uint32", "internalType": "uint32" }, - { "name": "epochVotingCutoff", "type": "uint32", "internalType": "uint32" }, + { "name": "registrationFee", "type": "uint256", "internalType": "uint256" }, + { "name": "registrationThresholdFactor", "type": "uint256", "internalType": "uint256" }, + { "name": "unregistrationThresholdFactor", "type": "uint256", "internalType": "uint256" }, + { "name": "unregistrationAfterEpochs", "type": "uint256", "internalType": "uint256" }, + { "name": "votingThresholdFactor", "type": "uint256", "internalType": "uint256" }, + { "name": "minClaim", "type": "uint256", "internalType": "uint256" }, + { "name": "minAccrual", "type": "uint256", "internalType": "uint256" }, + { "name": "epochStart", "type": "uint256", "internalType": "uint256" }, + { "name": "epochDuration", "type": "uint256", "internalType": "uint256" }, + { "name": "epochVotingCutoff", "type": "uint256", "internalType": "uint256" }, ], }, + { "name": "_owner", "type": "address", "internalType": "address" }, { "name": "_initiatives", "type": "address[]", "internalType": "address[]" }, ], "stateMutability": "nonpayable", @@ -77,13 +77,6 @@ export const Governance = [ "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], "stateMutability": "view", }, - { - "type": "function", - "name": "REGISTRATION_WARM_UP_PERIOD", - "inputs": [], - "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], - "stateMutability": "view", - }, { "type": "function", "name": "UNREGISTRATION_AFTER_EPOCHS", @@ -108,11 +101,12 @@ export const Governance = [ { "type": "function", "name": "allocateLQTY", - "inputs": [{ "name": "_initiatives", "type": "address[]", "internalType": "address[]" }, { - "name": "_deltaLQTYVotes", - "type": "int176[]", - "internalType": "int176[]", - }, { "name": "_deltaLQTYVetos", "type": "int176[]", "internalType": "int176[]" }], + "inputs": [ + { "name": "_initiativesToReset", "type": "address[]", "internalType": "address[]" }, + { "name": "_initiatives", "type": "address[]", "internalType": "address[]" }, + { "name": "_absoluteLQTYVotes", "type": "int256[]", "internalType": "int256[]" }, + { "name": "_absoluteLQTYVetos", "type": "int256[]", "internalType": "int256[]" }, + ], "outputs": [], "stateMutability": "nonpayable", }, @@ -133,10 +127,17 @@ export const Governance = [ { "type": "function", "name": "calculateVotingThreshold", - "inputs": [], + "inputs": [{ "name": "_votes", "type": "uint256", "internalType": "uint256" }], "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], "stateMutability": "view", }, + { + "type": "function", + "name": "calculateVotingThreshold", + "inputs": [], + "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], + "stateMutability": "nonpayable", + }, { "type": "function", "name": "claimForInitiative", @@ -148,8 +149,8 @@ export const Governance = [ "type": "function", "name": "claimFromStakingV1", "inputs": [{ "name": "_rewardRecipient", "type": "address", "internalType": "address" }], - "outputs": [{ "name": "accruedLUSD", "type": "uint256", "internalType": "uint256" }, { - "name": "accruedETH", + "outputs": [{ "name": "lusdSent", "type": "uint256", "internalType": "uint256" }, { + "name": "ethSent", "type": "uint256", "internalType": "uint256", }], @@ -165,14 +166,50 @@ export const Governance = [ { "type": "function", "name": "depositLQTY", - "inputs": [{ "name": "_lqtyAmount", "type": "uint88", "internalType": "uint88" }], + "inputs": [{ "name": "_lqtyAmount", "type": "uint256", "internalType": "uint256" }], + "outputs": [], + "stateMutability": "nonpayable", + }, + { + "type": "function", + "name": "depositLQTY", + "inputs": [{ "name": "_lqtyAmount", "type": "uint256", "internalType": "uint256" }, { + "name": "_doSendRewards", + "type": "bool", + "internalType": "bool", + }, { "name": "_recipient", "type": "address", "internalType": "address" }], "outputs": [], "stateMutability": "nonpayable", }, { "type": "function", "name": "depositLQTYViaPermit", - "inputs": [{ "name": "_lqtyAmount", "type": "uint88", "internalType": "uint88" }, { + "inputs": [ + { "name": "_lqtyAmount", "type": "uint256", "internalType": "uint256" }, + { + "name": "_permitParams", + "type": "tuple", + "internalType": "struct PermitParams", + "components": [ + { "name": "owner", "type": "address", "internalType": "address" }, + { "name": "spender", "type": "address", "internalType": "address" }, + { "name": "value", "type": "uint256", "internalType": "uint256" }, + { "name": "deadline", "type": "uint256", "internalType": "uint256" }, + { "name": "v", "type": "uint8", "internalType": "uint8" }, + { "name": "r", "type": "bytes32", "internalType": "bytes32" }, + { "name": "s", "type": "bytes32", "internalType": "bytes32" }, + ], + }, + { "name": "_doSendRewards", "type": "bool", "internalType": "bool" }, + { "name": "_recipient", "type": "address", "internalType": "address" }, + ], + "outputs": [], + "stateMutability": "nonpayable", + }, + { + "type": "function", + "name": "depositLQTYViaPermit", + "inputs": [{ "name": "_lqtyAmount", "type": "uint256", "internalType": "uint256" }, { "name": "_permitParams", "type": "tuple", "internalType": "struct PermitParams", @@ -200,24 +237,136 @@ export const Governance = [ "type": "function", "name": "epoch", "inputs": [], - "outputs": [{ "name": "", "type": "uint16", "internalType": "uint16" }], + "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], "stateMutability": "view", }, { "type": "function", "name": "epochStart", "inputs": [], - "outputs": [{ "name": "", "type": "uint32", "internalType": "uint32" }], + "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], + "stateMutability": "view", + }, + { + "type": "function", + "name": "getInitiativeSnapshotAndState", + "inputs": [{ "name": "_initiative", "type": "address", "internalType": "address" }], + "outputs": [{ + "name": "initiativeSnapshot", + "type": "tuple", + "internalType": "struct IGovernance.InitiativeVoteSnapshot", + "components": [ + { "name": "votes", "type": "uint256", "internalType": "uint256" }, + { "name": "forEpoch", "type": "uint256", "internalType": "uint256" }, + { "name": "lastCountedEpoch", "type": "uint256", "internalType": "uint256" }, + { "name": "vetos", "type": "uint256", "internalType": "uint256" }, + ], + }, { + "name": "initiativeState", + "type": "tuple", + "internalType": "struct IGovernance.InitiativeState", + "components": [ + { "name": "voteLQTY", "type": "uint256", "internalType": "uint256" }, + { "name": "voteOffset", "type": "uint256", "internalType": "uint256" }, + { "name": "vetoLQTY", "type": "uint256", "internalType": "uint256" }, + { "name": "vetoOffset", "type": "uint256", "internalType": "uint256" }, + { "name": "lastEpochClaim", "type": "uint256", "internalType": "uint256" }, + ], + }, { "name": "shouldUpdate", "type": "bool", "internalType": "bool" }], + "stateMutability": "view", + }, + { + "type": "function", + "name": "getInitiativeState", + "inputs": [{ "name": "_initiative", "type": "address", "internalType": "address" }, { + "name": "_votesSnapshot", + "type": "tuple", + "internalType": "struct IGovernance.VoteSnapshot", + "components": [{ "name": "votes", "type": "uint256", "internalType": "uint256" }, { + "name": "forEpoch", + "type": "uint256", + "internalType": "uint256", + }], + }, { + "name": "_votesForInitiativeSnapshot", + "type": "tuple", + "internalType": "struct IGovernance.InitiativeVoteSnapshot", + "components": [ + { "name": "votes", "type": "uint256", "internalType": "uint256" }, + { "name": "forEpoch", "type": "uint256", "internalType": "uint256" }, + { "name": "lastCountedEpoch", "type": "uint256", "internalType": "uint256" }, + { "name": "vetos", "type": "uint256", "internalType": "uint256" }, + ], + }, { + "name": "_initiativeState", + "type": "tuple", + "internalType": "struct IGovernance.InitiativeState", + "components": [ + { "name": "voteLQTY", "type": "uint256", "internalType": "uint256" }, + { "name": "voteOffset", "type": "uint256", "internalType": "uint256" }, + { "name": "vetoLQTY", "type": "uint256", "internalType": "uint256" }, + { "name": "vetoOffset", "type": "uint256", "internalType": "uint256" }, + { "name": "lastEpochClaim", "type": "uint256", "internalType": "uint256" }, + ], + }], + "outputs": [{ "name": "status", "type": "uint8", "internalType": "enum IGovernance.InitiativeStatus" }, { + "name": "lastEpochClaim", + "type": "uint256", + "internalType": "uint256", + }, { "name": "claimableAmount", "type": "uint256", "internalType": "uint256" }], + "stateMutability": "view", + }, + { + "type": "function", + "name": "getInitiativeState", + "inputs": [{ "name": "_initiative", "type": "address", "internalType": "address" }], + "outputs": [{ "name": "status", "type": "uint8", "internalType": "enum IGovernance.InitiativeStatus" }, { + "name": "lastEpochClaim", + "type": "uint256", + "internalType": "uint256", + }, { "name": "claimableAmount", "type": "uint256", "internalType": "uint256" }], + "stateMutability": "nonpayable", + }, + { + "type": "function", + "name": "getLatestVotingThreshold", + "inputs": [], + "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], + "stateMutability": "view", + }, + { + "type": "function", + "name": "getTotalVotesAndState", + "inputs": [], + "outputs": [{ + "name": "snapshot", + "type": "tuple", + "internalType": "struct IGovernance.VoteSnapshot", + "components": [{ "name": "votes", "type": "uint256", "internalType": "uint256" }, { + "name": "forEpoch", + "type": "uint256", + "internalType": "uint256", + }], + }, { + "name": "state", + "type": "tuple", + "internalType": "struct IGovernance.GlobalState", + "components": [{ "name": "countedVoteLQTY", "type": "uint256", "internalType": "uint256" }, { + "name": "countedVoteOffset", + "type": "uint256", + "internalType": "uint256", + }], + }, { "name": "shouldUpdate", "type": "bool", "internalType": "bool" }], "stateMutability": "view", }, { "type": "function", "name": "globalState", "inputs": [], - "outputs": [{ "name": "countedVoteLQTY", "type": "uint88", "internalType": "uint88" }, { - "name": "countedVoteLQTYAverageTimestamp", - "type": "uint32", - "internalType": "uint32", + "outputs": [{ "name": "countedVoteLQTY", "type": "uint256", "internalType": "uint256" }, { + "name": "countedVoteOffset", + "type": "uint256", + "internalType": "uint256", }], "stateMutability": "view", }, @@ -226,14 +375,21 @@ export const Governance = [ "name": "initiativeStates", "inputs": [{ "name": "", "type": "address", "internalType": "address" }], "outputs": [ - { "name": "voteLQTY", "type": "uint88", "internalType": "uint88" }, - { "name": "vetoLQTY", "type": "uint88", "internalType": "uint88" }, - { "name": "averageStakingTimestampVoteLQTY", "type": "uint32", "internalType": "uint32" }, - { "name": "averageStakingTimestampVetoLQTY", "type": "uint32", "internalType": "uint32" }, - { "name": "counted", "type": "uint16", "internalType": "uint16" }, + { "name": "voteLQTY", "type": "uint256", "internalType": "uint256" }, + { "name": "voteOffset", "type": "uint256", "internalType": "uint256" }, + { "name": "vetoLQTY", "type": "uint256", "internalType": "uint256" }, + { "name": "vetoOffset", "type": "uint256", "internalType": "uint256" }, + { "name": "lastEpochClaim", "type": "uint256", "internalType": "uint256" }, ], "stateMutability": "view", }, + { + "type": "function", + "name": "isOwner", + "inputs": [], + "outputs": [{ "name": "", "type": "bool", "internalType": "bool" }], + "stateMutability": "view", + }, { "type": "function", "name": "lqty", @@ -249,30 +405,46 @@ export const Governance = [ "type": "address", "internalType": "address", }], - "outputs": [{ "name": "voteLQTY", "type": "uint88", "internalType": "uint88" }, { - "name": "vetoLQTY", - "type": "uint88", - "internalType": "uint88", - }, { "name": "atEpoch", "type": "uint16", "internalType": "uint16" }], + "outputs": [ + { "name": "voteLQTY", "type": "uint256", "internalType": "uint256" }, + { "name": "voteOffset", "type": "uint256", "internalType": "uint256" }, + { "name": "vetoLQTY", "type": "uint256", "internalType": "uint256" }, + { "name": "vetoOffset", "type": "uint256", "internalType": "uint256" }, + { "name": "atEpoch", "type": "uint256", "internalType": "uint256" }, + ], "stateMutability": "view", }, { "type": "function", "name": "lqtyToVotes", - "inputs": [{ "name": "_lqtyAmount", "type": "uint88", "internalType": "uint88" }, { - "name": "_currentTimestamp", + "inputs": [{ "name": "_lqtyAmount", "type": "uint256", "internalType": "uint256" }, { + "name": "_timestamp", "type": "uint256", "internalType": "uint256", - }, { "name": "_averageTimestamp", "type": "uint32", "internalType": "uint32" }], - "outputs": [{ "name": "", "type": "uint240", "internalType": "uint240" }], + }, { "name": "_offset", "type": "uint256", "internalType": "uint256" }], + "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], "stateMutability": "pure", }, { "type": "function", - "name": "multicall", - "inputs": [{ "name": "data", "type": "bytes[]", "internalType": "bytes[]" }], - "outputs": [{ "name": "results", "type": "bytes[]", "internalType": "bytes[]" }], - "stateMutability": "payable", + "name": "multiDelegateCall", + "inputs": [{ "name": "inputs", "type": "bytes[]", "internalType": "bytes[]" }], + "outputs": [{ "name": "returnValues", "type": "bytes[]", "internalType": "bytes[]" }], + "stateMutability": "nonpayable", + }, + { + "type": "function", + "name": "owner", + "inputs": [], + "outputs": [{ "name": "", "type": "address", "internalType": "address" }], + "stateMutability": "view", + }, + { + "type": "function", + "name": "registerInitialInitiatives", + "inputs": [{ "name": "_initiatives", "type": "address[]", "internalType": "address[]" }], + "outputs": [], + "stateMutability": "nonpayable", }, { "type": "function", @@ -285,14 +457,25 @@ export const Governance = [ "type": "function", "name": "registeredInitiatives", "inputs": [{ "name": "", "type": "address", "internalType": "address" }], - "outputs": [{ "name": "", "type": "uint16", "internalType": "uint16" }], + "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], "stateMutability": "view", }, + { + "type": "function", + "name": "resetAllocations", + "inputs": [{ "name": "_initiativesToReset", "type": "address[]", "internalType": "address[]" }, { + "name": "checkAll", + "type": "bool", + "internalType": "bool", + }], + "outputs": [], + "stateMutability": "nonpayable", + }, { "type": "function", "name": "secondsWithinEpoch", "inputs": [], - "outputs": [{ "name": "", "type": "uint32", "internalType": "uint32" }], + "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], "stateMutability": "view", }, { @@ -303,20 +486,21 @@ export const Governance = [ "name": "voteSnapshot", "type": "tuple", "internalType": "struct IGovernance.VoteSnapshot", - "components": [{ "name": "votes", "type": "uint240", "internalType": "uint240" }, { + "components": [{ "name": "votes", "type": "uint256", "internalType": "uint256" }, { "name": "forEpoch", - "type": "uint16", - "internalType": "uint16", + "type": "uint256", + "internalType": "uint256", }], }, { "name": "initiativeVoteSnapshot", "type": "tuple", "internalType": "struct IGovernance.InitiativeVoteSnapshot", - "components": [{ "name": "votes", "type": "uint224", "internalType": "uint224" }, { - "name": "forEpoch", - "type": "uint16", - "internalType": "uint16", - }, { "name": "lastCountedEpoch", "type": "uint16", "internalType": "uint16" }], + "components": [ + { "name": "votes", "type": "uint256", "internalType": "uint256" }, + { "name": "forEpoch", "type": "uint256", "internalType": "uint256" }, + { "name": "lastCountedEpoch", "type": "uint256", "internalType": "uint256" }, + { "name": "vetos", "type": "uint256", "internalType": "uint256" }, + ], }], "stateMutability": "nonpayable", }, @@ -345,39 +529,52 @@ export const Governance = [ "type": "function", "name": "userStates", "inputs": [{ "name": "", "type": "address", "internalType": "address" }], - "outputs": [{ "name": "allocatedLQTY", "type": "uint88", "internalType": "uint88" }, { - "name": "averageStakingTimestamp", - "type": "uint32", - "internalType": "uint32", - }], + "outputs": [ + { "name": "unallocatedLQTY", "type": "uint256", "internalType": "uint256" }, + { "name": "unallocatedOffset", "type": "uint256", "internalType": "uint256" }, + { "name": "allocatedLQTY", "type": "uint256", "internalType": "uint256" }, + { "name": "allocatedOffset", "type": "uint256", "internalType": "uint256" }, + ], "stateMutability": "view", }, { "type": "function", "name": "votesForInitiativeSnapshot", "inputs": [{ "name": "", "type": "address", "internalType": "address" }], - "outputs": [{ "name": "votes", "type": "uint224", "internalType": "uint224" }, { - "name": "forEpoch", - "type": "uint16", - "internalType": "uint16", - }, { "name": "lastCountedEpoch", "type": "uint16", "internalType": "uint16" }], + "outputs": [ + { "name": "votes", "type": "uint256", "internalType": "uint256" }, + { "name": "forEpoch", "type": "uint256", "internalType": "uint256" }, + { "name": "lastCountedEpoch", "type": "uint256", "internalType": "uint256" }, + { "name": "vetos", "type": "uint256", "internalType": "uint256" }, + ], "stateMutability": "view", }, { "type": "function", "name": "votesSnapshot", "inputs": [], - "outputs": [{ "name": "votes", "type": "uint240", "internalType": "uint240" }, { + "outputs": [{ "name": "votes", "type": "uint256", "internalType": "uint256" }, { "name": "forEpoch", - "type": "uint16", - "internalType": "uint16", + "type": "uint256", + "internalType": "uint256", }], "stateMutability": "view", }, { "type": "function", "name": "withdrawLQTY", - "inputs": [{ "name": "_lqtyAmount", "type": "uint88", "internalType": "uint88" }], + "inputs": [{ "name": "_lqtyAmount", "type": "uint256", "internalType": "uint256" }], + "outputs": [], + "stateMutability": "nonpayable", + }, + { + "type": "function", + "name": "withdrawLQTY", + "inputs": [{ "name": "_lqtyAmount", "type": "uint256", "internalType": "uint256" }, { + "name": "_doSendRewards", + "type": "bool", + "internalType": "bool", + }, { "name": "_recipient", "type": "address", "internalType": "address" }], "outputs": [], "stateMutability": "nonpayable", }, @@ -385,23 +582,24 @@ export const Governance = [ "type": "event", "name": "AllocateLQTY", "inputs": [ - { "name": "user", "type": "address", "indexed": false, "internalType": "address" }, - { "name": "initiative", "type": "address", "indexed": false, "internalType": "address" }, + { "name": "user", "type": "address", "indexed": true, "internalType": "address" }, + { "name": "initiative", "type": "address", "indexed": true, "internalType": "address" }, { "name": "deltaVoteLQTY", "type": "int256", "indexed": false, "internalType": "int256" }, { "name": "deltaVetoLQTY", "type": "int256", "indexed": false, "internalType": "int256" }, - { "name": "atEpoch", "type": "uint16", "indexed": false, "internalType": "uint16" }, + { "name": "atEpoch", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "hookSuccess", "type": "bool", "indexed": false, "internalType": "bool" }, ], "anonymous": false, }, { "type": "event", "name": "ClaimForInitiative", - "inputs": [{ "name": "initiative", "type": "address", "indexed": false, "internalType": "address" }, { - "name": "bold", - "type": "uint256", - "indexed": false, - "internalType": "uint256", - }, { "name": "forEpoch", "type": "uint256", "indexed": false, "internalType": "uint256" }], + "inputs": [ + { "name": "initiative", "type": "address", "indexed": true, "internalType": "address" }, + { "name": "bold", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "forEpoch", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "hookSuccess", "type": "bool", "indexed": false, "internalType": "bool" }, + ], "anonymous": false, }, { @@ -418,45 +616,59 @@ export const Governance = [ { "type": "event", "name": "DepositLQTY", - "inputs": [{ "name": "user", "type": "address", "indexed": false, "internalType": "address" }, { - "name": "depositedLQTY", - "type": "uint256", - "indexed": false, - "internalType": "uint256", - }], + "inputs": [ + { "name": "user", "type": "address", "indexed": true, "internalType": "address" }, + { "name": "rewardRecipient", "type": "address", "indexed": false, "internalType": "address" }, + { "name": "lqtyAmount", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "lusdReceived", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "lusdSent", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "ethReceived", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "ethSent", "type": "uint256", "indexed": false, "internalType": "uint256" }, + ], "anonymous": false, }, { "type": "event", - "name": "RegisterInitiative", - "inputs": [{ "name": "initiative", "type": "address", "indexed": false, "internalType": "address" }, { - "name": "registrant", + "name": "OwnershipTransferred", + "inputs": [{ "name": "previousOwner", "type": "address", "indexed": true, "internalType": "address" }, { + "name": "newOwner", "type": "address", - "indexed": false, + "indexed": true, "internalType": "address", - }, { "name": "atEpoch", "type": "uint16", "indexed": false, "internalType": "uint16" }], + }], + "anonymous": false, + }, + { + "type": "event", + "name": "RegisterInitiative", + "inputs": [ + { "name": "initiative", "type": "address", "indexed": false, "internalType": "address" }, + { "name": "registrant", "type": "address", "indexed": false, "internalType": "address" }, + { "name": "atEpoch", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "hookSuccess", "type": "bool", "indexed": false, "internalType": "bool" }, + ], "anonymous": false, }, { "type": "event", "name": "SnapshotVotes", - "inputs": [{ "name": "votes", "type": "uint240", "indexed": false, "internalType": "uint240" }, { + "inputs": [{ "name": "votes", "type": "uint256", "indexed": false, "internalType": "uint256" }, { "name": "forEpoch", - "type": "uint16", + "type": "uint256", "indexed": false, - "internalType": "uint16", - }], + "internalType": "uint256", + }, { "name": "boldAccrued", "type": "uint256", "indexed": false, "internalType": "uint256" }], "anonymous": false, }, { "type": "event", "name": "SnapshotVotesForInitiative", - "inputs": [{ "name": "initiative", "type": "address", "indexed": false, "internalType": "address" }, { - "name": "votes", - "type": "uint240", - "indexed": false, - "internalType": "uint240", - }, { "name": "forEpoch", "type": "uint16", "indexed": false, "internalType": "uint16" }], + "inputs": [ + { "name": "initiative", "type": "address", "indexed": true, "internalType": "address" }, + { "name": "votes", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "vetos", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "forEpoch", "type": "uint256", "indexed": false, "internalType": "uint256" }, + ], "anonymous": false, }, { @@ -464,20 +676,24 @@ export const Governance = [ "name": "UnregisterInitiative", "inputs": [{ "name": "initiative", "type": "address", "indexed": false, "internalType": "address" }, { "name": "atEpoch", - "type": "uint16", + "type": "uint256", "indexed": false, - "internalType": "uint16", - }], + "internalType": "uint256", + }, { "name": "hookSuccess", "type": "bool", "indexed": false, "internalType": "bool" }], "anonymous": false, }, { "type": "event", "name": "WithdrawLQTY", "inputs": [ - { "name": "user", "type": "address", "indexed": false, "internalType": "address" }, - { "name": "withdrawnLQTY", "type": "uint256", "indexed": false, "internalType": "uint256" }, - { "name": "accruedLUSD", "type": "uint256", "indexed": false, "internalType": "uint256" }, - { "name": "accruedETH", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "user", "type": "address", "indexed": true, "internalType": "address" }, + { "name": "recipient", "type": "address", "indexed": false, "internalType": "address" }, + { "name": "lqtyReceived", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "lqtySent", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "lusdReceived", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "lusdSent", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "ethReceived", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "ethSent", "type": "uint256", "indexed": false, "internalType": "uint256" }, ], "anonymous": false, }, @@ -500,4 +716,3 @@ export const Governance = [ "inputs": [{ "name": "token", "type": "address", "internalType": "address" }], }, ] as const; - diff --git a/frontend/app/src/abi/LqtyToken.ts b/frontend/app/src/abi/LqtyToken.ts index a9730c6c9..2f1e8a48b 100644 --- a/frontend/app/src/abi/LqtyToken.ts +++ b/frontend/app/src/abi/LqtyToken.ts @@ -1,123 +1,201 @@ -export const LqtyToken = [{ - "anonymous": false, - "inputs": [{ "indexed": true, "internalType": "address", "name": "owner", "type": "address" }, { - "indexed": true, - "internalType": "address", - "name": "spender", - "type": "address", - }, { "indexed": false, "internalType": "uint256", "name": "amount", "type": "uint256" }], - "name": "Approval", - "type": "event", -}, { - "anonymous": false, - "inputs": [{ "indexed": true, "internalType": "address", "name": "from", "type": "address" }, { - "indexed": true, - "internalType": "address", - "name": "to", - "type": "address", - }, { "indexed": false, "internalType": "uint256", "name": "amount", "type": "uint256" }], - "name": "Transfer", - "type": "event", -}, { - "inputs": [], - "name": "DOMAIN_SEPARATOR", - "outputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], - "stateMutability": "view", - "type": "function", -}, { - "inputs": [{ "internalType": "address", "name": "", "type": "address" }, { - "internalType": "address", - "name": "", - "type": "address", - }], - "name": "allowance", - "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], - "stateMutability": "view", - "type": "function", -}, { - "inputs": [{ "internalType": "address", "name": "spender", "type": "address" }, { - "internalType": "uint256", - "name": "amount", - "type": "uint256", - }], - "name": "approve", - "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], - "stateMutability": "nonpayable", - "type": "function", -}, { - "inputs": [{ "internalType": "address", "name": "", "type": "address" }], - "name": "balanceOf", - "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], - "stateMutability": "view", - "type": "function", -}, { - "inputs": [], - "name": "decimals", - "outputs": [{ "internalType": "uint8", "name": "", "type": "uint8" }], - "stateMutability": "view", - "type": "function", -}, { - "inputs": [{ "internalType": "uint256", "name": "amt", "type": "uint256" }], - "name": "mint", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function", -}, { - "inputs": [], - "name": "name", - "outputs": [{ "internalType": "string", "name": "", "type": "string" }], - "stateMutability": "view", - "type": "function", -}, { - "inputs": [{ "internalType": "address", "name": "", "type": "address" }], - "name": "nonces", - "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], - "stateMutability": "view", - "type": "function", -}, { - "inputs": [ - { "internalType": "address", "name": "owner", "type": "address" }, - { "internalType": "address", "name": "spender", "type": "address" }, - { "internalType": "uint256", "name": "value", "type": "uint256" }, - { "internalType": "uint256", "name": "deadline", "type": "uint256" }, - { "internalType": "uint8", "name": "v", "type": "uint8" }, - { "internalType": "bytes32", "name": "r", "type": "bytes32" }, - { "internalType": "bytes32", "name": "s", "type": "bytes32" }, - ], - "name": "permit", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function", -}, { - "inputs": [], - "name": "symbol", - "outputs": [{ "internalType": "string", "name": "", "type": "string" }], - "stateMutability": "view", - "type": "function", -}, { - "inputs": [], - "name": "totalSupply", - "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], - "stateMutability": "view", - "type": "function", -}, { - "inputs": [{ "internalType": "address", "name": "to", "type": "address" }, { - "internalType": "uint256", - "name": "amount", - "type": "uint256", - }], - "name": "transfer", - "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], - "stateMutability": "nonpayable", - "type": "function", -}, { - "inputs": [{ "internalType": "address", "name": "from", "type": "address" }, { - "internalType": "address", - "name": "to", - "type": "address", - }, { "internalType": "uint256", "name": "amount", "type": "uint256" }], - "name": "transferFrom", - "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], - "stateMutability": "nonpayable", - "type": "function", -}] as const; +export const LqtyToken = [ + { + "inputs": [ + { "internalType": "string", "name": "_name", "type": "string" }, + { "internalType": "string", "name": "_symbol", "type": "string" }, + { "internalType": "uint256", "name": "_tapAmount", "type": "uint256" }, + { "internalType": "uint256", "name": "_tapPeriod", "type": "uint256" }, + ], + "stateMutability": "nonpayable", + "type": "constructor", + }, + { + "anonymous": false, + "inputs": [{ "indexed": true, "internalType": "address", "name": "owner", "type": "address" }, { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address", + }, { "indexed": false, "internalType": "uint256", "name": "value", "type": "uint256" }], + "name": "Approval", + "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": true, "internalType": "address", "name": "from", "type": "address" }, { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address", + }, { "indexed": false, "internalType": "uint256", "name": "value", "type": "uint256" }], + "name": "Transfer", + "type": "event", + }, + { + "inputs": [{ "internalType": "address", "name": "owner", "type": "address" }, { + "internalType": "address", + "name": "spender", + "type": "address", + }], + "name": "allowance", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [{ "internalType": "address", "name": "spender", "type": "address" }, { + "internalType": "uint256", + "name": "amount", + "type": "uint256", + }], + "name": "approve", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [{ "internalType": "address", "name": "account", "type": "address" }], + "name": "balanceOf", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "decimals", + "outputs": [{ "internalType": "uint8", "name": "", "type": "uint8" }], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [{ "internalType": "address", "name": "spender", "type": "address" }, { + "internalType": "uint256", + "name": "subtractedValue", + "type": "uint256", + }], + "name": "decreaseAllowance", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [{ "internalType": "address", "name": "spender", "type": "address" }, { + "internalType": "uint256", + "name": "addedValue", + "type": "uint256", + }], + "name": "increaseAllowance", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [{ "internalType": "address", "name": "", "type": "address" }], + "name": "lastTapped", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [{ "internalType": "address", "name": "_to", "type": "address" }, { + "internalType": "uint256", + "name": "_amount", + "type": "uint256", + }], + "name": "mint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [], + "name": "name", + "outputs": [{ "internalType": "string", "name": "", "type": "string" }], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "owner", + "outputs": [{ "internalType": "address", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function", + }, + { "inputs": [], "name": "renounceOwnership", "outputs": [], "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [], + "name": "symbol", + "outputs": [{ "internalType": "string", "name": "", "type": "string" }], + "stateMutability": "view", + "type": "function", + }, + { "inputs": [], "name": "tap", "outputs": [], "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [], + "name": "tapAmount", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "tapPeriod", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [{ "internalType": "address", "name": "receiver", "type": "address" }], + "name": "tapTo", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [{ "internalType": "address", "name": "to", "type": "address" }, { + "internalType": "uint256", + "name": "amount", + "type": "uint256", + }], + "name": "transfer", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [{ "internalType": "address", "name": "from", "type": "address" }, { + "internalType": "address", + "name": "to", + "type": "address", + }, { "internalType": "uint256", "name": "amount", "type": "uint256" }], + "name": "transferFrom", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [{ "internalType": "address", "name": "newOwner", "type": "address" }], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, +] as const; diff --git a/frontend/app/src/anim-utils.ts b/frontend/app/src/anim-utils.ts new file mode 100644 index 000000000..fdf63d60e --- /dev/null +++ b/frontend/app/src/anim-utils.ts @@ -0,0 +1,31 @@ +import { sleep } from "@/src/utils"; +import { useTransition } from "@react-spring/web"; +import { useState } from "react"; + +export function useFlashTransition(duration: number = 500) { + const [show, setShow] = useState(false); + return { + flash: () => setShow(true), + transition: useTransition(show, { + from: { opacity: 0, transform: "scale(0.9)" }, + enter: () => async (next) => { + await next({ opacity: 1, transform: "scale(1)" }); + setShow(false); + }, + leave: () => async (next) => { + await sleep(duration); + await next({ opacity: 0, transform: "scale(1)" }); + }, + config: { mass: 1, tension: 2000, friction: 80 }, + }), + }; +} + +export function useAppear(show: boolean) { + return useTransition(show, { + from: { opacity: 0, transform: "scale(0.9)" }, + enter: { opacity: 1, transform: "scale(1)" }, + leave: { opacity: 0, transform: "scale(1)", immediate: true }, + config: { mass: 1, tension: 2000, friction: 80 }, + }); +} diff --git a/frontend/app/src/app/error.tsx b/frontend/app/src/app/error.tsx new file mode 100644 index 000000000..27bba6215 --- /dev/null +++ b/frontend/app/src/app/error.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { ErrorBox } from "@/src/comps/ErrorBox/ErrorBox"; +import { sleep } from "@/src/utils"; +import { css } from "@/styled-system/css"; +import { AnchorButton, Button } from "@liquity2/uikit"; +import { a, useSpring } from "@react-spring/web"; +import Link from "next/link"; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+
+ +
+

+ An unexpected error occurred +

+
+
+ + + +
+
+
+ +
+{error.message}

+{error.stack} +
+
+
+
+
+ ); +} + +function Illustration() { + const spring = useSpring({ + from: { + diskOpacity: 0, + diskTransform: "scale(0.5)", + barOpacity: 0, + barTransform: ` + rotate(-20deg) + scale(0) + `, + }, + to: async (next) => { + await Promise.all([ + next({ + diskOpacity: 1, + diskTransform: "scale(1)", + }), + sleep(200).then(() => + next({ + barOpacity: 1, + barTransform: ` + rotate(0deg) + scale(1) + `, + }) + ), + ]); + }, + delay: 200, + config: { + mass: 2, + tension: 1200, + friction: 60, + }, + }); + return ( +
+ + +
+ ); +} diff --git a/frontend/app/src/app/layout.tsx b/frontend/app/src/app/layout.tsx index e0055f454..e23148b2f 100644 --- a/frontend/app/src/app/layout.tsx +++ b/frontend/app/src/app/layout.tsx @@ -4,13 +4,14 @@ import "@liquity2/uikit/index.css"; import type { Metadata } from "next"; import type { ReactNode } from "react"; -import { AboutModal } from "@/src/comps/AboutModal/AboutModal"; +import { About } from "@/src/comps/About/About"; import { AppLayout } from "@/src/comps/AppLayout/AppLayout"; +import { Blocking } from "@/src/comps/Blocking/Blocking"; import content from "@/src/content"; import { DemoMode } from "@/src/demo-mode"; import { VERCEL_ANALYTICS } from "@/src/env"; import { Ethereum } from "@/src/services/Ethereum"; -import { Prices } from "@/src/services/Prices"; +import { ReactQuery } from "@/src/services/ReactQuery"; import { StoredState } from "@/src/services/StoredState"; import { TransactionFlow } from "@/src/services/TransactionFlow"; import { UiKit } from "@liquity2/uikit"; @@ -31,21 +32,23 @@ export default function Layout({ - - - - - - - - {children} - - - - - - - + + + + + + + + + {children} + + + + + + + + {VERCEL_ANALYTICS && } diff --git a/frontend/app/src/app/leverage/[collateral]/page.tsx b/frontend/app/src/app/multiply/[collateral]/page.tsx similarity index 100% rename from frontend/app/src/app/leverage/[collateral]/page.tsx rename to frontend/app/src/app/multiply/[collateral]/page.tsx diff --git a/frontend/app/src/app/leverage/layout.tsx b/frontend/app/src/app/multiply/layout.tsx similarity index 100% rename from frontend/app/src/app/leverage/layout.tsx rename to frontend/app/src/app/multiply/layout.tsx diff --git a/frontend/app/src/app/leverage/page.tsx b/frontend/app/src/app/multiply/page.tsx similarity index 100% rename from frontend/app/src/app/leverage/page.tsx rename to frontend/app/src/app/multiply/page.tsx diff --git a/frontend/app/src/app/not-found.tsx b/frontend/app/src/app/not-found.tsx index 1b779a355..078768780 100644 --- a/frontend/app/src/app/not-found.tsx +++ b/frontend/app/src/app/not-found.tsx @@ -1,4 +1,10 @@ +"use client"; + +import { sleep } from "@/src/utils"; import { css } from "@/styled-system/css"; +import { AnchorButton } from "@liquity2/uikit"; +import { a, useSpring } from "@react-spring/web"; +import Link from "next/link"; export default function NotFoundPage() { return ( @@ -6,12 +12,123 @@ export default function NotFoundPage() { className={css({ flexGrow: 1, display: "flex", + flexDirection: "column", justifyContent: "center", alignItems: "center", width: "100%", })} > - Not Found +
+ +
+

+ Sorry, there’s nothing here +

+
+

+ Let’s get you back on track. +

+
+ + + +
+
+ ); +} + +function Illustration() { + const spring = useSpring({ + from: { + diskOpacity: 0, + diskTransform: "scale(0.5)", + barOpacity: 0, + barTransform: ` + rotate(-20deg) + scale(0) + `, + }, + to: async (next) => { + await Promise.all([ + next({ + diskOpacity: 1, + diskTransform: "scale(1)", + }), + sleep(200).then(() => + next({ + barOpacity: 1, + barTransform: ` + rotate(0deg) + scale(1) + `, + }) + ), + ]); + }, + delay: 200, + config: { + mass: 2, + tension: 1200, + friction: 60, + }, + }); + return ( +
+ +
); } diff --git a/frontend/app/src/comps/About/About.tsx b/frontend/app/src/comps/About/About.tsx new file mode 100644 index 000000000..01b98cb07 --- /dev/null +++ b/frontend/app/src/comps/About/About.tsx @@ -0,0 +1,383 @@ +"use client"; + +import type { Entries } from "@/src/types"; +import type { ReactNode } from "react"; + +import { useFlashTransition } from "@/src/anim-utils"; +import { Logo } from "@/src/comps/Logo/Logo"; +import * as env from "@/src/env"; +import { css } from "@/styled-system/css"; +import { AnchorTextButton, Button, Modal } from "@liquity2/uikit"; +import { a, useSpring } from "@react-spring/web"; +import { useQuery } from "@tanstack/react-query"; +import { createContext, useContext, useState } from "react"; + +const ENV_EXCLUDE: (keyof typeof env)[] = [ + "CollateralSymbolSchema", + "EnvSchema", + "WALLET_CONNECT_PROJECT_ID", + "VERCEL_ANALYTICS", + "COINGECKO_API_KEY", + "DEMO_MODE", +]; + +// split the env vars into 3 groups: +// - config: base config vars (excluding ENV_EXCLUDE) +// - contracts: main contracts (CONTRACT_*) +// - branches: branches contracts (in COLLATERAL_CONTRACTS) +function getEnvGroups() { + const config = { ...env }; + + const contracts: Record = {}; + + for (const [key, value] of Object.entries(env) as Entries) { + if (key.startsWith("CONTRACT_")) { + contracts[key.replace("CONTRACT_", "")] = String(value); + delete config[key]; + continue; + } + } + + const branches: { + collIndex: number; + symbol: string; + contracts: [string, string][]; + }[] = []; + + for (const { collIndex, symbol, contracts } of config.COLLATERAL_CONTRACTS) { + branches.push({ + collIndex, + symbol, + contracts: Object + .entries(contracts) + .map(([name, address]) => [name, String(address)]), + }); + } + delete config["COLLATERAL_CONTRACTS" as keyof typeof config]; + + const configFinal = Object.fromEntries( + Object.entries(config) + .filter(([key]) => !ENV_EXCLUDE.includes(key as keyof typeof env)) + .map(([key, value]) => { + if (key === "CHAIN_BLOCK_EXPLORER") { + const { name, url } = value as { name: string; url: string }; + return [key, `${name}|${url}`]; + } + if (key === "CHAIN_CURRENCY") { + const { name, symbol, decimals } = value as { name: string; symbol: string; decimals: number }; + return [key, `${name}|${symbol}|${decimals}`]; + } + return [key, String(value)]; + }), + ); + + return { config: configFinal, contracts, branches }; +} + +function useContractsHash(envGroups: ReturnType) { + return useQuery({ + queryKey: ["contractsHash"], + queryFn: async () => { + const encoder = new TextEncoder(); + const hashBuffer = await crypto.subtle.digest( + "SHA-256", + encoder.encode(JSON.stringify([ + envGroups.contracts, + envGroups.branches, + ])), + ); + return Array.from(new Uint8Array(hashBuffer)) + .slice(0, 4) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + }, + staleTime: Infinity, + }); +} + +const AboutContext = createContext<{ + contractsHash?: string; + fullVersion: string; + openModal: () => void; +}>({ + contractsHash: "", + fullVersion: "", + openModal: () => {}, +}); + +export function useAbout() { + return useContext(AboutContext); +} + +const envGroups = getEnvGroups(); + +export function About({ children }: { children: ReactNode }) { + const contractsHash = useContractsHash(envGroups); + const [visible, setVisible] = useState(false); + const copyTransition = useFlashTransition(); + return ( + setVisible(true), + fullVersion: `v${env.APP_VERSION}-${env.COMMIT_HASH}`, + contractsHash: contractsHash.data, + }} + > + {children} + setVisible(false)} + visible={visible} + title={} + maxWidth={800} + > +
+
+

+ About this version +

+ + ), + "Commit": ( + + ), + "Contracts hash": contractsHash.data ?? "", + }} + /> +
+
+
+

+ Build environment +

+
+ {copyTransition.transition((style, item) => ( + item && ( + +
+ Copied +
+
+ ) + ))} +
+
+ + + {envGroups.branches.map(({ collIndex, symbol, contracts }) => ( + + ))} +
+
+
+
+ ); +} + +function ModalTitle() { + const logoSpring = useSpring({ + from: { + containerProgress: 0, + transform: ` + translateX(-64px) + rotate(-240deg) + `, + }, + to: { + containerProgress: 1, + transform: ` + translateX(0px) + rotate(0deg) + `, + }, + delay: 300, + config: { + mass: 1, + tension: 800, + friction: 80, + precision: 0.001, + }, + }); + return ( +
+ + + + + +
Liquity V2 App
+
+ ); +} + +function AboutTable({ + entries, + title, +}: { + entries: Record; + title?: string; +}) { + return ( +
+ {title && ( +

+ {title} +

+ )} + + + {Object.entries(entries).map(([key, value]) => ( + + + + + ))} + +
{key} +
+ {value} +
+
+
+ ); +} diff --git a/frontend/app/src/comps/AboutModal/AboutModal.tsx b/frontend/app/src/comps/AboutModal/AboutModal.tsx deleted file mode 100644 index 78bcf6833..000000000 --- a/frontend/app/src/comps/AboutModal/AboutModal.tsx +++ /dev/null @@ -1,82 +0,0 @@ -"use client"; - -import type { ReactNode } from "react"; - -import { APP_VERSION, COMMIT_HASH } from "@/src/env"; -import * as env from "@/src/env"; -import { css } from "@/styled-system/css"; -import { Modal } from "@liquity2/uikit"; -import { createContext, useContext, useState } from "react"; - -const AboutModalContext = createContext({ - open: () => {}, - close: () => {}, -}); - -export function AboutModal({ children }: { children: ReactNode }) { - const [visible, setVisible] = useState(false); - - const contracts = Object.entries(env) - .filter(([name]) => name.startsWith("CONTRACT_")) - .map(([name, address]) => [name.replace("CONTRACT_", ""), String(address)]); - - return ( - { - setVisible(true); - }, - close: () => setVisible(false), - }} - > - {children} - setVisible(false)} - visible={visible} - title="About" - > -
-
- Version: {APP_VERSION} ( - {COMMIT_HASH} - ) -
-
-
- Contracts: -
-
-          {contracts.map(([name, address]) => (
-            
- {name}: {address} -
- ))} -
-
-
-
-
- ); -} - -export function useAboutModal() { - return useContext(AboutModalContext); -} diff --git a/frontend/app/src/comps/ActionCard/ActionCard.tsx b/frontend/app/src/comps/ActionCard/ActionCard.tsx index c10599b12..e99732c5f 100644 --- a/frontend/app/src/comps/ActionCard/ActionCard.tsx +++ b/frontend/app/src/comps/ActionCard/ActionCard.tsx @@ -10,7 +10,7 @@ import { ActionIcon } from "./ActionIcon"; export function ActionCard({ type, }: { - type: "borrow" | "leverage" | "earn" | "stake"; + type: "borrow" | "multiply" | "earn" | "stake"; }) { const [hint, setHint] = useState(false); const [active, setActive] = useState(false); @@ -44,15 +44,15 @@ export function ActionCard({ path: "/borrow", title: ac.borrow.title, })) - .with("leverage", () => ({ + .with("multiply", () => ({ colors: { background: token("colors.brandGreen"), foreground: token("colors.brandGreenContent"), foregroundAlt: token("colors.brandGreenContentAlt"), }, - description: ac.leverage.description, - path: "/leverage", - title: ac.leverage.title, + description: ac.multiply.description, + path: "/multiply", + title: ac.multiply.title, })) .with("earn", () => ({ colors: { diff --git a/frontend/app/src/comps/ActionCard/ActionIcon.tsx b/frontend/app/src/comps/ActionCard/ActionIcon.tsx index 101259b82..bb7c699cf 100644 --- a/frontend/app/src/comps/ActionCard/ActionIcon.tsx +++ b/frontend/app/src/comps/ActionCard/ActionIcon.tsx @@ -24,12 +24,12 @@ export function ActionIcon({ background: string; foreground: string; }; - iconType: "borrow" | "leverage" | "earn" | "stake"; + iconType: "borrow" | "multiply" | "earn" | "stake"; state: IconProps["state"]; }) { const Icon = match(iconType) .with("borrow", () => ActionIconBorrow) - .with("leverage", () => ActionIconLeverage) + .with("multiply", () => ActionIconLeverage) .with("earn", () => ActionIconEarn) .with("stake", () => ActionIconStake) .exhaustive(); diff --git a/frontend/app/src/comps/AppLayout/AppLayout.tsx b/frontend/app/src/comps/AppLayout/AppLayout.tsx index 469497fd7..d9a8f5de7 100644 --- a/frontend/app/src/comps/AppLayout/AppLayout.tsx +++ b/frontend/app/src/comps/AppLayout/AppLayout.tsx @@ -1,9 +1,13 @@ +"use client"; + import type { ReactNode } from "react"; -import { UpdatePrices } from "@/src/comps/Debug/UpdatePrices"; +import { useAbout } from "@/src/comps/About/About"; import { ProtocolStats } from "@/src/comps/ProtocolStats/ProtocolStats"; import { TopBar } from "@/src/comps/TopBar/TopBar"; +import * as env from "@/src/env"; import { css } from "@/styled-system/css"; +import { TextButton } from "@liquity2/uikit"; export const LAYOUT_WIDTH = 1092; export const MIN_WIDTH = 960; @@ -63,13 +67,41 @@ export function AppLayout({
+
- +
+ ); +} + +function BuildInfo() { + const about = useAbout(); + return ( +
+ { + about.openModal(); + }} + className={css({ + color: "dimmed", + })} + style={{ + fontSize: 12, + }} + />
); } diff --git a/frontend/app/src/comps/Blocking/Blocking.tsx b/frontend/app/src/comps/Blocking/Blocking.tsx new file mode 100644 index 000000000..5040a6cc1 --- /dev/null +++ b/frontend/app/src/comps/Blocking/Blocking.tsx @@ -0,0 +1,144 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { BLOCKING_LIST, BLOCKING_VPNAPI } from "@/src/env"; +import { useAccount } from "@/src/services/Ethereum"; +import type { Address } from "@/src/types"; +import { css } from "@/styled-system/css"; +import { useQuery } from "@tanstack/react-query"; +import * as v from "valibot"; +import { useReadContract } from "wagmi"; + +export function Blocking({ + children, +}: { + children: ReactNode; +}) { + const account = useAccount(); + const accountInBlockingList = useIsAccountInBlockingList(account.address ?? null); + const accountVpnapi = useVpnapiBlock(); + + let blocked: { + title: string; + message: string; + } | null = null; + + if (accountInBlockingList.data) { + blocked = { + title: "Account blocked", + message: "This app cannot be accessed from this account.", + }; + } + + if (accountVpnapi.data?.isRouted) { + blocked = { + title: "Routed connection detected (VPN or similar)", + message: "This app cannot be accessed from a routed connection.", + }; + } + + if (accountVpnapi.data?.isCountryBlocked) { + blocked = { + title: "Blocked country", + message: `This app cannot be accessed from this country (${accountVpnapi.data.country}).`, + }; + } + + if (!blocked) { + return children; + } + + return ( +
+

+ {blocked.message} +

+
+ ); +} + +// blocking list contract +export function useIsAccountInBlockingList(account: Address | null) { + return useReadContract({ + address: BLOCKING_LIST, + abi: [{ + "inputs": [{ "internalType": "address", "name": "", "type": "address" }], + "name": "isBlacklisted", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "view", + "type": "function", + }], + functionName: "isBlacklisted", + args: [account ?? "0x"], + query: { + enabled: Boolean(account && BLOCKING_LIST), + retry: false, + refetchInterval: false, + }, + }); +} + +// vpnapi.io blocking +const VpnapiResponseSchema = v.pipe( + v.object({ + ip: v.string(), + security: v.object({ + vpn: v.boolean(), + proxy: v.boolean(), + tor: v.boolean(), + relay: v.boolean(), + }), + location: v.object({ + country_code: v.string(), + }), + }), + v.transform((value) => ({ + isRouted: Object.values(value.security).some((is) => is), + country: value.location.country_code, + })), +); +export function useVpnapiBlock() { + return useQuery({ + queryKey: ["vpnapi", BLOCKING_VPNAPI], + queryFn: async () => { + if (!BLOCKING_VPNAPI) { + return null; + } + const response = await fetch( + `https://vpnapi.io/api/?key=${BLOCKING_VPNAPI.apiKey}`, + ); + const result = await response.json(); + const { isRouted, country } = v.parse(VpnapiResponseSchema, result); + return { + country, + isCountryBlocked: BLOCKING_VPNAPI.countries.includes(country), + isRouted, + }; + }, + enabled: BLOCKING_VPNAPI !== null, + retry: false, + refetchInterval: false, + }); +} diff --git a/frontend/app/src/comps/Debug/UpdatePrices.tsx b/frontend/app/src/comps/Debug/UpdatePrices.tsx deleted file mode 100644 index 7b8de4f39..000000000 --- a/frontend/app/src/comps/Debug/UpdatePrices.tsx +++ /dev/null @@ -1,86 +0,0 @@ -"use client"; - -import { PRICE_UPDATE_MANUAL } from "@/src/demo-mode"; -import { usePrice, useUpdatePrice } from "@/src/services/Prices"; -import { css } from "@/styled-system/css"; -import * as dn from "dnum"; - -const ETH_RANGE = [200, 5000]; -const RETH_RANGE = [220, 5500]; -const WSTETH_RANGE = [200, 5000]; - -export function UpdatePrices() { - const updatePrice = useUpdatePrice(); - - const ethPrice = usePrice("ETH"); - const rethPrice = usePrice("RETH"); - const wstethPrice = usePrice("WSTETH"); - - return PRICE_UPDATE_MANUAL && ( -
-
-
- simulate prices -
{" "} -
- {([ - ["ETH", ethPrice, ETH_RANGE], - ["RETH", rethPrice, RETH_RANGE], - ["WSTETH", wstethPrice, WSTETH_RANGE], - ] as const).map(([token, price, range]) => ( -
- {token}: ${price && dn.format(price, { digits: 2, trailingZeros: true })} - updatePrice(token, dn.div(dn.from(e.target.value, 18), 100))} - className={css({ - width: 100, - })} - /> -
- ))} -
-
-
- ); -} diff --git a/frontend/app/src/comps/ErrorBox/ErrorBox.tsx b/frontend/app/src/comps/ErrorBox/ErrorBox.tsx index cb534a15f..ae4cf02cd 100644 --- a/frontend/app/src/comps/ErrorBox/ErrorBox.tsx +++ b/frontend/app/src/comps/ErrorBox/ErrorBox.tsx @@ -20,6 +20,7 @@ export function ErrorBox({ const contentStyles = useSpring({ opacity: 1, height: expanded ? (size?.blockSize ?? 0) + 32 : 0, + chevronTransform: expanded ? "rotate(180deg)" : "rotate(0deg)", config: { mass: 1, tension: 1800, @@ -31,6 +32,7 @@ export function ErrorBox({ return (
-

{title}

+

+ {title} +

{expanded ? "Less" : "More"} details - + + +
void), - ]> = [ - // ["Liquity", "https://liquity.org"], - // ["Disclaimer", "https://example.org"], - // ["Privacy Policy", "https://example.org"], - // ["Contracts", "/contracts"], - // ["About", aboutModal.open], - ]; - - return ( -
-
-
    - {links.map(([labelTitle, href], index) => { - const [label, title] = Array.isArray(labelTitle) ? labelTitle : [labelTitle, undefined]; - return ( -
  • - {typeof href === "string" - ? ( - - {label} - - ) - : ( - - )} -
  • - ); - })} -
-
-
- ); -} diff --git a/frontend/app/src/comps/LeverageField/LeverageField.tsx b/frontend/app/src/comps/LeverageField/LeverageField.tsx index 23e9ec781..1e6da72d1 100644 --- a/frontend/app/src/comps/LeverageField/LeverageField.tsx +++ b/frontend/app/src/comps/LeverageField/LeverageField.tsx @@ -115,7 +115,7 @@ export function LeverageField({ ), end: ( - Leverage { + Multiply { 1 ? dn.mul(depositPreLeverage, leverageFactor) diff --git a/frontend/app/src/comps/PercentageBars/PercentageBars.tsx b/frontend/app/src/comps/PercentageBars/PercentageBars.tsx index a994492d4..98083797c 100644 --- a/frontend/app/src/comps/PercentageBars/PercentageBars.tsx +++ b/frontend/app/src/comps/PercentageBars/PercentageBars.tsx @@ -73,7 +73,7 @@ export function PercentageBars({ const gapWidth = barsGap * values.length / (values.length - 1); const barsTransitions = useTransition(values.map((v, i) => [v, i]), { - keys: ([_, i]) => i, + keys: ([_, i]) => i as number, // guaranteed, defined by the map above from: { scaleY: 0 }, enter: { scaleY: 1 }, config: { mass: 2, tension: 1200, friction: 50 }, @@ -125,21 +125,23 @@ export function PercentageBars({ - {barsTransitions((props, [value], _state, index) => ( - - ))} + {barsTransitions((props, [value], _state, index) => + value !== undefined && ( // type guard, should never be undefined + + ) + )} diff --git a/frontend/app/src/comps/Positions/NewPositionCard.tsx b/frontend/app/src/comps/Positions/NewPositionCard.tsx index 4bd0fb59e..7f2ce10cb 100644 --- a/frontend/app/src/comps/Positions/NewPositionCard.tsx +++ b/frontend/app/src/comps/Positions/NewPositionCard.tsx @@ -19,15 +19,15 @@ const actionAttributes = { path: "/borrow", title: "Borrow", }, - leverage: { + multiply: { colors: { background: token("colors.brandGreen"), foreground: token("colors.brandGreenContent"), foregroundAlt: token("colors.brandGreenContentAlt"), }, - description: contentActions.leverage.description, - path: "/leverage", - title: "Leverage", + description: contentActions.multiply.description, + path: "/multiply", + title: "Multiply", }, earn: { colors: { diff --git a/frontend/app/src/comps/Positions/PositionCardBorrow.tsx b/frontend/app/src/comps/Positions/PositionCardBorrow.tsx index 9ecf6557b..20d7ed00d 100644 --- a/frontend/app/src/comps/Positions/PositionCardBorrow.tsx +++ b/frontend/app/src/comps/Positions/PositionCardBorrow.tsx @@ -39,7 +39,7 @@ export function PositionCardBorrow({ const token = getCollToken(collIndex); const collateralPriceUsd = usePrice(token?.symbol ?? null); - const ltv = collateralPriceUsd && getLtv(deposit, borrowed, collateralPriceUsd); + const ltv = collateralPriceUsd.data && getLtv(deposit, borrowed, collateralPriceUsd.data); const redemptionRisk = getRedemptionRisk(interestRate); const maxLtv = token && dn.from(1 / token.collateralRatio, 18); @@ -160,7 +160,7 @@ export function PositionCardBorrow({ color: "positionContent", })} > - {formatLiquidationRisk(liquidationRisk)} + {liquidationRisk && formatLiquidationRisk(liquidationRisk)}
-
Leverage loan
+
Multiply position
{statusTag}
, ]} @@ -83,7 +82,7 @@ export function PositionCardLeverage({ value: ( {deposit ? dn.format(deposit, 2) : "−"} - + ), label: "Net value", diff --git a/frontend/app/src/comps/Positions/PositionCardLoan.tsx b/frontend/app/src/comps/Positions/PositionCardLoan.tsx index 40da21afe..cc1bd464c 100644 --- a/frontend/app/src/comps/Positions/PositionCardLoan.tsx +++ b/frontend/app/src/comps/Positions/PositionCardLoan.tsx @@ -29,7 +29,7 @@ export function PositionCardLoan( const prefixedTroveId = getPrefixedTroveId(props.collIndex, props.troveId); const loanMode = storedState.loanModes[prefixedTroveId] ?? props.type; - const Card = loanMode === "leverage" ? PositionCardLeverage : PositionCardBorrow; + const Card = loanMode === "multiply" ? PositionCardLeverage : PositionCardBorrow; return ( ( match(position) .returnType<[number, ReactNode]>() - .with({ type: P.union("borrow", "leverage") }, (p) => [ + .with({ type: P.union("borrow", "multiply") }, (p) => [ index, , ]) @@ -145,7 +145,7 @@ function PositionsGroup({ showNewPositionCard ? [ [0, ], - [1, ], + [1, ], [2, ], [3, ], ] diff --git a/frontend/app/src/comps/ProtocolStats/ProtocolStats.tsx b/frontend/app/src/comps/ProtocolStats/ProtocolStats.tsx index 5b35b1472..b6caddcc5 100644 --- a/frontend/app/src/comps/ProtocolStats/ProtocolStats.tsx +++ b/frontend/app/src/comps/ProtocolStats/ProtocolStats.tsx @@ -1,39 +1,30 @@ "use client"; +import type { TokenSymbol } from "@/src/types"; + import { Amount } from "@/src/comps/Amount/Amount"; import { Logo } from "@/src/comps/Logo/Logo"; import { getContracts } from "@/src/contracts"; import { useAccount } from "@/src/services/Ethereum"; -import { useAllPrices } from "@/src/services/Prices"; +import { usePrice } from "@/src/services/Prices"; import { useTotalDeposited } from "@/src/subgraph-hooks"; import { css } from "@/styled-system/css"; import { AnchorTextButton, HFlex, shortenAddress, TokenIcon } from "@liquity2/uikit"; import { blo } from "blo"; import * as dn from "dnum"; import Image from "next/image"; +import Link from "next/link"; const DISPLAYED_PRICES = ["LQTY", "BOLD", "ETH"] as const; export function ProtocolStats() { const account = useAccount(); - const prices = useAllPrices(); - const totalDeposited = useTotalDeposited(); - - const tvl = getContracts() - .collaterals - .map((collateral, collIndex) => { - const price = prices[collateral.symbol]; - const deposited = totalDeposited.data?.[collIndex].totalDeposited; - return price && deposited && dn.mul(price, deposited); - }) - .reduce((a, b) => b ? dn.add(a ?? dn.from(0, 18), b) : a, null); - + const tvl = useTvl(); return (
- {DISPLAYED_PRICES.map((symbol) => { - const price = prices[symbol]; - return ( - - - - {symbol} - - - - ); - })} + {DISPLAYED_PRICES.map((symbol) => ( + + ))} {account.address && ( - - + passHref + legacyBehavior + scroll={true} + > + + - - {shortenAddress(account.address, 3)} + {shortenAddress(account.address, 3)} - - } - className={css({ - color: "content", - })} - /> + } + className={css({ + color: "content", + borderRadius: 4, + _focusVisible: { + outline: "2px solid token(colors.focused)", + }, + _active: { + translate: "0 1px", + }, + })} + /> + )}
); } + +function Price({ symbol }: { symbol: TokenSymbol }) { + const price = usePrice(symbol); + return ( + + + + {symbol} + + + + ); +} + +function useTvl() { + const { collaterals } = getContracts(); + const totalDeposited = useTotalDeposited(); + const collPrices = Object.fromEntries(collaterals.map((collateral) => ( + [collateral.symbol, usePrice(collateral.symbol)] as const + ))) as Record>; + + // make sure all prices and the total deposited have loaded before calculating the TVL + if ( + !Object.values(collPrices).every((cp) => cp.status === "success") + || totalDeposited.status !== "success" + ) { + return null; + } + + const tvlByCollateral = collaterals.map((collateral, collIndex) => { + const price = collPrices[collateral.symbol].data; + const collDeposited = totalDeposited.data[collIndex]; + return price && collDeposited && dn.mul(price, collDeposited.totalDeposited); + }); + + let tvl = dn.from(0, 18); + for (const value of tvlByCollateral ?? []) { + tvl = value ? dn.add(tvl, value) : tvl; + } + + return tvl; +} diff --git a/frontend/app/src/comps/Screen/Screen.tsx b/frontend/app/src/comps/Screen/Screen.tsx index 943d9084b..0696dc40f 100644 --- a/frontend/app/src/comps/Screen/Screen.tsx +++ b/frontend/app/src/comps/Screen/Screen.tsx @@ -48,19 +48,21 @@ export function Screen({ from: { opacity: 0, transform: ` - translate(0, 48px) + scale3d(0.95, 0.95, 1) + translate(0, 12px) `, }, to: { opacity: 1, transform: ` + scale3d(1, 1, 1) translate(0, 0px) `, }, config: { mass: 1, tension: 2200, - friction: 220, + friction: 120, }, }); @@ -79,11 +81,11 @@ export function Screen({ translate3d(0, 0px, 0) `, }, - delay: 150, + delay: 100, config: { - mass: 2, - tension: 2400, - friction: 220, + mass: 1, + tension: 2200, + friction: 120, }, }); diff --git a/frontend/app/src/comps/StakePositionSummary/StakePositionSummary.tsx b/frontend/app/src/comps/StakePositionSummary/StakePositionSummary.tsx index 556e5bbf0..f94b1dcf9 100644 --- a/frontend/app/src/comps/StakePositionSummary/StakePositionSummary.tsx +++ b/frontend/app/src/comps/StakePositionSummary/StakePositionSummary.tsx @@ -1,21 +1,61 @@ import type { PositionStake } from "@/src/types"; +import { useAppear } from "@/src/anim-utils"; import { Amount } from "@/src/comps/Amount/Amount"; import { TagPreview } from "@/src/comps/TagPreview/TagPreview"; import { fmtnum } from "@/src/formatting"; +import { useGovernanceUser } from "@/src/subgraph-hooks"; import { css } from "@/styled-system/css"; -import { HFlex, IconStake, InfoTooltip, TokenIcon } from "@liquity2/uikit"; +import { HFlex, IconStake, InfoTooltip, TokenIcon, useRaf } from "@liquity2/uikit"; +import { a } from "@react-spring/web"; import * as dn from "dnum"; +import { useRef } from "react"; export function StakePositionSummary({ + loadingState = "success", prevStakePosition, stakePosition, txPreviewMode = false, }: { + loadingState?: "error" | "pending" | "success"; prevStakePosition?: null | PositionStake; stakePosition: null | PositionStake; txPreviewMode?: boolean; }) { + const govUser = useGovernanceUser(stakePosition?.owner ?? null); + + const appear = useAppear(loadingState === "success" && govUser.status === "success"); + + // votingPower(t) = lqty * t - offset + const votingPower = (timestamp: bigint) => { + if (!govUser.data) { + return null; + } + return ( + BigInt(govUser.data.stakedLQTY) * timestamp + - BigInt(govUser.data.stakedOffset) + ); + }; + + const votingPowerRef = useRef(null); + useRaf(() => { + if (!votingPowerRef.current) { + return; + } + + const vp = votingPower(BigInt(Date.now())); + if (vp === null) { + votingPowerRef.current.innerHTML = "0"; + return; + } + + const vpAsNum = Number(vp / 10n ** 18n) / 1000 / 1000; + votingPowerRef.current.innerHTML = fmtnum( + vpAsNum, + { digits: 2, trailingZeros: true }, + ); + }, 60); + return (
-
- -
- + {appear((style, show) => ( + show && ( + +
+ +
+ +
+ ) + ))} {prevStakePosition && stakePosition && !dn.eq(prevStakePosition.deposit, stakePosition.deposit) @@ -219,47 +274,52 @@ export function StakePositionSummary({ > Voting power
-
-
- -
- {prevStakePosition && stakePosition && !dn.eq(prevStakePosition.share, stakePosition.share) - ? ( + + {appear((style, show) => ( + show && ( +
-
- ) - : " of pool"} - - Voting power is the percentage of the total staked LQTY that you own. - -
+ {prevStakePosition && stakePosition && !dn.eq(prevStakePosition.share, stakePosition.share) && ( +
+ +
+ )} + + Voting power is the total staked LQTY that you own.
It is calculated as:
+ lqty * t - offset +
+ + ) + ))}
diff --git a/frontend/app/src/comps/Tag/LoanStatusTag.tsx b/frontend/app/src/comps/Tag/LoanStatusTag.tsx index b7d2d07a2..14deeee82 100644 --- a/frontend/app/src/comps/Tag/LoanStatusTag.tsx +++ b/frontend/app/src/comps/Tag/LoanStatusTag.tsx @@ -8,13 +8,11 @@ export function LoanStatusTag({ return (
[0]; }) { return (
{children}
); } + +function getStyles(size: Parameters[0]["size"]) { + if (size === "mini") { + return { + height: 12, + padding: "0 4px 0.5px", + fontSize: 9, + }; + } + if (size === "small") { + return { + height: 16, + padding: "0 4px 1px", + fontSize: 12, + }; + } + if (size === "medium") { + return { + height: 22, + padding: 6, + fontSize: 14, + }; + } + throw new Error(`Invalid size: ${size}`); +} diff --git a/frontend/app/src/comps/TopBar/TopBar.tsx b/frontend/app/src/comps/TopBar/TopBar.tsx index 97c964e39..0fe1c357f 100644 --- a/frontend/app/src/comps/TopBar/TopBar.tsx +++ b/frontend/app/src/comps/TopBar/TopBar.tsx @@ -3,7 +3,9 @@ import type { ComponentProps } from "react"; import { Logo } from "@/src/comps/Logo/Logo"; +import { Tag } from "@/src/comps/Tag/Tag"; import content from "@/src/content"; +import { DEPLOYMENT_FLAVOR } from "@/src/env"; import { css } from "@/styled-system/css"; import { IconBorrow, IconDashboard, IconEarn, IconLeverage, IconStake } from "@liquity2/uikit"; import Link from "next/link"; @@ -13,7 +15,7 @@ import { Menu } from "./Menu"; const menuItems: ComponentProps["menuItems"] = [ [content.menu.dashboard, "/", IconDashboard], [content.menu.borrow, "/borrow", IconBorrow], - [content.menu.leverage, "/leverage", IconLeverage], + [content.menu.multiply, "/multiply", IconLeverage], [content.menu.earn, "/earn", IconEarn], [content.menu.stake, "/stake", IconStake], ]; @@ -46,6 +48,7 @@ export function TopBar() { {content.appName} + {DEPLOYMENT_FLAVOR !== "" && ( +
+ + {DEPLOYMENT_FLAVOR} + +
+ )}
diff --git a/frontend/app/src/constants.ts b/frontend/app/src/constants.ts index 9831a403b..72689fa15 100644 --- a/frontend/app/src/constants.ts +++ b/frontend/app/src/constants.ts @@ -30,6 +30,7 @@ export const UPFRONT_INTEREST_PERIOD = 7n * ONE_DAY_IN_SECONDS; export const SP_YIELD_SPLIT = 72n * 10n ** 16n; // 72% export const DATA_REFRESH_INTERVAL = 30_000; +export const PRICE_REFRESH_INTERVAL = 60_000; export const LEVERAGE_MAX_SLIPPAGE = 0.05; // 5% export const CLOSE_FROM_COLLATERAL_SLIPPAGE = 0.05; // 5% @@ -42,9 +43,9 @@ export const MAX_COLLATERAL_DEPOSITS: Record = { RETH: dn.from(100_000_000n, 18), }; -// LTV factor suggestions, as ratios of the leverage factor range +// LTV factor suggestions, as ratios of the multiply factor range export const LEVERAGE_FACTOR_SUGGESTIONS = [ - norm(1.5, 1.1, 11), // 1.5x leverage with a 1.1x => 11x range + 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), ]; diff --git a/frontend/app/src/content.tsx b/frontend/app/src/content.tsx index 77671838e..927433e47 100644 --- a/frontend/app/src/content.tsx +++ b/frontend/app/src/content.tsx @@ -11,7 +11,7 @@ export default { menu: { dashboard: "Dashboard", borrow: "Borrow", - leverage: "Leverage", + multiply: "Multiply", earn: "Earn", stake: "Stake", }, @@ -165,6 +165,11 @@ export default { }, closeLoan: { + claimOnly: ( + <> + You are reclaiming your collateral and closing the position. The deposit will be returned to your wallet. + + ), repayWithBoldMessage: ( <> You are repaying your debt and closing the position. The deposit will be returned to your wallet. @@ -176,6 +181,8 @@ export default { will be returned to your wallet. ), + buttonRepayAndClose: "Repay & close", + buttonReclaimAndClose: "Reclaim & close", }, // Home screen @@ -187,8 +194,8 @@ export default { title: "Borrow BOLD", description: "Set your own interest rate and borrow BOLD against ETH and staked ETH.", }, - leverage: { - title: "Leverage ETH", + multiply: { + title: "Multiply ETH", description: "Set your own interest rate and increase your exposure to ETH and staked ETH.", }, earn: { @@ -249,11 +256,11 @@ export default { }, }, - // Leverage screen + // Multiply screen leverageScreen: { headline: (tokensIcons: N) => ( <> - Leverage your exposure to {tokensIcons} + Multiply your exposure to {tokensIcons} ), depositField: { @@ -268,7 +275,7 @@ export default { action: "Next: Summary", infoTooltips: { leverageLevel: [ - "Leverage level", + "Multiply level", <> Choose the amplification of your exposure. Note that a higher level means higher liquidation risk. You are responsible for your own assessment of what a suitable level is. diff --git a/frontend/app/src/contracts.ts b/frontend/app/src/contracts.ts index 1c8a84972..f5cf24134 100644 --- a/frontend/app/src/contracts.ts +++ b/frontend/app/src/contracts.ts @@ -24,6 +24,7 @@ import { CONTRACT_BOLD_TOKEN, CONTRACT_COLLATERAL_REGISTRY, CONTRACT_EXCHANGE_HELPERS, + CONTRACT_GOVERNANCE, CONTRACT_HINT_HELPERS, CONTRACT_LQTY_STAKING, CONTRACT_LQTY_TOKEN, @@ -105,7 +106,7 @@ export type Contracts = ProtocolContractMap & { const CONTRACTS: Contracts = { BoldToken: { abi: abis.BoldToken, address: CONTRACT_BOLD_TOKEN }, CollateralRegistry: { abi: abis.CollateralRegistry, address: CONTRACT_COLLATERAL_REGISTRY }, - Governance: { abi: abis.Governance, address: zeroAddress }, + Governance: { abi: abis.Governance, address: CONTRACT_GOVERNANCE }, ExchangeHelpers: { abi: abis.ExchangeHelpers, address: CONTRACT_EXCHANGE_HELPERS }, HintHelpers: { abi: abis.HintHelpers, address: CONTRACT_HINT_HELPERS }, LqtyStaking: { abi: abis.LqtyStaking, address: CONTRACT_LQTY_STAKING }, diff --git a/frontend/app/src/demo-mode/demo-data.ts b/frontend/app/src/demo-mode/demo-data.ts index 73ced4af1..0ac1e4e7a 100644 --- a/frontend/app/src/demo-mode/demo-data.ts +++ b/frontend/app/src/demo-mode/demo-data.ts @@ -5,17 +5,6 @@ import type { Dnum } from "dnum"; import { INTEREST_RATE_INCREMENT, INTEREST_RATE_MAX, INTEREST_RATE_MIN } from "@/src/constants"; import * as dn from "dnum"; -export const PRICE_UPDATE_INTERVAL = 15_000; -export const PRICE_UPDATE_VARIATION = 0.003; -export const PRICE_UPDATE_MANUAL = false; - -export const LQTY_PRICE = dn.from(0.64832, 18); -export const ETH_PRICE = dn.from(2_580.293872, 18); -export const RETH_PRICE = dn.from(2_884.72294, 18); -export const WSTETH_PRICE = dn.from(2_579.931, 18); -export const BOLD_PRICE = dn.from(1.0031, 18); -export const LUSD_PRICE = dn.from(1.012, 18); - export const STAKED_LQTY_TOTAL = [43_920_716_739_092_664_364_409_174n, 18] as const; export const ACCOUNT_STAKED_LQTY = { @@ -55,7 +44,7 @@ export const ACCOUNT_POSITIONS: Exclude[] = [ updatedAt: getTime(), }, { - type: "leverage", + type: "multiply", status: "active", borrowed: dn.from(28_934.23, 18), borrower: DEMO_ACCOUNT, @@ -149,10 +138,11 @@ export const INTEREST_CHART = INTEREST_RATE_BUCKETS.map(([_, size]) => ( export function getDebtBeforeRateBucketIndex(index: number) { let debt = dn.from(0, 18); for (let i = 0; i < index; i++) { - if (!INTEREST_RATE_BUCKETS[i]) { + const bucket = INTEREST_RATE_BUCKETS[i]; + if (!bucket) { break; } - debt = dn.add(debt, INTEREST_RATE_BUCKETS[i][1]); + debt = dn.add(debt, bucket[1]); if (i === index - 1) { break; } diff --git a/frontend/app/src/demo-mode/index.tsx b/frontend/app/src/demo-mode/index.tsx index 0f0729f13..d1343ec98 100644 --- a/frontend/app/src/demo-mode/index.tsx +++ b/frontend/app/src/demo-mode/index.tsx @@ -102,31 +102,43 @@ const DemoContext = createContext({ updateAccountConnected: noop, }); -export function DemoMode({ - children, -}: { - children: ReactNode; -}) { - const [state, setState] = useState(() => { - // attempt restoring state from local storage - const storedState = typeof localStorage !== "undefined" - ? localStorage.getItem(DEMO_STATE_KEY) - : null; +const DemoStorage = { + get: (): DemoModeState | null => { + if (!DEMO_MODE || typeof localStorage === "undefined") { + return null; + } + const storedState = localStorage.getItem(DEMO_STATE_KEY); if (storedState) { try { return v.parse(DemoModeStateSchema, JSON.parse(storedState)); } catch { - return demoModeStateDefault; + return null; } } - return demoModeStateDefault; - }); - - // save state to local storage - useEffect(() => { - if (typeof localStorage !== "undefined") { + return null; + }, + set: (state: DemoModeState) => { + if (DEMO_MODE && typeof localStorage !== "undefined") { localStorage.setItem(DEMO_STATE_KEY, JSON.stringify(state)); } + }, + clear: () => { + if (DEMO_MODE && typeof localStorage !== "undefined") { + localStorage.removeItem(DEMO_STATE_KEY); + } + }, +}; + +export function DemoMode({ + children, +}: { + children: ReactNode; +}) { + const [state, setState] = useState(() => DemoStorage.get() ?? demoModeStateDefault); + + // save state to storage + useEffect(() => { + DemoStorage.set(state); }, [state]); const setDemoModeState = useCallback(( @@ -144,9 +156,7 @@ export function DemoMode({ }, []); const clearDemoMode = useCallback(() => { - if (typeof localStorage !== "undefined") { - localStorage.removeItem(DEMO_STATE_KEY); - } + DemoStorage.clear(); setState(demoModeStateDefault); }, []); diff --git a/frontend/app/src/env.ts b/frontend/app/src/env.ts index 29df5cb4d..cfe6e7c54 100644 --- a/frontend/app/src/env.ts +++ b/frontend/app/src/env.ts @@ -13,6 +13,28 @@ export const CollateralSymbolSchema = v.union([ export const EnvSchema = v.pipe( v.object({ APP_VERSION: v.string(), + BLOCKING_LIST: v.optional(vAddress()), + BLOCKING_VPNAPI: v.pipe( + v.optional(v.string(), ""), + v.transform((value) => { + if (!value.trim()) { + return null; // not set + } + + const [apiKey, countries = ""] = value.split("|"); + if (!apiKey) { + throw new Error( + `Invalid BLOCKING_VPNAPI value: ${value}. ` + + `Expected format: API_KEY or API_KEY|COUNTRY,COUNTRY,… ` + + `(e.g. 123456|US,CA)`, + ); + } + return { + apiKey: apiKey.trim(), + countries: countries.split(",").map((c) => c.trim().toUpperCase()), + }; + }), + ), CHAIN_ID: v.pipe( v.string(), v.transform((value) => { @@ -31,19 +53,48 @@ export const EnvSchema = v.pipe( CHAIN_CONTRACT_ENS_RESOLVER: v.optional(vEnvAddressAndBlock()), CHAIN_CONTRACT_MULTICALL: vAddress(), COMMIT_HASH: v.string(), - SUBGRAPH_URL: v.string(), + COINGECKO_API_KEY: v.pipe( + v.optional(v.string(), ""), + v.rawTransform(({ dataset, addIssue, NEVER }) => { + const [apiType, apiKey] = dataset.value.split("|"); + if (!apiKey) { + return null; // no API key + } + if (apiType !== "demo" && apiType !== "pro") { + addIssue({ message: `Invalid CoinGecko API type: ${apiType}` }); + return NEVER; + } + if (!apiKey.trim()) { + addIssue({ message: `Invalid CoinGecko API key (empty)` }); + return NEVER; + } + return { + apiType: apiType as "demo" | "pro", + apiKey, + }; + }), + ), + DEMO_MODE: v.optional(vEnvFlag(), "false"), + DEPLOYMENT_FLAVOR: v.pipe( + v.optional(v.string(), ""), + v.transform((value) => value.trim()), + ), + INITIATIVE_UNI_V4_DONATIONS: vAddress(), + KNOWN_INITIATIVES_URL: v.optional(v.pipe(v.string(), v.url())), + SUBGRAPH_URL: v.pipe(v.string(), v.url()), + VERCEL_ANALYTICS: v.optional(vEnvFlag(), "false"), + WALLET_CONNECT_PROJECT_ID: v.string(), DELEGATE_AUTO: vAddress(), - CONTRACT_LQTY_TOKEN: vAddress(), - CONTRACT_LQTY_STAKING: vAddress(), - CONTRACT_LUSD_TOKEN: vAddress(), - CONTRACT_GOVERNANCE: vAddress(), - CONTRACT_BOLD_TOKEN: vAddress(), CONTRACT_COLLATERAL_REGISTRY: vAddress(), CONTRACT_EXCHANGE_HELPERS: 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_WETH: vAddress(), @@ -85,10 +136,6 @@ export const EnvSchema = v.pipe( COLL_2_CONTRACT_TROVE_MANAGER: v.optional(vAddress()), COLL_2_CONTRACT_TROVE_NFT: v.optional(vAddress()), COLL_2_TOKEN_ID: v.optional(CollateralSymbolSchema), - - DEMO_MODE: v.pipe(v.optional(vEnvFlag()), v.transform((value) => value ?? false)), - VERCEL_ANALYTICS: v.pipe(v.optional(v.string()), v.transform((value) => value ?? false)), - WALLET_CONNECT_PROJECT_ID: v.string(), }), v.transform((data) => { const env = { ...data }; @@ -155,32 +202,39 @@ export const EnvSchema = v.pipe( export type Env = v.InferOutput; -const parsedEnv = v.parse(EnvSchema, { +const parsedEnv = v.safeParse(EnvSchema, { APP_VERSION: process.env.APP_VERSION, // set in next.config.js - CHAIN_ID: process.env.NEXT_PUBLIC_CHAIN_ID, - CHAIN_NAME: process.env.NEXT_PUBLIC_CHAIN_NAME, - CHAIN_CURRENCY: process.env.NEXT_PUBLIC_CHAIN_CURRENCY, - CHAIN_RPC_URL: process.env.NEXT_PUBLIC_CHAIN_RPC_URL, + BLOCKING_LIST: process.env.NEXT_PUBLIC_BLOCKING_LIST, + BLOCKING_VPNAPI: process.env.NEXT_PUBLIC_BLOCKING_VPNAPI, CHAIN_BLOCK_EXPLORER: process.env.NEXT_PUBLIC_CHAIN_BLOCK_EXPLORER, CHAIN_CONTRACT_ENS_REGISTRY: process.env.NEXT_PUBLIC_CHAIN_CONTRACT_ENS_REGISTRY, CHAIN_CONTRACT_ENS_RESOLVER: process.env.NEXT_PUBLIC_CHAIN_CONTRACT_ENS_RESOLVER, CHAIN_CONTRACT_MULTICALL: process.env.NEXT_PUBLIC_CHAIN_CONTRACT_MULTICALL, + CHAIN_CURRENCY: process.env.NEXT_PUBLIC_CHAIN_CURRENCY, + CHAIN_ID: process.env.NEXT_PUBLIC_CHAIN_ID, + CHAIN_NAME: process.env.NEXT_PUBLIC_CHAIN_NAME, + CHAIN_RPC_URL: process.env.NEXT_PUBLIC_CHAIN_RPC_URL, + COINGECKO_API_KEY: process.env.NEXT_PUBLIC_COINGECKO_API_KEY, COMMIT_HASH: process.env.COMMIT_HASH, // set in next.config.js - SUBGRAPH_URL: process.env.NEXT_PUBLIC_SUBGRAPH_URL, - DELEGATE_AUTO: process.env.NEXT_PUBLIC_DELEGATE_AUTO, + DEMO_MODE: process.env.NEXT_PUBLIC_DEMO_MODE, + DEPLOYMENT_FLAVOR: process.env.NEXT_PUBLIC_DEPLOYMENT_FLAVOR, + INITIATIVE_UNI_V4_DONATIONS: process.env.NEXT_PUBLIC_INITIATIVE_UNI_V4_DONATIONS, + KNOWN_INITIATIVES_URL: process.env.NEXT_PUBLIC_KNOWN_INITIATIVES_URL, + 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, CONTRACT_BOLD_TOKEN: process.env.NEXT_PUBLIC_CONTRACT_BOLD_TOKEN, CONTRACT_COLLATERAL_REGISTRY: process.env.NEXT_PUBLIC_CONTRACT_COLLATERAL_REGISTRY, CONTRACT_EXCHANGE_HELPERS: process.env.NEXT_PUBLIC_CONTRACT_EXCHANGE_HELPERS, - CONTRACT_HINT_HELPERS: process.env.NEXT_PUBLIC_CONTRACT_HINT_HELPERS, - CONTRACT_MULTI_TROVE_GETTER: process.env.NEXT_PUBLIC_CONTRACT_MULTI_TROVE_GETTER, - CONTRACT_WETH: process.env.NEXT_PUBLIC_CONTRACT_WETH, - CONTRACT_GOVERNANCE: process.env.NEXT_PUBLIC_CONTRACT_GOVERNANCE, - CONTRACT_LQTY_TOKEN: process.env.NEXT_PUBLIC_CONTRACT_LQTY_TOKEN, + 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_WETH: process.env.NEXT_PUBLIC_CONTRACT_WETH, COLL_0_TOKEN_ID: process.env.NEXT_PUBLIC_COLL_0_TOKEN_ID, COLL_1_TOKEN_ID: process.env.NEXT_PUBLIC_COLL_1_TOKEN_ID, @@ -221,14 +275,20 @@ const parsedEnv = v.parse(EnvSchema, { COLL_2_CONTRACT_STABILITY_POOL: process.env.NEXT_PUBLIC_COLL_2_CONTRACT_STABILITY_POOL, COLL_2_CONTRACT_TROVE_MANAGER: process.env.NEXT_PUBLIC_COLL_2_CONTRACT_TROVE_MANAGER, COLL_2_CONTRACT_TROVE_NFT: process.env.NEXT_PUBLIC_COLL_2_CONTRACT_TROVE_NFT, - - DEMO_MODE: process.env.NEXT_PUBLIC_DEMO_MODE, - VERCEL_ANALYTICS: process.env.NEXT_PUBLIC_VERCEL_ANALYTICS, - WALLET_CONNECT_PROJECT_ID: process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID, }); +if (!parsedEnv.success) { + console.error( + "Invalid environment variable(s):", + v.flatten(parsedEnv.issues), + ); + throw new Error("Invalid environment variable(s)"); +} + export const { APP_VERSION, + BLOCKING_LIST, + BLOCKING_VPNAPI, CHAIN_BLOCK_EXPLORER, CHAIN_CONTRACT_ENS_REGISTRY, CHAIN_CONTRACT_ENS_RESOLVER, @@ -237,9 +297,9 @@ export const { CHAIN_ID, CHAIN_NAME, CHAIN_RPC_URL, + COINGECKO_API_KEY, COLLATERAL_CONTRACTS, COMMIT_HASH, - SUBGRAPH_URL, CONTRACT_BOLD_TOKEN, CONTRACT_COLLATERAL_REGISTRY, CONTRACT_EXCHANGE_HELPERS, @@ -252,6 +312,10 @@ export const { CONTRACT_WETH, DELEGATE_AUTO, DEMO_MODE, + DEPLOYMENT_FLAVOR, + INITIATIVE_UNI_V4_DONATIONS, + KNOWN_INITIATIVES_URL, + SUBGRAPH_URL, VERCEL_ANALYTICS, WALLET_CONNECT_PROJECT_ID, -} = parsedEnv; +} = parsedEnv.output; diff --git a/frontend/app/src/form-utils.ts b/frontend/app/src/form-utils.ts index ccbe828c2..36fc5866d 100644 --- a/frontend/app/src/form-utils.ts +++ b/frontend/app/src/form-utils.ts @@ -82,7 +82,7 @@ export function useForm
>>( })); } }, - value: form[name][0], + value: form[name]?.[0] as string, // type guard, should never be undefined }; } @@ -98,7 +98,10 @@ export function useForm>>( setForm((form) => { const newForm: Record> = { ...form }; for (const [name, value] of Object.entries(values)) { - const parser = newForm[name][2]; + const parser = newForm[name]?.[2]; + if (!parser) { + throw new Error(`No parser found for field ${name}`); + } newForm[name] = [value, parser(value), parser]; } return { ...form, ...newForm }; diff --git a/frontend/app/src/formatting.ts b/frontend/app/src/formatting.ts index a798a236f..e0c01eb39 100644 --- a/frontend/app/src/formatting.ts +++ b/frontend/app/src/formatting.ts @@ -126,3 +126,13 @@ export function formatPercentage( }) }%`; } + +export function formatDate(date: Date) { + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + hour: "numeric", + minute: "numeric", + }); +} diff --git a/frontend/app/src/graphql/gql.ts b/frontend/app/src/graphql/gql.ts index 5b2ddddf4..9b0c5555c 100644 --- a/frontend/app/src/graphql/gql.ts +++ b/frontend/app/src/graphql/gql.ts @@ -27,6 +27,8 @@ const documents = { "\n query StabilityPoolEpochScale($id: ID!) {\n stabilityPoolEpochScale(id: $id) {\n id\n B\n S\n }\n }\n": types.StabilityPoolEpochScaleDocument, "\n query InterestBatch($id: ID!) {\n interestBatch(id: $id) {\n collateral {\n collIndex\n }\n batchManager\n debt\n coll\n annualInterestRate\n annualManagementFee\n }\n }\n": types.InterestBatchDocument, "\n query InterestRateBrackets($collId: String!) {\n interestRateBrackets(where: { collateral: $collId }, orderBy: rate) {\n rate\n totalDebt\n }\n }\n": types.InterestRateBracketsDocument, + "\n query GovernanceInitiatives {\n governanceInitiatives {\n id\n }\n }\n": types.GovernanceInitiativesDocument, + "\n query GovernanceUser($id: ID!) {\n governanceUser(id: $id) {\n id\n allocatedLQTY\n stakedLQTY\n stakedOffset\n allocations {\n id\n atEpoch\n vetoLQTY\n voteLQTY\n initiative {\n id\n }\n }\n }\n }\n": types.GovernanceUserDocument, }; /** @@ -77,6 +79,14 @@ export function graphql(source: "\n query InterestBatch($id: ID!) {\n intere * 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 InterestRateBrackets($collId: String!) {\n interestRateBrackets(where: { collateral: $collId }, orderBy: rate) {\n rate\n totalDebt\n }\n }\n"): typeof import('./graphql').InterestRateBracketsDocument; +/** + * 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 GovernanceInitiatives {\n governanceInitiatives {\n id\n }\n }\n"): typeof import('./graphql').GovernanceInitiativesDocument; +/** + * 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 GovernanceUser($id: ID!) {\n governanceUser(id: $id) {\n id\n allocatedLQTY\n stakedLQTY\n stakedOffset\n allocations {\n id\n atEpoch\n vetoLQTY\n voteLQTY\n initiative {\n id\n }\n }\n }\n }\n"): typeof import('./graphql').GovernanceUserDocument; export function graphql(source: string) { diff --git a/frontend/app/src/graphql/graphql.ts b/frontend/app/src/graphql/graphql.ts index 86437ac3b..fc8874e3f 100644 --- a/frontend/app/src/graphql/graphql.ts +++ b/frontend/app/src/graphql/graphql.ts @@ -394,7 +394,7 @@ export enum Collateral_OrderBy { export type GovernanceAllocation = { __typename?: 'GovernanceAllocation'; - atEpoch: Scalars['Int']['output']; + atEpoch: Scalars['BigInt']['output']; id: Scalars['ID']['output']; initiative: GovernanceInitiative; user: GovernanceUser; @@ -406,14 +406,14 @@ export type GovernanceAllocation_Filter = { /** Filter for the block changed event. */ _change_block?: InputMaybe; and?: InputMaybe>>; - atEpoch?: InputMaybe; - atEpoch_gt?: InputMaybe; - atEpoch_gte?: InputMaybe; - atEpoch_in?: InputMaybe>; - atEpoch_lt?: InputMaybe; - atEpoch_lte?: InputMaybe; - atEpoch_not?: InputMaybe; - atEpoch_not_in?: InputMaybe>; + atEpoch?: InputMaybe; + atEpoch_gt?: InputMaybe; + atEpoch_gte?: InputMaybe; + atEpoch_in?: InputMaybe>; + atEpoch_lt?: InputMaybe; + atEpoch_lte?: InputMaybe; + atEpoch_not?: InputMaybe; + atEpoch_not_in?: InputMaybe>; id?: InputMaybe; id_gt?: InputMaybe; id_gte?: InputMaybe; @@ -494,15 +494,13 @@ export enum GovernanceAllocation_OrderBy { InitiativeRegisteredAt = 'initiative__registeredAt', InitiativeRegisteredAtEpoch = 'initiative__registeredAtEpoch', InitiativeRegistrant = 'initiative__registrant', - InitiativeTotalBoldClaimed = 'initiative__totalBoldClaimed', - InitiativeTotalVetos = 'initiative__totalVetos', - InitiativeTotalVotes = 'initiative__totalVotes', InitiativeUnregisteredAt = 'initiative__unregisteredAt', InitiativeUnregisteredAtEpoch = 'initiative__unregisteredAtEpoch', User = 'user', UserAllocatedLqty = 'user__allocatedLQTY', - UserAverageStakingTimestamp = 'user__averageStakingTimestamp', UserId = 'user__id', + UserStakedLqty = 'user__stakedLQTY', + UserStakedOffset = 'user__stakedOffset', VetoLqty = 'vetoLQTY', VoteLqty = 'voteLQTY' } @@ -510,17 +508,14 @@ export enum GovernanceAllocation_OrderBy { export type GovernanceInitiative = { __typename?: 'GovernanceInitiative'; id: Scalars['ID']['output']; - lastClaimEpoch?: Maybe; - lastVoteSnapshotEpoch?: Maybe; + lastClaimEpoch?: Maybe; + lastVoteSnapshotEpoch?: Maybe; lastVoteSnapshotVotes?: Maybe; registeredAt: Scalars['BigInt']['output']; - registeredAtEpoch: Scalars['Int']['output']; + registeredAtEpoch: Scalars['BigInt']['output']; registrant: Scalars['Bytes']['output']; - totalBoldClaimed: Scalars['BigInt']['output']; - totalVetos: Scalars['BigInt']['output']; - totalVotes: Scalars['BigInt']['output']; unregisteredAt?: Maybe; - unregisteredAtEpoch?: Maybe; + unregisteredAtEpoch?: Maybe; }; export type GovernanceInitiative_Filter = { @@ -535,22 +530,22 @@ export type GovernanceInitiative_Filter = { id_lte?: InputMaybe; id_not?: InputMaybe; id_not_in?: InputMaybe>; - lastClaimEpoch?: InputMaybe; - lastClaimEpoch_gt?: InputMaybe; - lastClaimEpoch_gte?: InputMaybe; - lastClaimEpoch_in?: InputMaybe>; - lastClaimEpoch_lt?: InputMaybe; - lastClaimEpoch_lte?: InputMaybe; - lastClaimEpoch_not?: InputMaybe; - lastClaimEpoch_not_in?: InputMaybe>; - lastVoteSnapshotEpoch?: InputMaybe; - lastVoteSnapshotEpoch_gt?: InputMaybe; - lastVoteSnapshotEpoch_gte?: InputMaybe; - lastVoteSnapshotEpoch_in?: InputMaybe>; - lastVoteSnapshotEpoch_lt?: InputMaybe; - lastVoteSnapshotEpoch_lte?: InputMaybe; - lastVoteSnapshotEpoch_not?: InputMaybe; - lastVoteSnapshotEpoch_not_in?: InputMaybe>; + lastClaimEpoch?: InputMaybe; + lastClaimEpoch_gt?: InputMaybe; + lastClaimEpoch_gte?: InputMaybe; + lastClaimEpoch_in?: InputMaybe>; + lastClaimEpoch_lt?: InputMaybe; + lastClaimEpoch_lte?: InputMaybe; + lastClaimEpoch_not?: InputMaybe; + lastClaimEpoch_not_in?: InputMaybe>; + lastVoteSnapshotEpoch?: InputMaybe; + lastVoteSnapshotEpoch_gt?: InputMaybe; + lastVoteSnapshotEpoch_gte?: InputMaybe; + lastVoteSnapshotEpoch_in?: InputMaybe>; + lastVoteSnapshotEpoch_lt?: InputMaybe; + lastVoteSnapshotEpoch_lte?: InputMaybe; + lastVoteSnapshotEpoch_not?: InputMaybe; + lastVoteSnapshotEpoch_not_in?: InputMaybe>; lastVoteSnapshotVotes?: InputMaybe; lastVoteSnapshotVotes_gt?: InputMaybe; lastVoteSnapshotVotes_gte?: InputMaybe; @@ -561,14 +556,14 @@ export type GovernanceInitiative_Filter = { lastVoteSnapshotVotes_not_in?: InputMaybe>; or?: InputMaybe>>; registeredAt?: InputMaybe; - registeredAtEpoch?: InputMaybe; - registeredAtEpoch_gt?: InputMaybe; - registeredAtEpoch_gte?: InputMaybe; - registeredAtEpoch_in?: InputMaybe>; - registeredAtEpoch_lt?: InputMaybe; - registeredAtEpoch_lte?: InputMaybe; - registeredAtEpoch_not?: InputMaybe; - registeredAtEpoch_not_in?: InputMaybe>; + registeredAtEpoch?: InputMaybe; + registeredAtEpoch_gt?: InputMaybe; + registeredAtEpoch_gte?: InputMaybe; + registeredAtEpoch_in?: InputMaybe>; + registeredAtEpoch_lt?: InputMaybe; + registeredAtEpoch_lte?: InputMaybe; + registeredAtEpoch_not?: InputMaybe; + registeredAtEpoch_not_in?: InputMaybe>; registeredAt_gt?: InputMaybe; registeredAt_gte?: InputMaybe; registeredAt_in?: InputMaybe>; @@ -586,39 +581,15 @@ export type GovernanceInitiative_Filter = { registrant_not?: InputMaybe; registrant_not_contains?: InputMaybe; registrant_not_in?: InputMaybe>; - totalBoldClaimed?: InputMaybe; - totalBoldClaimed_gt?: InputMaybe; - totalBoldClaimed_gte?: InputMaybe; - totalBoldClaimed_in?: InputMaybe>; - totalBoldClaimed_lt?: InputMaybe; - totalBoldClaimed_lte?: InputMaybe; - totalBoldClaimed_not?: InputMaybe; - totalBoldClaimed_not_in?: InputMaybe>; - totalVetos?: InputMaybe; - totalVetos_gt?: InputMaybe; - totalVetos_gte?: InputMaybe; - totalVetos_in?: InputMaybe>; - totalVetos_lt?: InputMaybe; - totalVetos_lte?: InputMaybe; - totalVetos_not?: InputMaybe; - totalVetos_not_in?: InputMaybe>; - totalVotes?: InputMaybe; - totalVotes_gt?: InputMaybe; - totalVotes_gte?: InputMaybe; - totalVotes_in?: InputMaybe>; - totalVotes_lt?: InputMaybe; - totalVotes_lte?: InputMaybe; - totalVotes_not?: InputMaybe; - totalVotes_not_in?: InputMaybe>; unregisteredAt?: InputMaybe; - unregisteredAtEpoch?: InputMaybe; - unregisteredAtEpoch_gt?: InputMaybe; - unregisteredAtEpoch_gte?: InputMaybe; - unregisteredAtEpoch_in?: InputMaybe>; - unregisteredAtEpoch_lt?: InputMaybe; - unregisteredAtEpoch_lte?: InputMaybe; - unregisteredAtEpoch_not?: InputMaybe; - unregisteredAtEpoch_not_in?: InputMaybe>; + unregisteredAtEpoch?: InputMaybe; + unregisteredAtEpoch_gt?: InputMaybe; + unregisteredAtEpoch_gte?: InputMaybe; + unregisteredAtEpoch_in?: InputMaybe>; + unregisteredAtEpoch_lt?: InputMaybe; + unregisteredAtEpoch_lte?: InputMaybe; + unregisteredAtEpoch_not?: InputMaybe; + unregisteredAtEpoch_not_in?: InputMaybe>; unregisteredAt_gt?: InputMaybe; unregisteredAt_gte?: InputMaybe; unregisteredAt_in?: InputMaybe>; @@ -636,9 +607,6 @@ export enum GovernanceInitiative_OrderBy { RegisteredAt = 'registeredAt', RegisteredAtEpoch = 'registeredAtEpoch', Registrant = 'registrant', - TotalBoldClaimed = 'totalBoldClaimed', - TotalVetos = 'totalVetos', - TotalVotes = 'totalVotes', UnregisteredAt = 'unregisteredAt', UnregisteredAtEpoch = 'unregisteredAtEpoch' } @@ -648,6 +616,7 @@ export type GovernanceStats = { id: Scalars['ID']['output']; totalInitiatives: Scalars['Int']['output']; totalLQTYStaked: Scalars['BigInt']['output']; + totalOffset: Scalars['BigInt']['output']; }; export type GovernanceStats_Filter = { @@ -679,20 +648,30 @@ export type GovernanceStats_Filter = { totalLQTYStaked_lte?: InputMaybe; totalLQTYStaked_not?: InputMaybe; totalLQTYStaked_not_in?: InputMaybe>; + totalOffset?: InputMaybe; + totalOffset_gt?: InputMaybe; + totalOffset_gte?: InputMaybe; + totalOffset_in?: InputMaybe>; + totalOffset_lt?: InputMaybe; + totalOffset_lte?: InputMaybe; + totalOffset_not?: InputMaybe; + totalOffset_not_in?: InputMaybe>; }; export enum GovernanceStats_OrderBy { Id = 'id', TotalInitiatives = 'totalInitiatives', - TotalLqtyStaked = 'totalLQTYStaked' + TotalLqtyStaked = 'totalLQTYStaked', + TotalOffset = 'totalOffset' } export type GovernanceUser = { __typename?: 'GovernanceUser'; allocatedLQTY: Scalars['BigInt']['output']; allocations: Array; - averageStakingTimestamp: Scalars['BigInt']['output']; id: Scalars['ID']['output']; + stakedLQTY: Scalars['BigInt']['output']; + stakedOffset: Scalars['BigInt']['output']; }; @@ -717,14 +696,6 @@ export type GovernanceUser_Filter = { allocatedLQTY_not_in?: InputMaybe>; allocations_?: InputMaybe; and?: InputMaybe>>; - averageStakingTimestamp?: InputMaybe; - averageStakingTimestamp_gt?: InputMaybe; - averageStakingTimestamp_gte?: InputMaybe; - averageStakingTimestamp_in?: InputMaybe>; - averageStakingTimestamp_lt?: InputMaybe; - averageStakingTimestamp_lte?: InputMaybe; - averageStakingTimestamp_not?: InputMaybe; - averageStakingTimestamp_not_in?: InputMaybe>; id?: InputMaybe; id_gt?: InputMaybe; id_gte?: InputMaybe; @@ -734,13 +705,30 @@ export type GovernanceUser_Filter = { id_not?: InputMaybe; id_not_in?: InputMaybe>; or?: InputMaybe>>; + stakedLQTY?: InputMaybe; + stakedLQTY_gt?: InputMaybe; + stakedLQTY_gte?: InputMaybe; + stakedLQTY_in?: InputMaybe>; + stakedLQTY_lt?: InputMaybe; + stakedLQTY_lte?: InputMaybe; + stakedLQTY_not?: InputMaybe; + stakedLQTY_not_in?: InputMaybe>; + stakedOffset?: InputMaybe; + stakedOffset_gt?: InputMaybe; + stakedOffset_gte?: InputMaybe; + stakedOffset_in?: InputMaybe>; + stakedOffset_lt?: InputMaybe; + stakedOffset_lte?: InputMaybe; + stakedOffset_not?: InputMaybe; + stakedOffset_not_in?: InputMaybe>; }; export enum GovernanceUser_OrderBy { AllocatedLqty = 'allocatedLQTY', Allocations = 'allocations', - AverageStakingTimestamp = 'averageStakingTimestamp', - Id = 'id' + Id = 'id', + StakedLqty = 'stakedLQTY', + StakedOffset = 'stakedOffset' } export type InterestBatch = { @@ -2283,6 +2271,18 @@ export type InterestRateBracketsQueryVariables = Exact<{ export type InterestRateBracketsQuery = { __typename?: 'Query', interestRateBrackets: Array<{ __typename?: 'InterestRateBracket', rate: bigint, totalDebt: bigint }> }; +export type GovernanceInitiativesQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GovernanceInitiativesQuery = { __typename?: 'Query', governanceInitiatives: Array<{ __typename?: 'GovernanceInitiative', id: string }> }; + +export type GovernanceUserQueryVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type GovernanceUserQuery = { __typename?: 'Query', governanceUser?: { __typename?: 'GovernanceUser', id: string, allocatedLQTY: bigint, stakedLQTY: bigint, stakedOffset: bigint, allocations: Array<{ __typename?: 'GovernanceAllocation', id: string, atEpoch: bigint, vetoLQTY: bigint, voteLQTY: bigint, initiative: { __typename?: 'GovernanceInitiative', id: string } }> } | null }; + export class TypedDocumentString extends String implements DocumentTypeDecoration @@ -2507,4 +2507,30 @@ export const InterestRateBracketsDocument = new TypedDocumentString(` totalDebt } } - `) as unknown as TypedDocumentString; \ No newline at end of file + `) as unknown as TypedDocumentString; +export const GovernanceInitiativesDocument = new TypedDocumentString(` + query GovernanceInitiatives { + governanceInitiatives { + id + } +} + `) as unknown as TypedDocumentString; +export const GovernanceUserDocument = new TypedDocumentString(` + query GovernanceUser($id: ID!) { + governanceUser(id: $id) { + id + allocatedLQTY + stakedLQTY + stakedOffset + allocations { + id + atEpoch + vetoLQTY + voteLQTY + initiative { + id + } + } + } +} + `) as unknown as TypedDocumentString; \ No newline at end of file diff --git a/frontend/app/src/liquity-governance.ts b/frontend/app/src/liquity-governance.ts new file mode 100644 index 000000000..3c841d84b --- /dev/null +++ b/frontend/app/src/liquity-governance.ts @@ -0,0 +1,270 @@ +import type { Address, Initiative } from "@/src/types"; +import type { Config as WagmiConfig } from "wagmi"; + +import { getProtocolContract } from "@/src/contracts"; +import { KNOWN_INITIATIVES_URL } from "@/src/env"; +import { useGovernanceInitiatives } from "@/src/subgraph-hooks"; +import { vAddress } from "@/src/valibot-utils"; +import { useQuery } from "@tanstack/react-query"; +import * as v from "valibot"; +import { useReadContract, useReadContracts } from "wagmi"; +import { readContract } from "wagmi/actions"; + +export function useGovernanceState() { + const Governance = getProtocolContract("Governance"); + return useReadContracts({ + contracts: [{ + ...Governance, + functionName: "epochStart", + }, { + ...Governance, + functionName: "getTotalVotesAndState", + }, { + ...Governance, + functionName: "secondsWithinEpoch", + }, { + ...Governance, + functionName: "EPOCH_DURATION", + }, { + ...Governance, + functionName: "EPOCH_VOTING_CUTOFF", + }], + query: { + select: ([ + epochStart_, + totalVotesAndState, + secondsWithinEpoch, + GOVERNANCE_EPOCH_DURATION, + GOVERNANCE_EPOCH_VOTING_CUTOFF, + ]) => { + const epochStart = epochStart_.result ?? 0n; + const epochDuration = GOVERNANCE_EPOCH_DURATION.result ?? 0n; + const epochVotingCutoff = GOVERNANCE_EPOCH_VOTING_CUTOFF.result ?? 0n; + + const period: "cutoff" | "voting" = (secondsWithinEpoch.result ?? 0n) > epochVotingCutoff + ? "cutoff" + : "voting"; + + const seconds = Number(secondsWithinEpoch.result ?? 0n); + const daysLeft = (Number(epochDuration) - seconds) / (24 * 60 * 60); + const daysLeftRounded = Math.ceil(daysLeft); + + return { + countedVoteLQTY: totalVotesAndState.result?.[1].countedVoteLQTY, + countedVoteOffset: totalVotesAndState.result?.[1].countedVoteOffset, + daysLeft, + daysLeftRounded, + epochEnd: epochStart + epochDuration, + epochStart, + period, + secondsWithinEpoch: secondsWithinEpoch.result, + totalVotes: totalVotesAndState.result?.[0], + }; + }, + }, + }); +} + +type InitiativeStatus = + | "nonexistent" + | "warm up" + | "skip" + | "claimable" + | "claimed" + | "disabled" + | "unregisterable"; + +function initiativeStatusFromNumber(status: number): InitiativeStatus { + const statuses: Record = { + 0: "nonexistent", + 1: "warm up", + 2: "skip", + 3: "claimable", + 4: "claimed", + 5: "disabled", + 6: "unregisterable", + }; + return statuses[status] || "nonexistent"; +} + +export function useInitiativeState(initiativeAddress: Address | null) { + const Governance = getProtocolContract("Governance"); + + return useReadContracts({ + contracts: [{ + ...Governance, + functionName: "getInitiativeState", + args: [initiativeAddress ?? "0x"], + }, { + ...Governance, + functionName: "getInitiativeSnapshotAndState", + args: [initiativeAddress ?? "0x"], + }], + query: { + enabled: initiativeAddress !== null, + select: ([initiativeState, snapshotAndState]) => { + return { + status: initiativeStatusFromNumber(initiativeState.result?.[0] ?? 0), + lastEpochClaim: initiativeState.result?.[1], + claimableAmount: initiativeState.result?.[2], + snapshot: snapshotAndState.result?.[0], + state: snapshotAndState.result?.[1], + shouldUpdate: snapshotAndState.result?.[2], + }; + }, + }, + }); +} + +export function useUserStates(account: Address | null) { + const Governance = getProtocolContract("Governance"); + const userStates = useReadContract({ + ...Governance, + functionName: "userStates", + args: [account ?? "0x"], + query: { + enabled: account !== null, + select: (userStates) => ({ + allocatedLQTY: userStates[2], + allocatedOffset: userStates[3], + unallocatedLQTY: userStates[0], + unallocatedOffset: userStates[1], + }), + }, + }); + return userStates; +} + +export async function getUserStates( + wagmiConfig: WagmiConfig, + account: Address, +) { + const Governance = getProtocolContract("Governance"); + const result = await readContract(wagmiConfig, { + ...Governance, + functionName: "userStates", + args: [account], + }); + + return { + allocatedLQTY: result[2], + allocatedOffset: result[3], + unallocatedLQTY: result[0], + unallocatedOffset: result[1], + }; +} + +export function useInitiatives() { + const initiatives = useGovernanceInitiatives(); + const knownInitiatives = useKnownInitiatives(); + return { + ...initiatives, + data: initiatives.data && knownInitiatives.data + ? initiatives.data.map((address): Initiative => { + const knownInitiative = knownInitiatives.data[address]; + return { + address, + name: knownInitiative?.name ?? null, + pairVolume: null, + protocol: knownInitiative?.group ?? null, + tvl: null, + votesDistribution: null, + }; + }) + : null, + }; +} + +const KnownInitiativesSchema = v.record( + v.pipe( + vAddress(), + v.transform((address) => address.toLowerCase()), + ), + v.object({ + name: v.string(), + group: v.string(), + }), +); + +export function useKnownInitiatives() { + return useQuery({ + queryKey: ["knownInitiatives"], + queryFn: async () => { + if (KNOWN_INITIATIVES_URL === undefined) { + throw new Error("KNOWN_INITIATIVES_URL is not defined"); + } + const response = await fetch(KNOWN_INITIATIVES_URL, { + headers: { "Content-Type": "application/json" }, + }); + return v.parse(KnownInitiativesSchema, await response.json()); + }, + enabled: KNOWN_INITIATIVES_URL !== undefined, + }); +} + +// const INITIATIVES_STATIC: Initiative[] = [ +// { +// address: "0x0000000000000000000000000000000000000001", +// name: "WETH-BOLD 0.3%", +// protocol: "Uniswap V4", +// tvl: dn.from(2_420_000, 18), +// pairVolume: dn.from(1_420_000, 18), +// votesDistribution: dn.from(0.35, 18), +// }, +// { +// address: "0x0000000000000000000000000000000000000002", +// name: "WETH-BOLD 0.3%", +// protocol: "Uniswap V4", +// tvl: dn.from(2_420_000, 18), +// pairVolume: dn.from(1_420_000, 18), +// votesDistribution: dn.from(0.20, 18), +// }, +// { +// address: "0x0000000000000000000000000000000000000003", +// name: "crvUSD-BOLD 0.01%", +// protocol: "Curve V2", +// tvl: dn.from(2_420_000, 18), +// pairVolume: dn.from(1_420_000, 18), +// votesDistribution: dn.from(0.15, 18), +// }, +// { +// address: "0x0000000000000000000000000000000000000004", +// name: "3pool-BOLD 0.01%", +// protocol: "Curve V2", +// tvl: dn.from(2_420_000, 18), +// pairVolume: dn.from(1_420_000, 18), +// votesDistribution: dn.from(0.10, 18), +// }, +// { +// address: "0x0000000000000000000000000000000000000005", +// name: "3pool-BOLD 0.01%", +// protocol: "Curve V2", +// tvl: dn.from(2_420_000, 18), +// pairVolume: dn.from(1_420_000, 18), +// votesDistribution: dn.from(0.10, 18), +// }, +// { +// address: "0x0000000000000000000000000000000000000006", +// name: "3pool-BOLD 0.01%", +// protocol: "Curve V2", +// tvl: dn.from(2_420_000, 18), +// pairVolume: dn.from(1_420_000, 18), +// votesDistribution: dn.from(0.05, 18), +// }, +// { +// address: "0x0000000000000000000000000000000000000007", +// name: "DeFi Collective: BOLD incentives on Euler", +// protocol: "0x5305...1418", +// tvl: dn.from(0, 18), +// pairVolume: dn.from(0, 18), +// votesDistribution: dn.from(0.025, 18), +// }, +// { +// address: "0x0000000000000000000000000000000000000008", +// name: "DeFi Collective: BOLD-USDC on Balancer", +// protocol: "0x7179...9f8f", +// tvl: dn.from(0, 18), +// pairVolume: dn.from(0, 18), +// votesDistribution: dn.from(0, 18), +// }, +// ]; diff --git a/frontend/app/src/liquity-leverage.ts b/frontend/app/src/liquity-leverage.ts index 66c406df2..e22b578d0 100644 --- a/frontend/app/src/liquity-leverage.ts +++ b/frontend/app/src/liquity-leverage.ts @@ -1,8 +1,14 @@ -import type { CollIndex, TroveId } from "@/src/types"; +import type { CollIndex, Dnum, TroveId } from "@/src/types"; import type { Config as WagmiConfig } from "wagmi"; -import { CLOSE_FROM_COLLATERAL_SLIPPAGE } from "@/src/constants"; +import { CLOSE_FROM_COLLATERAL_SLIPPAGE, DATA_REFRESH_INTERVAL } from "@/src/constants"; +import { getProtocolContract } from "@/src/contracts"; import { getCollateralContracts } from "@/src/contracts"; +import { dnum18 } from "@/src/dnum-utils"; +import { useDebouncedQueryKey } from "@/src/react-utils"; +import { useWagmiConfig } from "@/src/services/Ethereum"; +import { useQuery } from "@tanstack/react-query"; +import * as dn from "dnum"; import { readContract, readContracts } from "wagmi/actions"; const DECIMAL_PRECISION = 10n ** 18n; @@ -52,7 +58,7 @@ export async function getLeverUpTroveParams( const leverageRatio = BigInt(leverageFactor * 1000) * DECIMAL_PRECISION / 1000n; if (leverageRatio <= currentLR) { - throw new Error(`Leverage ratio should increase: ${leverageRatio} <= ${currentLR}`); + throw new Error(`Multiply ratio should increase: ${leverageRatio} <= ${currentLR}`); } const currentCollAmount = troveData.entireColl; @@ -111,7 +117,7 @@ export async function getLeverDownTroveParams( const leverageRatio = BigInt(leverageFactor * 1000) * DECIMAL_PRECISION / 1000n; if (leverageRatio >= currentLR) { - throw new Error(`Leverage ratio should decrease: ${leverageRatio} >= ${currentLR}`); + throw new Error(`Multiply ratio should decrease: ${leverageRatio} >= ${currentLR}`); } const currentCollAmount = troveData.entireColl; @@ -208,3 +214,62 @@ export async function getCloseFlashLoanAmount( function leverageRatioToCollateralRatio(inputRatio: bigint) { return inputRatio * DECIMAL_PRECISION / (inputRatio - DECIMAL_PRECISION); } + +export function useCheckLeverageSlippage({ + collIndex, + initialDeposit, + leverageFactor, + ownerIndex, +}: { + collIndex: CollIndex; + initialDeposit: Dnum | null; + leverageFactor: number; + ownerIndex: number | null; +}) { + const wagmiConfig = useWagmiConfig(); + const WethContract = getProtocolContract("WETH"); + const ExchangeHelpersContract = getProtocolContract("ExchangeHelpers"); + + const debouncedQueryKey = useDebouncedQueryKey([ + "openLeveragedTroveParams", + collIndex, + String(!initialDeposit || initialDeposit[0]), + leverageFactor, + ownerIndex, + ], 100); + + return useQuery({ + queryKey: debouncedQueryKey, + queryFn: async () => { + const params = initialDeposit && await getOpenLeveragedTroveParams( + collIndex, + 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, + ], + }); + + return dnum18(slippage); + }, + enabled: Boolean( + initialDeposit + && dn.gt(initialDeposit, 0) + && ownerIndex !== null, + ), + refetchInterval: DATA_REFRESH_INTERVAL, + }); +} diff --git a/frontend/app/src/liquity-utils.ts b/frontend/app/src/liquity-utils.ts index 93bf658d3..4afd6d669 100644 --- a/frontend/app/src/liquity-utils.ts +++ b/frontend/app/src/liquity-utils.ts @@ -45,6 +45,9 @@ export function parsePrefixedTroveId(value: PrefixedTroveId): { troveId: TroveId; } { const [collIndex_, troveId] = value.split(":"); + if (!collIndex_ || !troveId) { + throw new Error(`Invalid prefixed trove ID: ${value}`); + } const collIndex = parseInt(collIndex_, 10); if (!isCollIndex(collIndex) || !isTroveId(troveId)) { throw new Error(`Invalid prefixed trove ID: ${value}`); @@ -61,15 +64,13 @@ export function getCollToken(collIndex: CollIndex | null): CollateralToken | nul if (collIndex === null) { return null; } - const collToken = collaterals.map(({ symbol }) => { + return collaterals.map(({ symbol }) => { const collateral = COLLATERALS.find((c) => c.symbol === symbol); if (!collateral) { throw new Error(`Unknown collateral symbol: ${symbol}`); } return collateral; - })[collIndex]; - - return collToken; + })[collIndex] ?? null; } export function getCollIndexFromSymbol(symbol: CollateralSymbol | null): CollIndex | null { @@ -180,12 +181,21 @@ function earnPositionFromGraph( export function useStakePosition(address: null | Address) { const LqtyStaking = getProtocolContract("LqtyStaking"); + const Governance = getProtocolContract("Governance"); + + const userProxyAddress = useReadContract({ + ...Governance, + functionName: "deriveUserProxyAddress", + args: [address ?? "0x"], + query: { enabled: Boolean(address) }, + }); + return useReadContracts({ contracts: [ { ...LqtyStaking, functionName: "stakes", - args: [address ?? "0x"], + args: [userProxyAddress.data ?? "0x"], }, { ...LqtyStaking, @@ -193,11 +203,15 @@ export function useStakePosition(address: null | Address) { }, ], query: { - enabled: Boolean(address), + enabled: Boolean(address) && userProxyAddress.isSuccess, refetchInterval: DATA_REFRESH_INTERVAL, - select: ([deposit_, totalStaked_]): PositionStake => { - const totalStaked = dnum18(totalStaked_); - const deposit = dnum18(deposit_); + select: ([depositResult, totalStakedResult]): PositionStake | null => { + if (depositResult.status === "failure" || totalStakedResult.status === "failure") { + console.log("useStakePosition", depositResult.error, totalStakedResult.error); + return null; + } + const deposit = dnum18(depositResult.result); + const totalStaked = dnum18(totalStakedResult.result); return { type: "stake", deposit, @@ -211,7 +225,6 @@ export function useStakePosition(address: null | Address) { }; }, }, - allowFailure: false, }); } diff --git a/frontend/app/src/permit.ts b/frontend/app/src/permit.ts new file mode 100644 index 000000000..cfd708c6b --- /dev/null +++ b/frontend/app/src/permit.ts @@ -0,0 +1,90 @@ +// see https://eips.ethereum.org/EIPS/eip-2612 + +import type { Address } from "@/src/types"; +import type { Config as WagmiConfig } from "wagmi"; + +import Erc2612 from "@/src/abi/Erc2612"; +import { CHAIN_ID } from "@/src/env"; +import { slice } from "viem"; +import { getBlock, readContract, signTypedData } from "wagmi/actions"; + +export async function signPermit({ + account, + expiresAfter = 60n * 60n * 24n, // 1 day + spender, + token, + value, + wagmiConfig, +}: { + account: Address; + expiresAfter?: bigint; + spender: Address; + token: Address; + value: bigint; + wagmiConfig: WagmiConfig; +}) { + const [block, nonce, name] = await Promise.all([ + getBlock(wagmiConfig), + readContract(wagmiConfig, { + address: token, + abi: Erc2612, + functionName: "nonces", + args: [account], + }), + readContract(wagmiConfig, { + address: token, + abi: Erc2612, + functionName: "name", + }), + ]); + + const deadline = block.timestamp + expiresAfter; + + const signature = await signTypedData(wagmiConfig, { + domain: { + name, + version: "1", + chainId: CHAIN_ID, + verifyingContract: token, + }, + types: { + Permit: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], + }, + primaryType: "Permit", + message: { + owner: account, + spender, + value, + nonce, + deadline, + }, + }); + + return getPermitParamsFromSignature(signature, deadline); +} + +export type PermitParams = { + deadline: bigint; + v: number; + r: `0x${string}`; + s: `0x${string}`; +}; + +// Split signature into r, s, v + attach deadline +function getPermitParamsFromSignature( + signature: `0x${string}`, + deadline: bigint, +): PermitParams { + return { + deadline, + v: parseInt(slice(signature, 64, 65), 16), + r: slice(signature, 0, 32), + s: slice(signature, 32, 64), + }; +} diff --git a/frontend/app/src/react-utils.ts b/frontend/app/src/react-utils.ts index 530361a85..02e1e56f0 100644 --- a/frontend/app/src/react-utils.ts +++ b/frontend/app/src/react-utils.ts @@ -1,15 +1,18 @@ -import { useEffect, useState } from "react"; +import { useCallback, useState } from "react"; +import { debounce } from "./utils"; -// this hook can be used to debounce React Query key changes -export function useDebouncedQueryKey>(values: T, delay: number): T { +export function useDebouncedQueryKey( + values: T, + delay: number, +): T { const [debouncedValue, setDebouncedValue] = useState(values); - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedValue(values); - }, delay); - return () => { - clearTimeout(timer); - }; - }, [...values, delay]); + + const debouncedSet = useCallback( + debounce(setDebouncedValue, delay), + [delay], + ); + + debouncedSet(values); + return debouncedValue; } diff --git a/frontend/app/src/screens/AccountScreen/AccountScreen.tsx b/frontend/app/src/screens/AccountScreen/AccountScreen.tsx index beb173022..1ff51e8d3 100644 --- a/frontend/app/src/screens/AccountScreen/AccountScreen.tsx +++ b/frontend/app/src/screens/AccountScreen/AccountScreen.tsx @@ -203,8 +203,7 @@ function Balance({ writeContract({ abi: LqtyToken.abi, address: LqtyToken.address, - functionName: "mint", - args: [100n * 10n ** 18n], + functionName: "tap", }, { onError: (error) => { alert(error.message); diff --git a/frontend/app/src/screens/BorrowScreen/BorrowScreen.tsx b/frontend/app/src/screens/BorrowScreen/BorrowScreen.tsx index f19ea1b40..276bc5344 100644 --- a/frontend/app/src/screens/BorrowScreen/BorrowScreen.tsx +++ b/frontend/app/src/screens/BorrowScreen/BorrowScreen.tsx @@ -59,7 +59,7 @@ export function BorrowScreen() { // useParams() can return an array but not with the current // routing setup, so we can safely cast it to a string - const collSymbol = String(useParams().collateral ?? contracts.collaterals[0].symbol).toUpperCase(); + const collSymbol = String(useParams().collateral ?? contracts.collaterals[0]?.symbol ?? "").toUpperCase(); if (!isCollateralSymbol(collSymbol)) { throw new Error(`Invalid collateral symbol: ${collSymbol}`); } @@ -78,6 +78,9 @@ export function BorrowScreen() { }); const collateral = collaterals[collIndex]; + if (!collateral) { + throw new Error(`Unknown collateral index: ${collIndex}`); + } const maxCollDeposit = MAX_COLLATERAL_DEPOSITS[collSymbol] ?? null; @@ -106,6 +109,9 @@ export function BorrowScreen() { ] as const))); const collBalance = balances[collateral.symbol]; + if (!collBalance) { + throw new Error(`Unknown collateral symbol: ${collateral.symbol}`); + } const troveCount = useTrovesCount(account.address ?? null, collIndex); @@ -114,7 +120,7 @@ export function BorrowScreen() { debt.isEmpty ? null : debt.parsed, interestRate, collateral.collateralRatio, - collPrice, + collPrice.data ?? null, ); const debtSuggestions = loanDetails.maxDebt @@ -135,10 +141,10 @@ export function BorrowScreen() { } } - const ltv = debt && loanDetails.deposit && collPrice && getLtv( + const ltv = debt && loanDetails.deposit && collPrice.data && getLtv( loanDetails.deposit, debt, - collPrice, + collPrice.data, ); // don’t show if ltv > max LTV @@ -206,15 +212,20 @@ export function BorrowScreen() { icon: , label: name, value: account.isConnected - ? fmtnum(balances[symbol].data ?? 0) + ? fmtnum(balances[symbol]?.data ?? 0) : "−", }))} menuPlacement="end" menuWidth={300} onSelect={(index) => { + const coll = collaterals[index]; + if (!coll) { + throw new Error(`Unknown collateral index: ${index}`); + } + deposit.setValue(""); router.push( - `/borrow/${collaterals[index].symbol.toLowerCase()}`, + `/borrow/${coll.symbol.toLowerCase()}`, { scroll: false }, ); }} @@ -225,8 +236,8 @@ export function BorrowScreen() { placeholder="0.00" secondary={{ start: `$${ - deposit.parsed && collPrice - ? fmtnum(dn.mul(collPrice, deposit.parsed), "2z") + deposit.parsed && collPrice.data + ? fmtnum(dn.mul(collPrice.data, deposit.parsed), "2z") : "0.00" }`, end: maxAmount && dn.gt(maxAmount, 0) && ( @@ -242,9 +253,9 @@ export function BorrowScreen() { /> } footer={{ - start: collPrice && ( + start: collPrice.data && ( ), diff --git a/frontend/app/src/screens/EarnPoolScreen/EarnPoolScreen.tsx b/frontend/app/src/screens/EarnPoolScreen/EarnPoolScreen.tsx index 4b331e2a6..b28ab165b 100644 --- a/frontend/app/src/screens/EarnPoolScreen/EarnPoolScreen.tsx +++ b/frontend/app/src/screens/EarnPoolScreen/EarnPoolScreen.tsx @@ -134,7 +134,11 @@ export function EarnPoolScreen() { { - router.push(`/earn/${collateralSymbol.toLowerCase()}/${TABS[index].action}`, { + const tab = TABS[index]; + if (!tab) { + throw new Error("Invalid tab index"); + } + router.push(`/earn/${collateralSymbol.toLowerCase()}/${tab.action}`, { scroll: false, }); }} diff --git a/frontend/app/src/screens/EarnPoolScreen/PanelClaimRewards.tsx b/frontend/app/src/screens/EarnPoolScreen/PanelClaimRewards.tsx index 91d8afe9a..fc1185cd0 100644 --- a/frontend/app/src/screens/EarnPoolScreen/PanelClaimRewards.tsx +++ b/frontend/app/src/screens/EarnPoolScreen/PanelClaimRewards.tsx @@ -30,14 +30,14 @@ export function PanelClaimRewards({ } const boldPriceUsd = usePrice("BOLD"); - const collPriceUsd = usePrice(collateral.symbol ?? null); + const collPriceUsd = usePrice(collateral.symbol); - const totalRewards = collPriceUsd && boldPriceUsd && dn.add( - dn.mul(position?.rewards?.bold ?? DNUM_0, boldPriceUsd), - dn.mul(position?.rewards?.coll ?? DNUM_0, collPriceUsd), + 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 gasFeeUsd = collPriceUsd && dn.multiply(dn.from(0.0015, 18), collPriceUsd); + const gasFeeUsd = collPriceUsd.data && dn.multiply(dn.from(0.0015, 18), collPriceUsd.data); const allowSubmit = account.isConnected && totalRewards && dn.gt(totalRewards, 0); diff --git a/frontend/app/src/screens/HomeScreen/HomeScreen.tsx b/frontend/app/src/screens/HomeScreen/HomeScreen.tsx index 9fd95795a..44bb22c3a 100644 --- a/frontend/app/src/screens/HomeScreen/HomeScreen.tsx +++ b/frontend/app/src/screens/HomeScreen/HomeScreen.tsx @@ -138,7 +138,7 @@ function BorrowingRow({ /> @@ -152,7 +152,7 @@ function BorrowingRow({ fontSize: 14, })} > - Leverage + Multiply } diff --git a/frontend/app/src/screens/LeverageScreen/LeverageScreen.tsx b/frontend/app/src/screens/LeverageScreen/LeverageScreen.tsx index bc73d0f77..e09556019 100644 --- a/frontend/app/src/screens/LeverageScreen/LeverageScreen.tsx +++ b/frontend/app/src/screens/LeverageScreen/LeverageScreen.tsx @@ -1,7 +1,7 @@ "use client"; import type { DelegateMode } from "@/src/comps/InterestRateField/InterestRateField"; -import type { Address, Dnum, PositionLoanUncommitted } from "@/src/types"; +import type { Address, PositionLoanUncommitted } from "@/src/types"; import type { ComponentPropsWithoutRef, ReactNode } from "react"; import { Amount } from "@/src/comps/Amount/Amount"; @@ -12,7 +12,6 @@ import { LeverageField, useLeverageField } from "@/src/comps/LeverageField/Lever import { RedemptionInfo } from "@/src/comps/RedemptionInfo/RedemptionInfo"; import { Screen } from "@/src/comps/Screen/Screen"; import { - DATA_REFRESH_INTERVAL, ETH_MAX_RESERVE, INTEREST_RATE_DEFAULT, LEVERAGE_MAX_SLIPPAGE, @@ -20,15 +19,14 @@ import { MIN_DEBT, } from "@/src/constants"; import content from "@/src/content"; -import { getContracts, getProtocolContract } from "@/src/contracts"; +import { getContracts } from "@/src/contracts"; import { dnum18, dnumMax } from "@/src/dnum-utils"; import { useInputFieldValue } from "@/src/form-utils"; import { fmtnum } from "@/src/formatting"; -import { getOpenLeveragedTroveParams } from "@/src/liquity-leverage"; +import { useCheckLeverageSlippage } from "@/src/liquity-leverage"; import { getRedemptionRisk } from "@/src/liquity-math"; import { getCollIndexFromSymbol } from "@/src/liquity-utils"; -import { useDebouncedQueryKey } from "@/src/react-utils"; -import { useAccount, useBalance, useWagmiConfig } from "@/src/services/Ethereum"; +import { useAccount, useBalance } from "@/src/services/Ethereum"; import { usePrice } from "@/src/services/Prices"; import { useTransactionFlow } from "@/src/services/TransactionFlow"; import { useTrovesCount } from "@/src/subgraph-hooks"; @@ -48,11 +46,9 @@ import { TokenIcon, VFlex, } from "@liquity2/uikit"; -import { useQuery } from "@tanstack/react-query"; import * as dn from "dnum"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; -import { useReadContract } from "wagmi"; export function LeverageScreen() { const router = useRouter(); @@ -62,7 +58,7 @@ export function LeverageScreen() { // useParams() can return an array but not with the current // routing setup, so we can safely cast it to a string - const collSymbol = String(useParams().collateral ?? contracts.collaterals[0].symbol).toUpperCase(); + const collSymbol = String(useParams().collateral ?? contracts.collaterals[0]?.symbol ?? "").toUpperCase(); if (!isCollateralSymbol(collSymbol)) { throw new Error(`Invalid collateral symbol: ${collSymbol}`); } @@ -81,6 +77,9 @@ export function LeverageScreen() { }); const collToken = collateralTokens[collIndex]; + if (!collToken) { + throw new Error(`Unknown collateral index: ${collIndex}`); + } const balances = Object.fromEntries(collateralTokens.map(({ symbol }) => ( [symbol, useBalance(account.address, symbol)] as const @@ -106,22 +105,22 @@ export function LeverageScreen() { const leverageField = useLeverageField({ depositPreLeverage: depositPreLeverage.parsed, - collPrice: collPrice ?? dn.from(0, 18), + collPrice: collPrice.data ?? dn.from(0, 18), collToken, }); + // reset leverage when collateral changes useEffect(() => { - // reset leverage when collateral changes - leverageField.updateLeverageFactor(leverageField.leverageFactorSuggestions[0]); + leverageField.updateLeverageFactor(leverageField.leverageFactorSuggestions[0] ?? 1.1); }, [collToken.symbol, leverageField.leverageFactorSuggestions]); const redemptionRisk = getRedemptionRisk(interestRate); - const depositUsd = depositPreLeverage.parsed && collPrice && dn.mul( + const depositUsd = depositPreLeverage.parsed && collPrice.data && dn.mul( depositPreLeverage.parsed, - collPrice, + collPrice.data, ); - const collBalance = balances[collToken.symbol].data; + const collBalance = balances[collToken.symbol]?.data; const maxAmount = collBalance && dnumMax( dn.sub(collBalance, collSymbol === "ETH" ? ETH_MAX_RESERVE : 0), // Only keep a reserve for ETH, not LSTs @@ -129,7 +128,7 @@ export function LeverageScreen() { ); const newLoan: PositionLoanUncommitted = { - type: "leverage", + type: "multiply", status: "active", batchManager: interestRateDelegate, borrowed: leverageField.debt ?? dn.from(0, 18), @@ -145,13 +144,16 @@ export function LeverageScreen() { const hasDeposit = Boolean(depositPreLeverage.parsed && dn.gt(depositPreLeverage.parsed, 0)); const leverageSlippage = useCheckLeverageSlippage({ + collIndex, initialDeposit: depositPreLeverage.parsed, leverageFactor: leverageField.leverageFactor, ownerIndex: troveCount.data ?? null, - loan: newLoan, }); - const leverageSlippageElements = useSlippageElements(leverageSlippage, hasDeposit && account.isConnected); + const leverageSlippageElements = useSlippageElements( + leverageSlippage, + hasDeposit && account.isConnected, + ); const hasAllowedSlippage = leverageSlippage.data && dn.lte(leverageSlippage.data, LEVERAGE_MAX_SLIPPAGE); @@ -203,7 +205,7 @@ export function LeverageScreen() { icon: , label: name, value: account.isConnected - ? fmtnum(balances[symbol].data ?? 0) + ? fmtnum(balances[symbol]?.data ?? 0) : "−", }))} menuPlacement="end" @@ -213,7 +215,11 @@ export function LeverageScreen() { depositPreLeverage.setValue(""); depositPreLeverage.focus(); }, 0); - const { symbol } = collateralTokens[index]; + const collToken = collateralTokens[index]; + if (!collToken) { + throw new Error(`Unknown collateral index: ${index}`); + } + const { symbol } = collToken; router.push( `/leverage/${symbol.toLowerCase()}`, { scroll: false }, @@ -241,10 +247,10 @@ export function LeverageScreen() { /> } footer={{ - start: collPrice && ( + start: collPrice.data && ( ), end: ( @@ -404,63 +410,6 @@ export function LeverageScreen() { ); } -export function useCheckLeverageSlippage({ - initialDeposit, - leverageFactor, - loan, - ownerIndex, -}: { - initialDeposit: Dnum | null; - leverageFactor: number; - loan: PositionLoanUncommitted; - ownerIndex: number | null; -}) { - const { collIndex } = loan; - const wagmiConfig = useWagmiConfig(); - const WethContract = getProtocolContract("WETH"); - const ExchangeHelpersContract = getProtocolContract("ExchangeHelpers"); - - const debouncedQueryKey = useDebouncedQueryKey([ - "openLeveragedTroveParams", - collIndex, - String(!initialDeposit || initialDeposit[0]), - leverageFactor, - ownerIndex, - ], 100); - - const openLeveragedTroveParams = useQuery({ - queryKey: debouncedQueryKey, - queryFn: () => ( - initialDeposit && getOpenLeveragedTroveParams( - collIndex, - initialDeposit[0], - leverageFactor, - wagmiConfig, - ) - ), - enabled: Boolean( - initialDeposit - && dn.gt(initialDeposit, 0) - && ownerIndex !== null, - ), - refetchInterval: DATA_REFRESH_INTERVAL, - }); - - const boldAmount = openLeveragedTroveParams.data?.expectedBoldAmount ?? 0n; - const flashLoanAmount = openLeveragedTroveParams.data?.flashLoanAmount ?? 0n; - - return useReadContract({ - abi: ExchangeHelpersContract.abi, - address: ExchangeHelpersContract.address, - functionName: "getCollFromBold", - args: [boldAmount, WethContract.address, flashLoanAmount], - query: { - enabled: Boolean(openLeveragedTroveParams.data), - select: (result) => dnum18(result[1]), - }, - }); -} - function useSlippageElements( leverageSlippage: ReturnType, ready: boolean, @@ -523,7 +472,6 @@ function useSlippageElements( const message = "Calculating slippage…"; return { drawer: null, - // drawer: { mode: "loading", message }, message, mode: "loading", onClose, @@ -550,11 +498,6 @@ function useSlippageElements( return { drawer: null, - // drawer: { - // mode: "success", - // message: `Slippage below threshold (${fmtnum(LEVERAGE_MAX_SLIPPAGE, 2, 100)}%)`, - // autoClose: 700, - // }, onClose, mode: "success", }; diff --git a/frontend/app/src/screens/LoanScreen/LoanScreen.tsx b/frontend/app/src/screens/LoanScreen/LoanScreen.tsx index 16f1a25ff..10906c484 100644 --- a/frontend/app/src/screens/LoanScreen/LoanScreen.tsx +++ b/frontend/app/src/screens/LoanScreen/LoanScreen.tsx @@ -54,14 +54,13 @@ export function LoanScreen() { const loan = useLoanById(paramPrefixedId); const loanMode = storedState.loanModes[paramPrefixedId] ?? loan.data?.type ?? "borrow"; - const collateral = getCollToken(loan.data?.collIndex ?? null); - const collPriceUsd = usePrice(collateral?.symbol ?? null); + const collToken = getCollToken(loan.data?.collIndex ?? null); + const collPriceUsd = usePrice(collToken?.symbol ?? null); const { troveId } = parsePrefixedTroveId(paramPrefixedId); + const fullyRedeemed = loan.data && loan.data.status === "redeemed" && dn.eq(loan.data.borrowed, 0); - const tab = TABS.findIndex(({ id }) => id === action); - - const loadingState = match([loan, collPriceUsd]) + const loadingState = match([loan, collPriceUsd.data ?? null]) .returnType() .with( P.union( @@ -108,8 +107,8 @@ export function LoanScreen() { }} heading={
-
- This loan has been partially redeemed. - -
+ {fullyRedeemed + ? "This loan has been fully redeemed." + : "This loan has been partially redeemed."} +
)} @@ -184,20 +179,27 @@ export function LoanScreen() { panelId: `p-${id}`, tabId: `t-${id}`, }))} - selected={tab} + selected={TABS.findIndex(({ id }) => id === action)} onSelect={(index) => { if (!loan.data) { return; } - const id = getPrefixedTroveId(loan.data.collIndex, loan.data.troveId); + const tab = TABS[index]; + if (!tab) { + throw new Error("Invalid tab index"); + } + const id = getPrefixedTroveId( + loan.data.collIndex, + loan.data.troveId, + ); router.push( - `/loan/${TABS[index].id}?id=${id}`, + `/loan/${tab.id}?id=${id}`, { scroll: false }, ); }} /> {action === "colldebt" && ( - loanMode === "leverage" + loanMode === "multiply" ? : )} @@ -243,11 +245,8 @@ function ClaimCollateralSurplus({ }, }); - const collSurplusUsd = collPriceUsd && collSurplus.data - ? dn.mul( - collSurplus.data, - collPriceUsd, - ) + const collSurplusUsd = collPriceUsd.data && collSurplus.data + ? dn.mul(collSurplus.data, collPriceUsd.data) : null; // const isOwner = account.address && addressesEqual(account.address, loan.borrower); diff --git a/frontend/app/src/screens/LoanScreen/LoanScreenCard.tsx b/frontend/app/src/screens/LoanScreen/LoanScreenCard.tsx index ea2de6cc6..dc34cd3b0 100644 --- a/frontend/app/src/screens/LoanScreen/LoanScreenCard.tsx +++ b/frontend/app/src/screens/LoanScreen/LoanScreenCard.tsx @@ -3,6 +3,7 @@ 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 { ScreenCard } from "@/src/comps/Screen/ScreenCard"; import { LoanStatusTag } from "@/src/comps/Tag/LoanStatusTag"; @@ -34,10 +35,9 @@ import { a, useTransition } from "@react-spring/web"; import { blo } from "blo"; import * as dn from "dnum"; import Image from "next/image"; -import { useEffect, useState } from "react"; import { match, P } from "ts-pattern"; -type LoanMode = "borrow" | "leverage"; +type LoanMode = "borrow" | "multiply"; export function LoanScreenCard({ collateral, @@ -84,10 +84,13 @@ export function LoanScreenCard({ ); const nftUrl = useTroveNftUrl(loan?.collIndex ?? null, troveId); - const title = mode === "leverage" ? "Leverage loan" : "BOLD loan"; + const title = mode === "multiply" ? "Multiply" : "BOLD loan"; + + const fullyRedeemed = loan && loan.status === "redeemed" && dn.eq(loan.borrowed, 0); return ( () .with("loading", () => "loading") @@ -221,6 +224,7 @@ function LoanCardHeading({ title, titleFull, }: { + // whether to inherit the color from the parent inheritColor?: boolean; mode: LoanMode; statusTag?: ReactNode; @@ -253,21 +257,8 @@ function LoanCardHeading({ color: inheritColor ? "inherit" : "var(--color-alt)", }} > - {mode === "leverage" - ? ( - inheritColor - ? - : ( -
- -
- ) - ) + {mode === "multiply" + ? : }
{title}
@@ -344,30 +335,7 @@ function LoanCard({ nftUrl: string | null; onLeverageModeChange: (mode: LoanMode) => void; }) { - const [notifyCopy, setNotifyCopy] = useState(false); - - useEffect(() => { - if (!notifyCopy) { - return; - } - const timeout = setTimeout(() => { - setNotifyCopy(false); - }, 500); - return () => { - clearTimeout(timeout); - }; - }, [notifyCopy]); - - const notifyCopyTransition = useTransition(notifyCopy, { - from: { opacity: 0, transform: "scale(0.9)" }, - enter: { opacity: 1, transform: "scale(1)" }, - leave: { opacity: 0, transform: "scale(1)" }, - config: { - mass: 1, - tension: 2000, - friction: 80, - }, - }); + const copyTransition = useFlashTransition(); const cardTransition = useTransition(props, { keys: (props) => props.mode, @@ -404,6 +372,8 @@ function LoanCard({ const closedOrLiquidated = props.loan.status === "liquidated" || props.loan.status === "closed"; + const fullyRedeemed = props.loan.status === "redeemed" && dn.eq(props.loan.borrowed, 0); + return (
{ - const title = mode === "leverage" ? "Leverage loan" : "BOLD loan"; + const title = mode === "multiply" ? "Multiply" : "BOLD loan"; return ( -

- - : loan.status === "redeemed" - ? - : null} - /> -
+

- {notifyCopyTransition((style, show) => ( - show && ( - - Copied - - ) - ))} - - -

- } - items={[ - { - icon: ( -
- {mode === "leverage" - ? - : } -
- ), - label: mode === "leverage" - ? "Convert to BOLD loan" - : "Convert to leverage loan", - }, - { - icon: ( -
- -
- ), - label: "Copy public link", - }, - { - icon: ( - - ), - label: `Owner ${shortenAddress(loan.borrower, 4)}`, - value: ( -
- -
- ), - }, - { - icon: ( -
- -
- ), - label: "Open NFT", - value: ( -
- -
- ), - }, - ]} - selected={0} - onSelect={(index) => { - if (index === 0) { - onLeverageModeChange(mode === "leverage" ? "borrow" : "leverage"); - } - if (index === 1) { - navigator.clipboard.writeText(window.location.href); - setNotifyCopy(true); - } - if (index === 2) { - window.open(`${CHAIN_BLOCK_EXPLORER?.url}address/${loan.borrower}`); - } - if (index === 3 && nftUrl) { - window.open(nftUrl); - } - }} + + : loan.status === "redeemed" + ? + : null} /> -

- -
+
+ {copyTransition.transition((style, show) => ( + show && ( + + Copied + + ) + ))} + + +
+ } + items={[ + { + icon: ( +
+ {mode === "multiply" + ? + : } +
+ ), + label: mode === "multiply" + ? "Convert to BOLD loan" + : "Convert to Multiply position", + }, + { + icon: ( +
+ +
+ ), + label: "Copy public link", + }, + { + icon: ( + + ), + label: `Owner ${shortenAddress(loan.borrower, 4)}`, + value: ( +
+ +
+ ), + }, + { + icon: ( +
+ +
+ ), + label: "Open NFT", + value: ( +
+ +
+ ), + }, + ]} + selected={0} + onSelect={(index) => { + if (index === 0) { + onLeverageModeChange(mode === "multiply" ? "borrow" : "multiply"); + } + if (index === 1) { + navigator.clipboard.writeText(window.location.href); + copyTransition.flash(); + } + if (index === 2) { + window.open(`${CHAIN_BLOCK_EXPLORER?.url}address/${loan.borrower}`); + } + if (index === 3 && nftUrl) { + window.open(nftUrl); + } + }} + /> +
+
- {mode === "leverage" - ? ( -
-
{fmtnum(loan.deposit)}
- +
+ {mode === "multiply" + ? (
-
- {fmtnum(loan.deposit)}
+ +
+
+ + {loanDetails.status === "underwater" || leverageFactor === null ? INFINITY - : `${roundToDecimal(leverageFactor, 3)}x` - }`} - className={css({ - fontSize: 16, - })} - > - {loanDetails.status === "underwater" || leverageFactor === null - ? INFINITY - : `${roundToDecimal(leverageFactor, 1)}x`} - + : `${roundToDecimal(leverageFactor, 1)}x`} + +
-
- ) - : ( -
- {fmtnum(loan.borrowed)} - -
- )} + ) + : ( +
+ {fmtnum(loan.borrowed)} + +
+ )} +
+
+ {mode === "multiply" ? "Total exposure" : "Total debt"} +
- {closedOrLiquidated + {fullyRedeemed + ? ( +
+ + {fmtnum(loan.deposit)} {collateral.name} + + + {fmtnum(loan.interestRate, 2, 100)}% + + + + + {formatRisk(redemptionRisk)} + + +
+ ) + : closedOrLiquidated ? (
- N/A + N/A N/A N/A N/A @@ -723,10 +728,9 @@ function LoanCard({ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 12, - paddingTop: 32, })} > - {mode === "leverage" + {mode === "multiply" ? ( - + {!claimOnly && ( + - {fmtnum(amountToRepay)} -
- ({ - icon: , - label: ( - <> - {repayToken.name} - + {fmtnum(amountToRepay)} + + ({ + icon: , + label: ( + <> + {repayToken.name} + + {repayToken.symbol === "BOLD" ? " account" : " loan"} + + + ), + })} + items={(["BOLD", collToken.symbol] as const).map((symbol) => ({ + icon: , + label: ( +
- {repayToken.symbol === "BOLD" ? " account" : " loan"} - - - ), - })} - items={(["BOLD", collToken.symbol] as const).map((symbol) => ({ - icon: , - label: ( -
- {TOKENS_BY_SYMBOL[symbol].name} {symbol === "BOLD" ? "(account)" : "(loan collateral)"} -
- ), - value: symbol === "BOLD" ? fmtnum(boldBalance.data) : null, - }))} - menuWidth={300} - menuPlacement="end" - onSelect={setRepayDropdownIndex} - selected={repayDropdownIndex} - /> -
- } - footer={{ - start: ( - - ), - }} - /> + {TOKENS_BY_SYMBOL[symbol].name} {symbol === "BOLD" ? "(account)" : "(loan collateral)"} + + ), + value: symbol === "BOLD" ? fmtnum(boldBalance.data) : null, + }))} + menuWidth={300} + menuPlacement="end" + onSelect={setRepayDropdownIndex} + selected={repayDropdownIndex} + /> + + } + footer={{ + start: ( + + ), + }} + /> + )} - {repayToken.symbol === "BOLD" + {claimOnly + ? content.closeLoan.claimOnly + : repayToken.symbol === "BOLD" ? content.closeLoan.repayWithBoldMessage : content.closeLoan.repayWithCollateralMessage} @@ -245,7 +255,9 @@ export function PanelClosePosition({