diff --git a/.github/workflows/cadence_tests.yml b/.github/workflows/cadence_tests.yml index ceec0582..f3e23728 100644 --- a/.github/workflows/cadence_tests.yml +++ b/.github/workflows/cadence_tests.yml @@ -4,9 +4,11 @@ on: push: branches: - main + - feature/* pull_request: branches: - main + - feature/* jobs: tests: @@ -27,8 +29,15 @@ jobs: key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go- + - name: Cache Flow Emulator Fork Data + uses: actions/cache@v4 + with: + path: .flow-fork-cache + key: ${{ runner.os }}-flow-emulator-fork-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-flow-emulator-fork- - name: Install Flow CLI - run: sh -ci "$(curl -fsSL https://raw.githubusercontent.com/onflow/flow-cli/master/install.sh)" + run: sh -ci "$(curl -fsSL https://raw.githubusercontent.com/onflow/flow-cli/master/install.sh)" -- v2.14.2-evm-manipulation-poc.1 - name: Flow CLI Version run: flow version - name: Update PATH diff --git a/.github/workflows/e2e_tests.yml b/.github/workflows/e2e_tests.yml index d2504456..f349df89 100644 --- a/.github/workflows/e2e_tests.yml +++ b/.github/workflows/e2e_tests.yml @@ -4,9 +4,11 @@ on: push: branches: - main + - feature/* pull_request: branches: - main + - feature/* jobs: e2e-tests: @@ -28,7 +30,7 @@ jobs: restore-keys: | ${{ runner.os }}-go- - name: Install Flow CLI - run: sh -ci "$(curl -fsSL https://raw.githubusercontent.com/onflow/flow-cli/master/install.sh)" + run: sh -ci "$(curl -fsSL https://raw.githubusercontent.com/onflow/flow-cli/master/install.sh)" -- v2.14.2-evm-manipulation-poc.1 - name: Flow CLI Version run: flow version - name: Update PATH diff --git a/.github/workflows/incrementfi_tests.yml b/.github/workflows/incrementfi_tests.yml index 647d1cd4..98d3191e 100644 --- a/.github/workflows/incrementfi_tests.yml +++ b/.github/workflows/incrementfi_tests.yml @@ -4,9 +4,11 @@ on: push: branches: - main + - feature/* pull_request: branches: - main + - feature/* jobs: tests: @@ -18,7 +20,7 @@ jobs: token: ${{ secrets.GH_PAT }} submodules: recursive - name: Install Flow CLI - run: sh -ci "$(curl -fsSL https://raw.githubusercontent.com/onflow/flow-cli/master/install.sh)" + run: sh -ci "$(curl -fsSL https://raw.githubusercontent.com/onflow/flow-cli/master/install.sh)" -- v2.14.2-evm-manipulation-poc.1 - name: Flow CLI Version run: flow version - name: Update PATH diff --git a/.github/workflows/punchswap.yml b/.github/workflows/punchswap.yml index a7591245..47f38a7c 100644 --- a/.github/workflows/punchswap.yml +++ b/.github/workflows/punchswap.yml @@ -4,9 +4,11 @@ on: push: branches: - main + - feature/* pull_request: branches: - main + - feature/* jobs: tests: @@ -24,7 +26,7 @@ jobs: cache-dependency-path: | **/go.sum - name: Install Flow CLI - run: sh -ci "$(curl -fsSL https://raw.githubusercontent.com/onflow/flow-cli/master/install.sh)" + run: sh -ci "$(curl -fsSL https://raw.githubusercontent.com/onflow/flow-cli/master/install.sh)" -- v2.14.2-evm-manipulation-poc.1 - name: Flow CLI Version run: flow version - name: Update PATH diff --git a/.github/workflows/scheduled_rebalance_tests.yml b/.github/workflows/scheduled_rebalance_tests.yml index d504ae69..6c226f16 100644 --- a/.github/workflows/scheduled_rebalance_tests.yml +++ b/.github/workflows/scheduled_rebalance_tests.yml @@ -4,10 +4,11 @@ on: push: branches: - main - - scheduled-rebalancing + - feature/* pull_request: branches: - main + - feature/* jobs: scheduled-rebalance-tests: @@ -29,7 +30,7 @@ jobs: restore-keys: | ${{ runner.os }}-go- - name: Install Flow CLI - run: sh -ci "$(curl -fsSL https://raw.githubusercontent.com/onflow/flow-cli/master/install.sh)" + run: sh -ci "$(curl -fsSL https://raw.githubusercontent.com/onflow/flow-cli/master/install.sh)" -- v2.14.2-evm-manipulation-poc.1 - name: Flow CLI Version run: flow version - name: Update PATH diff --git a/cadence/contracts/mocks/EVM.cdc b/cadence/contracts/mocks/EVM.cdc new file mode 100644 index 00000000..f62e4c9f --- /dev/null +++ b/cadence/contracts/mocks/EVM.cdc @@ -0,0 +1,1000 @@ +import Crypto +import "NonFungibleToken" +import "FungibleToken" +import "FlowToken" + +access(all) +contract EVM { + + // Entitlements enabling finer-grained access control on a CadenceOwnedAccount + access(all) entitlement Validate + access(all) entitlement Withdraw + access(all) entitlement Call + access(all) entitlement Deploy + access(all) entitlement Owner + access(all) entitlement Bridge + + /// Block executed event is emitted when a new block is created, + /// which always happens when a transaction is executed. + access(all) + event BlockExecuted( + // height or number of the block + height: UInt64, + // hash of the block + hash: [UInt8; 32], + // timestamp of the block creation + timestamp: UInt64, + // total Flow supply + totalSupply: Int, + // all gas used in the block by transactions included + totalGasUsed: UInt64, + // parent block hash + parentHash: [UInt8; 32], + // root hash of all the transaction receipts + receiptRoot: [UInt8; 32], + // root hash of all the transaction hashes + transactionHashRoot: [UInt8; 32], + /// value returned for PREVRANDAO opcode + prevrandao: [UInt8; 32], + ) + + /// Transaction executed event is emitted every time a transaction + /// is executed by the EVM (even if failed). + access(all) + event TransactionExecuted( + // hash of the transaction + hash: [UInt8; 32], + // index of the transaction in a block + index: UInt16, + // type of the transaction + type: UInt8, + // RLP encoded transaction payload + payload: [UInt8], + // code indicating a specific validation (201-300) or execution (301-400) error + errorCode: UInt16, + // a human-readable message about the error (if any) + errorMessage: String, + // the amount of gas transaction used + gasConsumed: UInt64, + // if transaction was a deployment contains a newly deployed contract address + contractAddress: String, + // RLP encoded logs + logs: [UInt8], + // block height in which transaction was included + blockHeight: UInt64, + /// captures the hex encoded data that is returned from + /// the evm. For contract deployments + /// it returns the code deployed to + /// the address provided in the contractAddress field. + /// in case of revert, the smart contract custom error message + /// is also returned here (see EIP-140 for more details). + returnedData: [UInt8], + /// captures the input and output of the calls (rlp encoded) to the extra + /// precompiled contracts (e.g. Cadence Arch) during the transaction execution. + /// This data helps to replay the transactions without the need to + /// have access to the full cadence state data. + precompiledCalls: [UInt8], + /// stateUpdateChecksum provides a mean to validate + /// the updates to the storage when re-executing a transaction off-chain. + stateUpdateChecksum: [UInt8; 4] + ) + + access(all) + event CadenceOwnedAccountCreated(address: String) + + /// FLOWTokensDeposited is emitted when FLOW tokens is bridged + /// into the EVM environment. Note that this event is not emitted + /// for transfer of flow tokens between two EVM addresses. + /// Similar to the FungibleToken.Deposited event + /// this event includes a depositedUUID that captures the + /// uuid of the source vault. + access(all) + event FLOWTokensDeposited( + address: String, + amount: UFix64, + depositedUUID: UInt64, + balanceAfterInAttoFlow: UInt + ) + + /// FLOWTokensWithdrawn is emitted when FLOW tokens are bridged + /// out of the EVM environment. Note that this event is not emitted + /// for transfer of flow tokens between two EVM addresses. + /// similar to the FungibleToken.Withdrawn events + /// this event includes a withdrawnUUID that captures the + /// uuid of the returning vault. + access(all) + event FLOWTokensWithdrawn( + address: String, + amount: UFix64, + withdrawnUUID: UInt64, + balanceAfterInAttoFlow: UInt + ) + + /// BridgeAccessorUpdated is emitted when the BridgeAccessor Capability + /// is updated in the stored BridgeRouter along with identifying + /// information about both. + access(all) + event BridgeAccessorUpdated( + routerType: Type, + routerUUID: UInt64, + routerAddress: Address, + accessorType: Type, + accessorUUID: UInt64, + accessorAddress: Address + ) + + /// EVMAddress is an EVM-compatible address + access(all) + struct EVMAddress { + + /// Bytes of the address + access(all) + let bytes: [UInt8; 20] + + /// Constructs a new EVM address from the given byte representation + view init(bytes: [UInt8; 20]) { + self.bytes = bytes + } + + /// Balance of the address + access(all) + view fun balance(): Balance { + let balance = InternalEVM.balance( + address: self.bytes + ) + return Balance(attoflow: balance) + } + + /// Nonce of the address + access(all) + fun nonce(): UInt64 { + return InternalEVM.nonce( + address: self.bytes + ) + } + + /// Code of the address + access(all) + fun code(): [UInt8] { + return InternalEVM.code( + address: self.bytes + ) + } + + /// CodeHash of the address + access(all) + fun codeHash(): [UInt8] { + return InternalEVM.codeHash( + address: self.bytes + ) + } + + /// Deposits the given vault into the EVM account with the given address + access(all) + fun deposit(from: @FlowToken.Vault) { + let amount = from.balance + if amount == 0.0 { + panic("calling deposit function with an empty vault is not allowed") + } + let depositedUUID = from.uuid + InternalEVM.deposit( + from: <-from, + to: self.bytes + ) + emit FLOWTokensDeposited( + address: self.toString(), + amount: amount, + depositedUUID: depositedUUID, + balanceAfterInAttoFlow: self.balance().attoflow + ) + } + + /// Serializes the address to a hex string without the 0x prefix + /// Future implementations should pass data to InternalEVM for native serialization + access(all) + view fun toString(): String { + return String.encodeHex(self.bytes.toVariableSized()) + } + + /// Compares the address with another address + access(all) + view fun equals(_ other: EVMAddress): Bool { + return self.bytes == other.bytes + } + } + + /// EVMBytes is a type wrapper used for ABI encoding/decoding into + /// Solidity `bytes` type + access(all) + struct EVMBytes { + + /// Byte array representing the `bytes` value + access(all) + let value: [UInt8] + + view init(value: [UInt8]) { + self.value = value + } + } + + /// EVMBytes4 is a type wrapper used for ABI encoding/decoding into + /// Solidity `bytes4` type + access(all) + struct EVMBytes4 { + + /// Byte array representing the `bytes4` value + access(all) + let value: [UInt8; 4] + + view init(value: [UInt8; 4]) { + self.value = value + } + } + + /// EVMBytes32 is a type wrapper used for ABI encoding/decoding into + /// Solidity `bytes32` type + access(all) + struct EVMBytes32 { + + /// Byte array representing the `bytes32` value + access(all) + let value: [UInt8; 32] + + view init(value: [UInt8; 32]) { + self.value = value + } + } + + /// Converts a hex string to an EVM address if the string is a valid hex string + /// Future implementations should pass data to InternalEVM for native deserialization + access(all) + fun addressFromString(_ asHex: String): EVMAddress { + pre { + asHex.length == 40 || asHex.length == 42: "Invalid hex string length for an EVM address" + } + // Strip the 0x prefix if it exists + var withoutPrefix = (asHex[1] == "x" ? asHex.slice(from: 2, upTo: asHex.length) : asHex).toLower() + let bytes = withoutPrefix.decodeHex().toConstantSized<[UInt8; 20]>()! + return EVMAddress(bytes: bytes) + } + + access(all) + struct Balance { + + /// The balance in atto-FLOW + /// Atto-FLOW is the smallest denomination of FLOW (1e18 FLOW) + /// that is used to store account balances inside EVM + /// similar to the way WEI is used to store ETH divisible to 18 decimal places. + access(all) + var attoflow: UInt + + /// Constructs a new balance + access(all) + view init(attoflow: UInt) { + self.attoflow = attoflow + } + + /// Sets the balance by a UFix64 (8 decimal points), the format + /// that is used in Cadence to store FLOW tokens. + access(all) + fun setFLOW(flow: UFix64){ + self.attoflow = InternalEVM.castToAttoFLOW(balance: flow) + } + + /// Casts the balance to a UFix64 (rounding down) + /// Warning! casting a balance to a UFix64 which supports a lower level of precision + /// (8 decimal points in compare to 18) might result in rounding down error. + /// Use the toAttoFlow function if you care need more accuracy. + access(all) + view fun inFLOW(): UFix64 { + return InternalEVM.castToFLOW(balance: self.attoflow) + } + + /// Returns the balance in Atto-FLOW + access(all) + view fun inAttoFLOW(): UInt { + return self.attoflow + } + + /// Returns true if the balance is zero + access(all) + fun isZero(): Bool { + return self.attoflow == 0 + } + } + + /// reports the status of evm execution. + access(all) enum Status: UInt8 { + /// is (rarely) returned when status is unknown + /// and something has gone very wrong. + access(all) case unknown + + /// is returned when execution of an evm transaction/call + /// has failed at the validation step (e.g. nonce mismatch). + /// An invalid transaction/call is rejected to be executed + /// or be included in a block. + access(all) case invalid + + /// is returned when execution of an evm transaction/call + /// has been successful but the vm has reported an error as + /// the outcome of execution (e.g. running out of gas). + /// A failed tx/call is included in a block. + /// Note that resubmission of a failed transaction would + /// result in invalid status in the second attempt, given + /// the nonce would be come invalid. + access(all) case failed + + /// is returned when execution of an evm transaction/call + /// has been successful and no error is reported by the vm. + access(all) case successful + } + + /// reports the outcome of evm transaction/call execution attempt + access(all) struct Result { + /// status of the execution + access(all) + let status: Status + + /// error code (error code zero means no error) + access(all) + let errorCode: UInt64 + + /// error message + access(all) + let errorMessage: String + + /// returns the amount of gas metered during + /// evm execution + access(all) + let gasUsed: UInt64 + + /// returns the data that is returned from + /// the evm for the call. For coa.deploy + /// calls it returns the code deployed to + /// the address provided in the contractAddress field. + /// in case of revert, the smart contract custom error message + /// is also returned here (see EIP-140 for more details). + access(all) + let data: [UInt8] + + /// returns the newly deployed contract address + /// if the transaction caused such a deployment + /// otherwise the value is nil. + access(all) + let deployedContract: EVMAddress? + + init( + status: Status, + errorCode: UInt64, + errorMessage: String, + gasUsed: UInt64, + data: [UInt8], + contractAddress: [UInt8; 20]? + ) { + self.status = status + self.errorCode = errorCode + self.errorMessage = errorMessage + self.gasUsed = gasUsed + self.data = data + + if let addressBytes = contractAddress { + self.deployedContract = EVMAddress(bytes: addressBytes) + } else { + self.deployedContract = nil + } + } + } + + access(all) + resource interface Addressable { + /// The EVM address + access(all) + view fun address(): EVMAddress + } + + access(all) + resource CadenceOwnedAccount: Addressable { + + access(self) + var addressBytes: [UInt8; 20] + + init() { + // address is initially set to zero + // but updated through initAddress later + // we have to do this since we need resource id (uuid) + // to calculate the EVM address for this cadence owned account + self.addressBytes = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + } + + access(contract) + fun initAddress(addressBytes: [UInt8; 20]) { + // only allow set address for the first time + // check address is empty + for item in self.addressBytes { + assert(item == 0, message: "address byte is not empty") + } + self.addressBytes = addressBytes + } + + /// The EVM address of the cadence owned account + access(all) + view fun address(): EVMAddress { + // Always create a new EVMAddress instance + return EVMAddress(bytes: self.addressBytes) + } + + /// Get balance of the cadence owned account + access(all) + view fun balance(): Balance { + return self.address().balance() + } + + /// Deposits the given vault into the cadence owned account's balance + access(all) + fun deposit(from: @FlowToken.Vault) { + self.address().deposit(from: <-from) + } + + /// The EVM address of the cadence owned account behind an entitlement, acting as proof of access + access(Owner | Validate) + view fun protectedAddress(): EVMAddress { + return self.address() + } + + /// Withdraws the balance from the cadence owned account's balance + /// Note that amounts smaller than 10nF (10e-8) can't be withdrawn + /// given that Flow Token Vaults use UFix64s to store balances. + /// If the given balance conversion to UFix64 results in + /// rounding error, this function would fail. + access(Owner | Withdraw) + fun withdraw(balance: Balance): @FlowToken.Vault { + if balance.isZero() { + panic("calling withdraw function with zero balance is not allowed") + } + let vault <- InternalEVM.withdraw( + from: self.addressBytes, + amount: balance.attoflow + ) as! @FlowToken.Vault + emit FLOWTokensWithdrawn( + address: self.address().toString(), + amount: balance.inFLOW(), + withdrawnUUID: vault.uuid, + balanceAfterInAttoFlow: self.balance().attoflow + ) + return <-vault + } + + /// Deploys a contract to the EVM environment. + /// Returns the result which contains address of + /// the newly deployed contract + access(Owner | Deploy) + fun deploy( + code: [UInt8], + gasLimit: UInt64, + value: Balance + ): Result { + return InternalEVM.deploy( + from: self.addressBytes, + code: code, + gasLimit: gasLimit, + value: value.attoflow + ) as! Result + } + + /// Calls a function with the given data. + /// The execution is limited by the given amount of gas + access(Owner | Call) + fun call( + to: EVMAddress, + data: [UInt8], + gasLimit: UInt64, + value: Balance + ): Result { + return InternalEVM.call( + from: self.addressBytes, + to: to.bytes, + data: data, + gasLimit: gasLimit, + value: value.attoflow + ) as! Result + } + + /// Calls a contract function with the given data. + /// The execution is limited by the given amount of gas. + /// The transaction state changes are not persisted. + access(all) + fun dryCall( + to: EVMAddress, + data: [UInt8], + gasLimit: UInt64, + value: Balance, + ): Result { + return InternalEVM.dryCall( + from: self.addressBytes, + to: to.bytes, + data: data, + gasLimit: gasLimit, + value: value.attoflow + ) as! Result + } + + /// Bridges the given NFT to the EVM environment, requiring a Provider from which to withdraw a fee to fulfill + /// the bridge request + access(all) + fun depositNFT( + nft: @{NonFungibleToken.NFT}, + feeProvider: auth(FungibleToken.Withdraw) &{FungibleToken.Provider} + ) { + EVM.borrowBridgeAccessor().depositNFT(nft: <-nft, to: self.address(), feeProvider: feeProvider) + } + + /// Bridges the given NFT from the EVM environment, requiring a Provider from which to withdraw a fee to fulfill + /// the bridge request. Note: the caller should own the requested NFT in EVM + access(Owner | Bridge) + fun withdrawNFT( + type: Type, + id: UInt256, + feeProvider: auth(FungibleToken.Withdraw) &{FungibleToken.Provider} + ): @{NonFungibleToken.NFT} { + return <- EVM.borrowBridgeAccessor().withdrawNFT( + caller: &self as auth(Call) &CadenceOwnedAccount, + type: type, + id: id, + feeProvider: feeProvider + ) + } + + /// Bridges the given Vault to the EVM environment, requiring a Provider from which to withdraw a fee to fulfill + /// the bridge request + access(all) + fun depositTokens( + vault: @{FungibleToken.Vault}, + feeProvider: auth(FungibleToken.Withdraw) &{FungibleToken.Provider} + ) { + EVM.borrowBridgeAccessor().depositTokens(vault: <-vault, to: self.address(), feeProvider: feeProvider) + } + + /// Bridges the given fungible tokens from the EVM environment, requiring a Provider from which to withdraw a + /// fee to fulfill the bridge request. Note: the caller should own the requested tokens & sufficient balance of + /// requested tokens in EVM + access(Owner | Bridge) + fun withdrawTokens( + type: Type, + amount: UInt256, + feeProvider: auth(FungibleToken.Withdraw) &{FungibleToken.Provider} + ): @{FungibleToken.Vault} { + return <- EVM.borrowBridgeAccessor().withdrawTokens( + caller: &self as auth(Call) &CadenceOwnedAccount, + type: type, + amount: amount, + feeProvider: feeProvider + ) + } + } + + /// Creates a new cadence owned account + access(all) + fun createCadenceOwnedAccount(): @CadenceOwnedAccount { + let acc <-create CadenceOwnedAccount() + let addr = InternalEVM.createCadenceOwnedAccount(uuid: acc.uuid) + acc.initAddress(addressBytes: addr) + + emit CadenceOwnedAccountCreated(address: acc.address().toString()) + return <-acc + } + + /// Runs an a RLP-encoded EVM transaction, deducts the gas fees, + /// and deposits the gas fees into the provided coinbase address. + access(all) + fun run(tx: [UInt8], coinbase: EVMAddress): Result { + return InternalEVM.run( + tx: tx, + coinbase: coinbase.bytes + ) as! Result + } + + /// mustRun runs the transaction using EVM.run yet it + /// rollback if the tx execution status is unknown or invalid. + /// Note that this method does not rollback if transaction + /// is executed but an vm error is reported as the outcome + /// of the execution (status: failed). + access(all) + fun mustRun(tx: [UInt8], coinbase: EVMAddress): Result { + let runResult = self.run(tx: tx, coinbase: coinbase) + assert( + runResult.status == Status.failed || runResult.status == Status.successful, + message: "tx is not valid for execution" + ) + return runResult + } + + /// Simulates running unsigned RLP-encoded transaction using + /// the from address as the signer. + /// The transaction state changes are not persisted. + /// This is useful for gas estimation or calling view contract functions. + access(all) + fun dryRun(tx: [UInt8], from: EVMAddress): Result { + return InternalEVM.dryRun( + tx: tx, + from: from.bytes, + ) as! Result + } + + /// Calls a contract function with the given data. + /// The execution is limited by the given amount of gas. + /// The transaction state changes are not persisted. + access(all) + fun dryCall( + from: EVMAddress, + to: EVMAddress, + data: [UInt8], + gasLimit: UInt64, + value: Balance, + ): Result { + return InternalEVM.dryCall( + from: from.bytes, + to: to.bytes, + data: data, + gasLimit: gasLimit, + value: value.attoflow + ) as! Result + } + + /// Runs a batch of RLP-encoded EVM transactions, deducts the gas fees, + /// and deposits the gas fees into the provided coinbase address. + /// An invalid transaction is not executed and not included in the block. + access(all) + fun batchRun(txs: [[UInt8]], coinbase: EVMAddress): [Result] { + return InternalEVM.batchRun( + txs: txs, + coinbase: coinbase.bytes, + ) as! [Result] + } + + access(all) + fun encodeABI(_ values: [AnyStruct]): [UInt8] { + return InternalEVM.encodeABI(values) + } + + access(all) + fun decodeABI(types: [Type], data: [UInt8]): [AnyStruct] { + return InternalEVM.decodeABI(types: types, data: data) + } + + access(all) + fun encodeABIWithSignature( + _ signature: String, + _ values: [AnyStruct] + ): [UInt8] { + let methodID = HashAlgorithm.KECCAK_256.hash( + signature.utf8 + ).slice(from: 0, upTo: 4) + let arguments = InternalEVM.encodeABI(values) + + return methodID.concat(arguments) + } + + access(all) + fun decodeABIWithSignature( + _ signature: String, + types: [Type], + data: [UInt8] + ): [AnyStruct] { + let methodID = HashAlgorithm.KECCAK_256.hash( + signature.utf8 + ).slice(from: 0, upTo: 4) + + for byte in methodID { + if byte != data.removeFirst() { + panic("signature mismatch") + } + } + + return InternalEVM.decodeABI(types: types, data: data) + } + + /// ValidationResult returns the result of COA ownership proof validation + access(all) + struct ValidationResult { + access(all) + let isValid: Bool + + access(all) + let problem: String? + + init(isValid: Bool, problem: String?) { + self.isValid = isValid + self.problem = problem + } + } + + /// validateCOAOwnershipProof validates a COA ownership proof + access(all) + fun validateCOAOwnershipProof( + address: Address, + path: PublicPath, + signedData: [UInt8], + keyIndices: [UInt64], + signatures: [[UInt8]], + evmAddress: [UInt8; 20] + ): ValidationResult { + // make signature set first + // check number of signatures matches number of key indices + if keyIndices.length != signatures.length { + return ValidationResult( + isValid: false, + problem: "key indices size doesn't match the signatures" + ) + } + + // fetch account + let acc = getAccount(address) + + var signatureSet: [Crypto.KeyListSignature] = [] + let keyList = Crypto.KeyList() + var keyListLength = 0 + let seenAccountKeyIndices: {Int: Int} = {} + for signatureIndex, signature in signatures{ + // index of the key on the account + let accountKeyIndex = Int(keyIndices[signatureIndex]!) + // index of the key in the key list + var keyListIndex = 0 + + if !seenAccountKeyIndices.containsKey(accountKeyIndex) { + // fetch account key with accountKeyIndex + if let key = acc.keys.get(keyIndex: accountKeyIndex) { + if key.isRevoked { + return ValidationResult( + isValid: false, + problem: "account key is revoked" + ) + } + + keyList.add( + key.publicKey, + hashAlgorithm: key.hashAlgorithm, + // normalization factor. We need to divide by 1000 because the + // `Crypto.KeyList.verify()` function expects the weight to be + // in the range [0, 1]. 1000 is the key weight threshold. + weight: key.weight / 1000.0, + ) + + keyListIndex = keyListLength + keyListLength = keyListLength + 1 + seenAccountKeyIndices[accountKeyIndex] = keyListIndex + } else { + return ValidationResult( + isValid: false, + problem: "invalid key index" + ) + } + } else { + // if we have already seen this accountKeyIndex, use the keyListIndex + // that was previously assigned to it + // `Crypto.KeyList.verify()` knows how to handle duplicate keys + keyListIndex = seenAccountKeyIndices[accountKeyIndex]! + } + + signatureSet.append(Crypto.KeyListSignature( + keyIndex: keyListIndex, + signature: signature + )) + } + + let isValid = keyList.verify( + signatureSet: signatureSet, + signedData: signedData, + domainSeparationTag: "FLOW-V0.0-user" + ) + + if !isValid{ + return ValidationResult( + isValid: false, + problem: "the given signatures are not valid or provide enough weight" + ) + } + + let coaRef = acc.capabilities.borrow<&EVM.CadenceOwnedAccount>(path) + if coaRef == nil { + return ValidationResult( + isValid: false, + problem: "could not borrow bridge account's resource" + ) + } + + // verify evm address matching + var addr = coaRef!.address() + for index, item in coaRef!.address().bytes { + if item != evmAddress[index] { + return ValidationResult( + isValid: false, + problem: "evm address mismatch" + ) + } + } + + return ValidationResult( + isValid: true, + problem: nil + ) + } + + /// Block returns information about the latest executed block. + access(all) + struct EVMBlock { + access(all) + let height: UInt64 + + access(all) + let hash: String + + access(all) + let totalSupply: Int + + access(all) + let timestamp: UInt64 + + init(height: UInt64, hash: String, totalSupply: Int, timestamp: UInt64) { + self.height = height + self.hash = hash + self.totalSupply = totalSupply + self.timestamp = timestamp + } + } + + /// Returns the latest executed block. + access(all) + fun getLatestBlock(): EVMBlock { + return InternalEVM.getLatestBlock() as! EVMBlock + } + + /// Interface for a resource which acts as an entrypoint to the VM bridge + access(all) + resource interface BridgeAccessor { + + /// Endpoint enabling the bridging of an NFT to EVM + access(Bridge) + fun depositNFT( + nft: @{NonFungibleToken.NFT}, + to: EVMAddress, + feeProvider: auth(FungibleToken.Withdraw) &{FungibleToken.Provider} + ) + + /// Endpoint enabling the bridging of an NFT from EVM + access(Bridge) + fun withdrawNFT( + caller: auth(Call) &CadenceOwnedAccount, + type: Type, + id: UInt256, + feeProvider: auth(FungibleToken.Withdraw) &{FungibleToken.Provider} + ): @{NonFungibleToken.NFT} + + /// Endpoint enabling the bridging of a fungible token vault to EVM + access(Bridge) + fun depositTokens( + vault: @{FungibleToken.Vault}, + to: EVMAddress, + feeProvider: auth(FungibleToken.Withdraw) &{FungibleToken.Provider} + ) + + /// Endpoint enabling the bridging of fungible tokens from EVM + access(Bridge) + fun withdrawTokens( + caller: auth(Call) &CadenceOwnedAccount, + type: Type, + amount: UInt256, + feeProvider: auth(FungibleToken.Withdraw) &{FungibleToken.Provider} + ): @{FungibleToken.Vault} + } + + /// Interface which captures a Capability to the bridge Accessor, saving it within the BridgeRouter resource + access(all) + resource interface BridgeRouter { + + /// Returns a reference to the BridgeAccessor designated for internal bridge requests + access(Bridge) view fun borrowBridgeAccessor(): auth(Bridge) &{BridgeAccessor} + + /// Sets the BridgeAccessor Capability in the BridgeRouter + access(Bridge) fun setBridgeAccessor(_ accessor: Capability) { + pre { + accessor.check(): "Invalid BridgeAccessor Capability provided" + emit BridgeAccessorUpdated( + routerType: self.getType(), + routerUUID: self.uuid, + routerAddress: self.owner?.address ?? panic("Router must have an owner to be identified"), + accessorType: accessor.borrow()!.getType(), + accessorUUID: accessor.borrow()!.uuid, + accessorAddress: accessor.address + ) + } + } + } + + /// Returns a reference to the BridgeAccessor designated for internal bridge requests + access(self) + view fun borrowBridgeAccessor(): auth(Bridge) &{BridgeAccessor} { + return self.account.storage.borrow(from: /storage/evmBridgeRouter) + ?.borrowBridgeAccessor() + ?? panic("Could not borrow reference to the EVM bridge") + } + + /// The Heartbeat resource controls the block production. + /// It is stored in the storage and used in the Flow protocol to call the heartbeat function once per block. + access(all) + resource Heartbeat { + /// heartbeat calls commit block proposals and forms new blocks including all the + /// recently executed transactions. + /// The Flow protocol makes sure to call this function once per block as a system call. + access(all) + fun heartbeat() { + InternalEVM.commitBlockProposal() + } + } + + access(all) + fun call( + from: String, + to: String, + data: [UInt8], + gasLimit: UInt64, + value: UInt + ): Result { + return InternalEVM.call( + from: EVM.addressFromString(from).bytes, + to: EVM.addressFromString(to).bytes, + data: data, + gasLimit: gasLimit, + value: value + ) as! Result + } + + /// Stores a value to an address' storage slot. + access(all) + fun store(target: EVM.EVMAddress, slot: String, value: String) { + InternalEVM.store(target: target.bytes, slot: slot, value: value) + } + + /// Loads a storage slot from an address. + access(all) + fun load(target: EVM.EVMAddress, slot: String): [UInt8] { + return InternalEVM.load(target: target.bytes, slot: slot) + } + + /// Runs a transaction by setting the call's `msg.sender` to be the `from` address. + access(all) + fun runTxAs( + from: EVM.EVMAddress, + to: EVM.EVMAddress, + data: [UInt8], + gasLimit: UInt64, + value: EVM.Balance, + ): Result { + return InternalEVM.call( + from: from.bytes, + to: to.bytes, + data: data, + gasLimit: gasLimit, + value: value.attoflow + ) as! Result + } + + /// setupHeartbeat creates a heartbeat resource and saves it to storage. + /// The function is called once during the contract initialization. + /// + /// The heartbeat resource is used to control the block production, + /// and used in the Flow protocol to call the heartbeat function once per block. + /// + /// The function can be called by anyone, but only once: + /// the function will fail if the resource already exists. + /// + /// The resulting resource is stored in the account storage, + /// and is only accessible by the account, not the caller of the function. + access(all) + fun setupHeartbeat() { + self.account.storage.save(<-create Heartbeat(), to: /storage/EVMHeartbeat) + } + + init() { + self.setupHeartbeat() + } +} \ No newline at end of file diff --git a/cadence/tests/evm_state_helpers.cdc b/cadence/tests/evm_state_helpers.cdc new file mode 100644 index 00000000..dff7a128 --- /dev/null +++ b/cadence/tests/evm_state_helpers.cdc @@ -0,0 +1,66 @@ +import Test +import "EVM" + +/* --- ERC4626 Vault State Manipulation --- */ + +/// Set vault share price by manipulating totalAssets, totalSupply, and asset.balanceOf(vault) +/// priceMultiplier: share price as a multiplier (e.g. 2.0 for 2x price) +access(all) fun setVaultSharePrice( + vaultAddress: String, + assetAddress: String, + assetBalanceSlot: UInt256, + totalSupplySlot: UInt256, + vaultTotalAssetsSlot: UInt256, + priceMultiplier: UFix64, + signer: Test.TestAccount +) { + let result = Test.executeTransaction( + Test.Transaction( + code: Test.readFile("transactions/set_erc4626_vault_price.cdc"), + authorizers: [signer.address], + signers: [signer], + arguments: [vaultAddress, assetAddress, assetBalanceSlot, totalSupplySlot, vaultTotalAssetsSlot, priceMultiplier] + ) + ) + Test.expect(result, Test.beSucceeded()) +} + +/* --- Uniswap V3 Pool State Manipulation --- */ + +/// Set Uniswap V3 pool to a specific price via EVM.store +/// Creates pool if it doesn't exist, then manipulates state +/// Price is specified as UFix128 for high precision (24 decimal places) +access(all) fun setPoolToPrice( + factoryAddress: String, + tokenAAddress: String, + tokenBAddress: String, + fee: UInt64, + priceTokenBPerTokenA: UFix128, + tokenABalanceSlot: UInt256, + tokenBBalanceSlot: UInt256, + signer: Test.TestAccount +) { + let seedResult = Test.executeTransaction( + Test.Transaction( + code: Test.readFile("transactions/set_uniswap_v3_pool_price.cdc"), + authorizers: [signer.address], + signers: [signer], + arguments: [factoryAddress, tokenAAddress, tokenBAddress, fee, priceTokenBPerTokenA, tokenABalanceSlot, tokenBBalanceSlot] + ) + ) + Test.expect(seedResult, Test.beSucceeded()) +} + +/* --- Fee Adjustment --- */ + +/// Adjust a pool price to compensate for Uniswap V3 swap fees. +/// Forward: price / (1 - fee/1e6) +/// Reverse: price * (1 - fee/1e6) +/// Computed in UFix128 for full 24-decimal-place precision. +access(all) fun feeAdjustedPrice(_ price: UFix128, fee: UInt64, reverse: Bool): UFix128 { + let feeRate = UFix128(fee) / 1_000_000.0 + if reverse { + return price * (1.0 - feeRate) + } + return price / (1.0 - feeRate) +} diff --git a/cadence/tests/evm_state_helpers_test.cdc b/cadence/tests/evm_state_helpers_test.cdc new file mode 100644 index 00000000..a432fcd7 --- /dev/null +++ b/cadence/tests/evm_state_helpers_test.cdc @@ -0,0 +1,156 @@ +// Tests that EVM state helpers correctly set Uniswap V3 pool price and ERC4626 vault price +#test_fork(network: "mainnet-fork", height: 143292255) + +import Test +import BlockchainHelpers + +import "test_helpers.cdc" +import "evm_state_helpers.cdc" + +import "FlowToken" + +access(all) let whaleFlowAccount = Test.getAccount(0x92674150c9213fc9) + +access(all) let factoryAddress = "0xca6d7Bb03334bBf135902e1d919a5feccb461632" +access(all) let routerAddress = "0xeEDC6Ff75e1b10B903D9013c358e446a73d35341" +access(all) let quoterAddress = "0x370A8DF17742867a44e56223EC20D82092242C85" + +access(all) let morphoVaultAddress = "0xd069d989e2F44B70c65347d1853C0c67e10a9F8D" +access(all) let pyusd0Address = "0x99aF3EeA856556646C98c8B9b2548Fe815240750" +access(all) let wflowAddress = "0xd3bF53DAC106A0290B0483EcBC89d40FcC961f3e" + +access(all) let pyusd0BalanceSlot = 1 as UInt256 +access(all) let fusdevBalanceSlot = 12 as UInt256 +access(all) let wflowBalanceSlot = 3 as UInt256 +access(all) let morphoVaultTotalSupplySlot = 11 as UInt256 +access(all) let morphoVaultTotalAssetsSlot = 15 as UInt256 + +access(all) let pyusd0VaultTypeId = "A.1e4aa0b87d10b141.EVMVMBridgedToken_99af3eea856556646c98c8b9b2548fe815240750.Vault" + +// Vault public paths +access(all) let pyusd0PublicPath = /public/EVMVMBridgedToken_99af3eea856556646c98c8b9b2548fe815240750Vault +access(all) let fusdevPublicPath = /public/EVMVMBridgedToken_d069d989e2f44b70c65347d1853c0c67e10a9f8dVault + +access(all) let univ3PoolFee: UInt64 = 3000 + +access(all) var snapshot: UInt64 = 0 +access(all) var testAccount = Test.createAccount() + +access(all) +fun setup() { + deployContractsForFork() + transferFlow(signer: whaleFlowAccount, recipient: testAccount.address, amount: 10000000.0) + createCOA(testAccount, fundingAmount: 5.0) + + // Set up a WFLOW/PYUSD0 pool at 1:1 so we can swap FLOW→PYUSD0 to fund the Cadence vault + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: wflowAddress, + tokenBAddress: pyusd0Address, + fee: univ3PoolFee, + priceTokenBPerTokenA: 1.0, + tokenABalanceSlot: wflowBalanceSlot, + tokenBBalanceSlot: pyusd0BalanceSlot, + signer: testAccount + ) + + // Swap FLOW→PYUSD0 to create the Cadence-side PYUSD0 vault (needed for ERC4626 deposit test) + let swapRes = Test.executeTransaction( + Test.Transaction( + code: Test.readFile("transactions/execute_univ3_swap.cdc"), + authorizers: [testAccount.address], + signers: [testAccount], + arguments: [factoryAddress, routerAddress, quoterAddress, wflowAddress, pyusd0Address, univ3PoolFee, 11000.0] + ) + ) + Test.expect(swapRes, Test.beSucceeded()) + + snapshot = getCurrentBlockHeight() + Test.commitBlock() +} + +access(all) +fun test_UniswapV3PriceSetAndSwap() { + let prices = [0.5, 1.0, 2.0, 3.0, 5.0] + let flowAmount = 10000.0 + + for price in prices { + Test.reset(to: snapshot) + + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: wflowAddress, + tokenBAddress: pyusd0Address, + fee: univ3PoolFee, + priceTokenBPerTokenA: UFix128(price), + tokenABalanceSlot: wflowBalanceSlot, + tokenBBalanceSlot: pyusd0BalanceSlot, + signer: testAccount + ) + + let balanceBefore = getBalance(address: testAccount.address, vaultPublicPath: pyusd0PublicPath)! + + let swapRes = Test.executeTransaction( + Test.Transaction( + code: Test.readFile("transactions/execute_univ3_swap.cdc"), + authorizers: [testAccount.address], + signers: [testAccount], + arguments: [factoryAddress, routerAddress, quoterAddress, wflowAddress, pyusd0Address, univ3PoolFee, flowAmount] + ) + ) + Test.expect(swapRes, Test.beSucceeded()) + + let balanceAfter = getBalance(address: testAccount.address, vaultPublicPath: pyusd0PublicPath)! + let swapOutput = balanceAfter - balanceBefore + let expectedOut = feeAdjustedPrice(UFix128(price), fee: univ3PoolFee, reverse: true) * UFix128(flowAmount) + + // PYUSD0 has 6 decimals, so we need to use a tolerance of 1e-6 + let tolerance = 0.000001 + Test.assert( + equalAmounts(a: UFix64(swapOutput), b: UFix64(expectedOut), tolerance: tolerance), + message: "Pool price \(price): swap output \(swapOutput) not within \(tolerance) of expected \(expectedOut)" + ) + log("Pool price \(price): expected=\(expectedOut) actual=\(swapOutput)") + } +} + +access(all) +fun test_ERC4626PriceSetAndDeposit() { + let multipliers = [0.5, 1.0, 2.0, 3.0, 5.0] + let amountIn = 10000.0 + + for multiplier in multipliers { + Test.reset(to: snapshot) + + setVaultSharePrice( + vaultAddress: morphoVaultAddress, + assetAddress: pyusd0Address, + assetBalanceSlot: pyusd0BalanceSlot, + totalSupplySlot: morphoVaultTotalSupplySlot, + vaultTotalAssetsSlot: morphoVaultTotalAssetsSlot, + priceMultiplier: multiplier, + signer: testAccount + ) + + let depositRes = Test.executeTransaction( + Test.Transaction( + code: Test.readFile("transactions/execute_morpho_deposit.cdc"), + authorizers: [testAccount.address], + signers: [testAccount], + arguments: [pyusd0VaultTypeId, morphoVaultAddress, amountIn] + ) + ) + Test.expect(depositRes, Test.beSucceeded()) + + let fusdevBalance = getBalance(address: testAccount.address, vaultPublicPath: fusdevPublicPath)! + let expectedShares = amountIn / multiplier + + // FUSDEV has 18 decimals, so we need to use a tolerance of 1e-8 (Cadence UFix64 precision) + let tolerance: UFix64 = 0.00000001 + Test.assert( + equalAmounts(a: fusdevBalance, b: expectedShares, tolerance: tolerance), + message: "Multiplier \(multiplier): FUSDEV shares \(fusdevBalance) not within \(tolerance) of expected \(expectedShares)" + ) + log("Multiplier \(multiplier): expected=\(expectedShares) actual=\(fusdevBalance)") + } +} diff --git a/cadence/tests/forked_rebalance_scenario1_test.cdc b/cadence/tests/forked_rebalance_scenario1_test.cdc new file mode 100644 index 00000000..cba61185 --- /dev/null +++ b/cadence/tests/forked_rebalance_scenario1_test.cdc @@ -0,0 +1,294 @@ +#test_fork(network: "mainnet-fork", height: 143292255) + +import Test +import BlockchainHelpers + +import "test_helpers.cdc" +import "evm_state_helpers.cdc" + +// FlowYieldVaults platform +import "FlowYieldVaults" +// other +import "FlowToken" +import "MOET" +import "FlowYieldVaultsStrategiesV2" +import "FlowALPv0" + +// ============================================================================ +// CADENCE ACCOUNTS +// ============================================================================ + +access(all) let flowYieldVaultsAccount = Test.getAccount(0xb1d63873c3cc9f79) +access(all) let flowALPAccount = Test.getAccount(0x6b00ff876c299c61) +access(all) let bandOracleAccount = Test.getAccount(0x6801a6222ebf784a) +access(all) let whaleFlowAccount = Test.getAccount(0x92674150c9213fc9) +access(all) let coaOwnerAccount = Test.getAccount(0xe467b9dd11fa00df) + +access(all) var strategyIdentifier = Type<@FlowYieldVaultsStrategiesV2.FUSDEVStrategy>().identifier +access(all) var flowTokenIdentifier = Type<@FlowToken.Vault>().identifier +access(all) var moetTokenIdentifier = Type<@MOET.Vault>().identifier + +// ============================================================================ +// PROTOCOL ADDRESSES +// ============================================================================ + +// Uniswap V3 Factory on Flow EVM mainnet +access(all) let factoryAddress = "0xca6d7Bb03334bBf135902e1d919a5feccb461632" + +// ============================================================================ +// VAULT & TOKEN ADDRESSES +// ============================================================================ + +// FUSDEV - Morpho VaultV2 (ERC4626) +// Underlying asset: PYUSD0 +access(all) let morphoVaultAddress = "0xd069d989e2F44B70c65347d1853C0c67e10a9F8D" + +// PYUSD0 - Stablecoin (FUSDEV's underlying asset) +access(all) let pyusd0Address = "0x99aF3EeA856556646C98c8B9b2548Fe815240750" + +// MOET - Flow ALP USD +access(all) let moetAddress = "0x213979bB8A9A86966999b3AA797C1fcf3B967ae2" + +// WFLOW - Wrapped Flow +access(all) let wflowAddress = "0xd3bF53DAC106A0290B0483EcBC89d40FcC961f3e" + +// ============================================================================ +// STORAGE SLOT CONSTANTS +// ============================================================================ + +// Token balanceOf mapping slots (for EVM.store to manipulate balances) +access(all) let moetBalanceSlot = 0 as UInt256 +access(all) let pyusd0BalanceSlot = 1 as UInt256 +access(all) let fusdevBalanceSlot = 12 as UInt256 +access(all) let wflowBalanceSlot = 3 as UInt256 + +// Morpho vault storage slots +access(all) let morphoVaultTotalSupplySlot = 11 as UInt256 +access(all) let morphoVaultTotalAssetsSlot = 15 as UInt256 + + +access(all) +fun setup() { + // Deploy all contracts for mainnet fork + deployContractsForFork() + + // Setup Uniswap V3 pools with structurally valid state + // This sets slot0, observations, liquidity, ticks, bitmap, positions, and POOL token balances + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: pyusd0Address, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 100, reverse: false), + tokenABalanceSlot: pyusd0BalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: pyusd0Address, + tokenBAddress: wflowAddress, + fee: 3000, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 3000, reverse: false), + tokenABalanceSlot: pyusd0BalanceSlot, + tokenBBalanceSlot: wflowBalanceSlot, + signer: coaOwnerAccount + ) + + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: moetAddress, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 100, reverse: false), + tokenABalanceSlot: moetBalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: moetAddress, + tokenBAddress: pyusd0Address, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 100, reverse: false), + tokenABalanceSlot: moetBalanceSlot, + tokenBBalanceSlot: pyusd0BalanceSlot, + signer: coaOwnerAccount + ) + + // BandOracle is only used for FLOW price for FlowALP collateral + let symbolPrices: {String: UFix64} = { + "FLOW": 1.0, + "USD": 1.0 + } + setBandOraclePrices(signer: bandOracleAccount, symbolPrices: symbolPrices) + + let reserveAmount = 100_000_00.0 + transferFlow(signer: whaleFlowAccount, recipient: flowALPAccount.address, amount: reserveAmount) + mintMoet(signer: flowALPAccount, to: flowALPAccount.address, amount: reserveAmount, beFailed: false) + + // Fund FlowYieldVaults account for scheduling fees (atomic initial scheduling) + transferFlow(signer: whaleFlowAccount, recipient: flowYieldVaultsAccount.address, amount: 100.0) +} + +access(all) var testSnapshot: UInt64 = 0 +access(all) +fun test_ForkedRebalanceYieldVaultScenario1() { + let fundingAmount = 1000.0 + + let user = Test.createAccount() + + let flowPrices = [0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0, 5.0] + + // Expected values from Google sheet calculations + let expectedYieldTokenValues: {UFix64: UFix64} = { + 0.5: 307.69230769, + 0.8: 492.30769231, + 1.0: 615.38461538, + 1.2: 738.46153846, + 1.5: 923.07692308, + 2.0: 1230.76923077, + 3.0: 1846.15384615, + 5.0: 3076.92307692 + } + + // Likely 0.0 + let flowBalanceBefore = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + transferFlow(signer: whaleFlowAccount, recipient: user.address, amount: fundingAmount) + grantBeta(flowYieldVaultsAccount, user) + + // Set vault to baseline 1:1 price + setVaultSharePrice( + vaultAddress: morphoVaultAddress, + assetAddress: pyusd0Address, + assetBalanceSlot: pyusd0BalanceSlot, + totalSupplySlot: morphoVaultTotalSupplySlot, + vaultTotalAssetsSlot: morphoVaultTotalAssetsSlot, + priceMultiplier: 1.0, + signer: user + ) + + createYieldVault( + signer: user, + strategyIdentifier: strategyIdentifier, + vaultIdentifier: flowTokenIdentifier, + amount: fundingAmount, + beFailed: false + ) + + // Capture the actual position ID from the FlowCreditMarket.Opened event + var pid = (getLastPositionOpenedEvent(Test.eventsOfType(Type())) as! FlowALPv0.Opened).pid + log("[TEST] Captured Position ID from event: \(pid)") + + var yieldVaultIDs = getYieldVaultIDs(address: user.address) + log("[TEST] YieldVault ID: \(yieldVaultIDs![0])") + Test.assert(yieldVaultIDs != nil, message: "Expected user's YieldVault IDs to be non-nil but encountered nil") + Test.assertEqual(1, yieldVaultIDs!.length) + + var yieldVaultBalance = getYieldVaultBalance(address: user.address, yieldVaultID: yieldVaultIDs![0]) + + log("[TEST] Initial yield vault balance: \(yieldVaultBalance ?? 0.0)") + + rebalanceYieldVault(signer: flowYieldVaultsAccount, id: yieldVaultIDs![0], force: true, beFailed: false) + rebalancePosition(signer: flowALPAccount, pid: pid, force: true, beFailed: false) + + testSnapshot = getCurrentBlockHeight() + + for flowPrice in flowPrices { + if (getCurrentBlockHeight() > testSnapshot) { + Test.reset(to: testSnapshot) + } + yieldVaultBalance = getYieldVaultBalance(address: user.address, yieldVaultID: yieldVaultIDs![0]) + + log("[TEST] YieldVault balance before flow price \(flowPrice) \(yieldVaultBalance ?? 0.0)") + + // === FLOW PRICE CHANGES === + setBandOraclePrices(signer: bandOracleAccount, symbolPrices: { + "FLOW": flowPrice, + "USD": 1.0 + }) + + // Update WFLOW/PYUSD0 pool to match new Flow price + // 1 WFLOW = flowPrice PYUSD0 + // Recollat traverses PYUSD0→WFLOW (reverse on this pool) + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: wflowAddress, + tokenBAddress: pyusd0Address, + fee: 3000, + priceTokenBPerTokenA: feeAdjustedPrice(UFix128(flowPrice), fee: 3000, reverse: true), + tokenABalanceSlot: wflowBalanceSlot, + tokenBBalanceSlot: pyusd0BalanceSlot, + signer: coaOwnerAccount + ) + + // MOET/FUSDEV pool: fee adjustment direction depends on rebalance type + // Surplus (flowPrice > 1.0): swaps MOET→FUSDEV (forward) + // Deficit (flowPrice < 1.0): swaps FUSDEV→MOET (reverse) + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: moetAddress, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 100, reverse: flowPrice < 1.0), + tokenABalanceSlot: moetBalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + yieldVaultBalance = getYieldVaultBalance(address: user.address, yieldVaultID: yieldVaultIDs![0]) + + log("[TEST] YieldVault balance before flow price \(flowPrice) rebalance: \(yieldVaultBalance ?? 0.0)") + + // Get yield token balance before rebalance + let yieldTokensBefore = getAutoBalancerBalance(id: yieldVaultIDs![0]) ?? 0.0 + let currentValueBefore = getAutoBalancerCurrentValue(id: yieldVaultIDs![0]) ?? 0.0 + + rebalanceYieldVault(signer: flowYieldVaultsAccount, id: yieldVaultIDs![0], force: false, beFailed: false) + rebalancePosition(signer: flowALPAccount, pid: pid, force: false, beFailed: false) + + yieldVaultBalance = getYieldVaultBalance(address: user.address, yieldVaultID: yieldVaultIDs![0]) + + log("[TEST] YieldVault balance after flow before \(flowPrice): \(yieldVaultBalance ?? 0.0)") + + // Get yield token balance after rebalance + let yieldTokensAfter = getAutoBalancerBalance(id: yieldVaultIDs![0]) ?? 0.0 + let currentValueAfter = getAutoBalancerCurrentValue(id: yieldVaultIDs![0]) ?? 0.0 + + // Get expected yield tokens from Google sheet calculations + let expectedYieldTokens = expectedYieldTokenValues[flowPrice] ?? 0.0 + + log("\n=== SCENARIO 1 DETAILS for Flow Price \(flowPrice) ===") + log("YieldVault Balance: \(yieldVaultBalance ?? 0.0)") + log("Yield Tokens Before: \(yieldTokensBefore)") + log("Yield Tokens After: \(yieldTokensAfter)") + log("Expected Yield Tokens: \(expectedYieldTokens)") + let precisionDiff = yieldTokensAfter > expectedYieldTokens ? yieldTokensAfter - expectedYieldTokens : expectedYieldTokens - yieldTokensAfter + let precisionSign = yieldTokensAfter > expectedYieldTokens ? "+" : "-" + log("Precision Difference: \(precisionSign)\(precisionDiff)") + let percentDiff = expectedYieldTokens > 0.0 ? (precisionDiff / expectedYieldTokens) * 100.0 : 0.0 + log("Percent Difference: \(precisionSign)\(percentDiff)%") + + Test.assert( + equalAmounts(a: yieldTokensAfter, b: expectedYieldTokens, tolerance: 0.01), + message: "Expected yield tokens for flow price \(flowPrice) to be \(expectedYieldTokens) but got \(yieldTokensAfter)" + ) + + let yieldChange = yieldTokensAfter > yieldTokensBefore ? yieldTokensAfter - yieldTokensBefore : yieldTokensBefore - yieldTokensAfter + let yieldSign = yieldTokensAfter > yieldTokensBefore ? "+" : "-" + log("Yield Token Change: \(yieldSign)\(yieldChange)") + log("Current Value Before: \(currentValueBefore)") + log("Current Value After: \(currentValueAfter)") + let valueChange = currentValueAfter > currentValueBefore ? currentValueAfter - currentValueBefore : currentValueBefore - currentValueAfter + let valueSign = currentValueAfter > currentValueBefore ? "+" : "-" + log("Value Change: \(valueSign)\(valueChange)") + log("=============================================\n") + } + + // closeYieldVault(signer: user, id: yieldVaultIDs![0], beFailed: false) + + let flowBalanceAfter = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + log("[TEST] flow balance after \(flowBalanceAfter)") +} diff --git a/cadence/tests/forked_rebalance_scenario2_test.cdc b/cadence/tests/forked_rebalance_scenario2_test.cdc new file mode 100644 index 00000000..8de34708 --- /dev/null +++ b/cadence/tests/forked_rebalance_scenario2_test.cdc @@ -0,0 +1,422 @@ +#test_fork(network: "mainnet-fork", height: 143292255) + +import Test +import BlockchainHelpers + +import "test_helpers.cdc" +import "evm_state_helpers.cdc" + +import "FlowToken" +import "MOET" +import "FlowYieldVaultsStrategiesV2" +import "FlowALPv0" +import "FlowYieldVaults" + + +// ============================================================================ +// CADENCE ACCOUNTS +// ============================================================================ + +access(all) let flowYieldVaultsAccount = Test.getAccount(0xb1d63873c3cc9f79) +access(all) let flowALPAccount = Test.getAccount(0x6b00ff876c299c61) +access(all) let bandOracleAccount = Test.getAccount(0x6801a6222ebf784a) +access(all) let whaleFlowAccount = Test.getAccount(0x92674150c9213fc9) +access(all) let coaOwnerAccount = Test.getAccount(0xe467b9dd11fa00df) + +access(all) var strategyIdentifier = Type<@FlowYieldVaultsStrategiesV2.FUSDEVStrategy>().identifier +access(all) var flowTokenIdentifier = Type<@FlowToken.Vault>().identifier +access(all) var moetTokenIdentifier = Type<@MOET.Vault>().identifier + +// ============================================================================ +// PROTOCOL ADDRESSES +// ============================================================================ + +// Uniswap V3 Factory on Flow EVM mainnet +access(all) let factoryAddress = "0xca6d7Bb03334bBf135902e1d919a5feccb461632" + +// ============================================================================ +// VAULT & TOKEN ADDRESSES +// ============================================================================ + +// FUSDEV - Morpho VaultV2 (ERC4626) +// Underlying asset: PYUSD0 +access(all) let morphoVaultAddress = "0xd069d989e2F44B70c65347d1853C0c67e10a9F8D" + +// PYUSD0 - Stablecoin (FUSDEV's underlying asset) +access(all) let pyusd0Address = "0x99aF3EeA856556646C98c8B9b2548Fe815240750" + +// MOET - Flow ALP USD +access(all) let moetAddress = "0x213979bB8A9A86966999b3AA797C1fcf3B967ae2" + +// WFLOW - Wrapped Flow +access(all) let wflowAddress = "0xd3bF53DAC106A0290B0483EcBC89d40FcC961f3e" + +// ============================================================================ +// STORAGE SLOT CONSTANTS +// ============================================================================ + +// Token balanceOf mapping slots (for EVM.store to manipulate balances) +access(all) let moetBalanceSlot = 0 as UInt256 +access(all) let pyusd0BalanceSlot = 1 as UInt256 +access(all) let fusdevBalanceSlot = 12 as UInt256 +access(all) let wflowBalanceSlot = 3 as UInt256 + +// Morpho vault storage slots +access(all) let morphoVaultTotalSupplySlot = 11 as UInt256 +access(all) let morphoVaultTotalAssetsSlot = 15 as UInt256 + +access(all) +fun setup() { + // Deploy all contracts for mainnet fork + deployContractsForFork() + + // Setup Uniswap V3 pools with structurally valid state + // This sets slot0, observations, liquidity, ticks, bitmap, positions, and POOL token balances + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: pyusd0Address, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 100, reverse: false), + tokenABalanceSlot: pyusd0BalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: pyusd0Address, + tokenBAddress: wflowAddress, + fee: 3000, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 3000, reverse: false), + tokenABalanceSlot: pyusd0BalanceSlot, + tokenBBalanceSlot: wflowBalanceSlot, + signer: coaOwnerAccount + ) + + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: moetAddress, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 100, reverse: false), + tokenABalanceSlot: moetBalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: moetAddress, + tokenBAddress: pyusd0Address, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 100, reverse: false), + tokenABalanceSlot: moetBalanceSlot, + tokenBBalanceSlot: pyusd0BalanceSlot, + signer: coaOwnerAccount + ) + + // BandOracle is used for FLOW and USD (MOET) prices + let symbolPrices = { + "FLOW": 1.0, + "USD": 1.0 + } + setBandOraclePrices(signer: bandOracleAccount, symbolPrices: symbolPrices) + + // mint tokens & set liquidity in mock swapper contract + let reserveAmount = 100_000_00.0 + // service account does not have enough flow to "mint" + // var mintFlowResult = mintFlow(to: flowCreditMarketAccount, amount: reserveAmount) + // Test.expect(mintFlowResult, Test.beSucceeded()) + transferFlow(signer: whaleFlowAccount, recipient: flowALPAccount.address, amount: reserveAmount) + + mintMoet(signer: flowALPAccount, to: flowALPAccount.address, amount: reserveAmount, beFailed: false) + + // Grant FlowALPv1 Pool capability to FlowYieldVaults account + let protocolBetaRes = grantProtocolBeta(flowALPAccount, flowYieldVaultsAccount) + Test.expect(protocolBetaRes, Test.beSucceeded()) + + // Fund FlowYieldVaults account for scheduling fees (atomic initial scheduling) + // service account does not have enough flow to "mint" + // mintFlowResult = mintFlow(to: flowYieldVaultsAccount, amount: 100.0) + // Test.expect(mintFlowResult, Test.beSucceeded()) + transferFlow(signer: whaleFlowAccount, recipient: flowYieldVaultsAccount.address, amount: 100.0) +} + +/// Logs full position details (all balances with direction, health, etc.) +access(all) +fun logPositionDetails(label: String, pid: UInt64) { + let positionDetails = getPositionDetails(pid: pid, beFailed: false) + log("\n--- Position Details (\(label)) pid=\(pid) ---") + log(" health: \(positionDetails.health)") + log(" defaultTokenAvailableBalance: \(positionDetails.defaultTokenAvailableBalance)") + for balance in positionDetails.balances { + let direction = balance.direction.rawValue == 0 ? "CREDIT(collateral)" : "DEBIT(debt)" + log(" [\(direction)] \(balance.vaultType.identifier): \(balance.balance)") + } + log("--- End Position Details ---") +} + +access(all) +fun test_RebalanceYieldVaultScenario2() { + // Test.reset(to: snapshot) + + let fundingAmount = 1000.0 + + let user = Test.createAccount() + + let yieldPriceIncreases = [1.1, 1.2, 1.3, 1.5, 2.0, 3.0] + let expectedFlowBalance = [ + 1061.53846154, + 1120.92522862, + 1178.40857368, + 1289.97388243, + 1554.58390959, + 2032.91742023 + ] + + // Likely 0.0 + let flowBalanceBefore = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + transferFlow(signer: whaleFlowAccount, recipient: user.address, amount: fundingAmount) + grantBeta(flowYieldVaultsAccount, user) + + // Set vault to baseline 1:1 price + setVaultSharePrice( + vaultAddress: morphoVaultAddress, + assetAddress: pyusd0Address, + assetBalanceSlot: pyusd0BalanceSlot, + totalSupplySlot: morphoVaultTotalSupplySlot, + vaultTotalAssetsSlot: morphoVaultTotalAssetsSlot, + priceMultiplier: 1.0, + signer: user + ) + + createYieldVault( + signer: user, + strategyIdentifier: strategyIdentifier, + vaultIdentifier: flowTokenIdentifier, + amount: fundingAmount, + beFailed: false + ) + + // Capture the actual position ID from the FlowCreditMarket.Opened event + var pid = (getLastPositionOpenedEvent(Test.eventsOfType(Type())) as! FlowALPv0.Opened).pid + log("[TEST] Captured Position ID from event: \(pid)") + + var yieldVaultIDs = getYieldVaultIDs(address: user.address) + log("[TEST] YieldVault ID: \(yieldVaultIDs![0])") + Test.assert(yieldVaultIDs != nil, message: "Expected user's YieldVault IDs to be non-nil but encountered nil") + Test.assertEqual(1, yieldVaultIDs!.length) + + var yieldVaultBalance = getYieldVaultBalance(address: user.address, yieldVaultID: yieldVaultIDs![0]) + + log("[TEST] Initial yield vault balance: \(yieldVaultBalance ?? 0.0)") + + rebalanceYieldVault(signer: flowYieldVaultsAccount, id: yieldVaultIDs![0], force: true, beFailed: false) + rebalancePosition(signer: flowALPAccount, pid: pid, force: true, beFailed: false) + + for index, yieldTokenPrice in yieldPriceIncreases { + yieldVaultBalance = getYieldVaultBalance(address: user.address, yieldVaultID: yieldVaultIDs![0]) + + log("[TEST] YieldVault balance before yield price \(yieldTokenPrice): \(yieldVaultBalance ?? 0.0)") + + setVaultSharePrice( + vaultAddress: morphoVaultAddress, + assetAddress: pyusd0Address, + assetBalanceSlot: pyusd0BalanceSlot, + totalSupplySlot: morphoVaultTotalSupplySlot, + vaultTotalAssetsSlot: morphoVaultTotalAssetsSlot, + priceMultiplier: yieldTokenPrice, + signer: user + ) + + // Update FUSDEV pools + // Since FUSDEV is increasing in value we want to sell FUSDEV on the rebalance + // FUSDEV -> PYUSD0 -> WFLOW + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: pyusd0Address, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0 / UFix128(yieldTokenPrice), fee: 100, reverse: true), + tokenABalanceSlot: pyusd0BalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + // MOET -> FUSDEV + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: moetAddress, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0 / UFix128(yieldTokenPrice), fee: 100, reverse: false), + tokenABalanceSlot: moetBalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + yieldVaultBalance = getYieldVaultBalance(address: user.address, yieldVaultID: yieldVaultIDs![0]) + + log("[TEST] YieldVault balance before yield price \(yieldTokenPrice) rebalance: \(yieldVaultBalance ?? 0.0)") + + rebalanceYieldVault(signer: flowYieldVaultsAccount, id: yieldVaultIDs![0], force: false, beFailed: false) + rebalancePosition(signer: flowALPAccount, pid: pid, force: false, beFailed: false) + + // FUSDEV -> MOET for the yield balance check (we want to sell FUSDEV) + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: moetAddress, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0 / UFix128(yieldTokenPrice), fee: 100, reverse: true), + tokenABalanceSlot: moetBalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + yieldVaultBalance = getYieldVaultBalance(address: user.address, yieldVaultID: yieldVaultIDs![0]) + + log("[TEST] YieldVault balance after yield price \(yieldTokenPrice) rebalance: \(yieldVaultBalance ?? 0.0)") + + // Perform comprehensive diagnostic precision trace + performDiagnosticPrecisionTrace( + yieldVaultID: yieldVaultIDs![0], + pid: pid, + yieldPrice: yieldTokenPrice, + expectedValue: expectedFlowBalance[index], + userAddress: user.address + ) + + // Get Flow collateral from position + let flowCollateralAmount = getFlowCollateralFromPosition(pid: pid) + let flowCollateralValue = flowCollateralAmount * 1.0 // Flow price remains at 1.0 + + // Detailed precision comparison + let actualYieldVaultBalance = yieldVaultBalance ?? 0.0 + let expectedBalance = expectedFlowBalance[index] + + // Calculate differences + let yieldVaultDiff = actualYieldVaultBalance > expectedBalance ? actualYieldVaultBalance - expectedBalance : expectedBalance - actualYieldVaultBalance + let yieldVaultSign = actualYieldVaultBalance > expectedBalance ? "+" : "-" + let yieldVaultPercentDiff = (yieldVaultDiff / expectedBalance) * 100.0 + + let positionDiff = flowCollateralValue > expectedBalance ? flowCollateralValue - expectedBalance : expectedBalance - flowCollateralValue + let positionSign = flowCollateralValue > expectedBalance ? "+" : "-" + let positionPercentDiff = (positionDiff / expectedBalance) * 100.0 + + let yieldVaultVsPositionDiff = actualYieldVaultBalance > flowCollateralValue ? actualYieldVaultBalance - flowCollateralValue : flowCollateralValue - actualYieldVaultBalance + let yieldVaultVsPositionSign = actualYieldVaultBalance > flowCollateralValue ? "+" : "-" + + log("\n=== PRECISION COMPARISON for Yield Price \(yieldTokenPrice) ===") + log("Expected Value: \(expectedBalance)") + log("Actual YieldVault Balance: \(actualYieldVaultBalance)") + log("Flow Position Value: \(flowCollateralValue)") + log("Flow Position Amount: \(flowCollateralAmount) tokens") + log("") + log("YieldVault vs Expected: \(yieldVaultSign)\(yieldVaultDiff) (\(yieldVaultSign)\(yieldVaultPercentDiff)%)") + log("Position vs Expected: \(positionSign)\(positionDiff) (\(positionSign)\(positionPercentDiff)%)") + log("YieldVault vs Position: \(yieldVaultVsPositionSign)\(yieldVaultVsPositionDiff)") + log("===============================================\n") + + let percentToleranceCheck = equalAmounts(a: yieldVaultPercentDiff, b: 0.0, tolerance: 0.01) + Test.assert(percentToleranceCheck, message: "Percent difference \(yieldVaultPercentDiff)% is not within tolerance \(0.01)%") + } + + // closeYieldVault(signer: user, id: yieldVaultIDs![0], beFailed: false) + + // let flowBalanceAfter = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + // log("[TEST] flow balance after \(flowBalanceAfter)") + + // Test.assert( + // (flowBalanceAfter-flowBalanceBefore) > 0.1, + // message: "Expected user's Flow balance after rebalance to be more than zero but got \(flowBalanceAfter)" + // ) +} + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +// Helper function to get Flow collateral from position +access(all) fun getFlowCollateralFromPosition(pid: UInt64): UFix64 { + let positionDetails = getPositionDetails(pid: pid, beFailed: false) + for balance in positionDetails.balances { + if balance.vaultType == Type<@FlowToken.Vault>() { + // Credit means it's a deposit (collateral) + if balance.direction.rawValue == 0 { // Credit = 0 + return balance.balance + } + } + } + return 0.0 +} + +// Enhanced diagnostic precision tracking function with full call stack tracing +access(all) fun performDiagnosticPrecisionTrace( + yieldVaultID: UInt64, + pid: UInt64, + yieldPrice: UFix64, + expectedValue: UFix64, + userAddress: Address +) { + // Get position ground truth + let positionDetails = getPositionDetails(pid: pid, beFailed: false) + var flowAmount: UFix64 = 0.0 + + for balance in positionDetails.balances { + if balance.vaultType.identifier == flowTokenIdentifier { + if balance.direction.rawValue == 0 { // Credit + flowAmount = balance.balance + } + } + } + + // Values at different layers + let positionValue = flowAmount * 1.0 // Flow price = 1.0 in Scenario 2 + let yieldVaultValue = getYieldVaultBalance(address: userAddress, yieldVaultID: yieldVaultID) ?? 0.0 + + // Calculate drifts with proper sign handling + let yieldVaultDriftAbs = yieldVaultValue > expectedValue ? yieldVaultValue - expectedValue : expectedValue - yieldVaultValue + let yieldVaultDriftSign = yieldVaultValue > expectedValue ? "+" : "-" + let positionDriftAbs = positionValue > expectedValue ? positionValue - expectedValue : expectedValue - positionValue + let positionDriftSign = positionValue > expectedValue ? "+" : "-" + let yieldVaultVsPositionAbs = yieldVaultValue > positionValue ? yieldVaultValue - positionValue : positionValue - yieldVaultValue + let yieldVaultVsPositionSign = yieldVaultValue > positionValue ? "+" : "-" + + // Enhanced logging with intermediate values + log("\n+----------------------------------------------------------------+") + log("| PRECISION DRIFT DIAGNOSTIC - Yield Price \(yieldPrice) |") + log("+----------------------------------------------------------------+") + log("| Layer | Value | Drift | % Drift |") + log("|----------------|----------------|---------------|--------------|") + log("| Position | \(formatValue(positionValue)) | \(positionDriftSign)\(formatValue(positionDriftAbs)) | \(positionDriftSign)\(formatPercent(positionDriftAbs / expectedValue))% |") + log("| YieldVault Balance | \(formatValue(yieldVaultValue)) | \(yieldVaultDriftSign)\(formatValue(yieldVaultDriftAbs)) | \(yieldVaultDriftSign)\(formatPercent(yieldVaultDriftAbs / expectedValue))% |") + log("| Expected | \(formatValue(expectedValue)) | ------------- | ------------ |") + log("|----------------|----------------|---------------|--------------|") + log("| YieldVault vs Position: \(yieldVaultVsPositionSign)\(formatValue(yieldVaultVsPositionAbs)) |") + log("+----------------------------------------------------------------+") + + // Log intermediate calculation values + log("\n== INTERMEDIATE VALUES TRACE:") + + // Log position balance details + log("- Position Balance Details:") + log(" * Flow Amount (trueBalance): \(flowAmount)") + + // Skip the problematic UInt256 conversion entirely to avoid overflow + log("- Expected Value Analysis:") + log(" * Expected UFix64: \(expectedValue)") + + // Log precision loss summary without complex calculations + log("- Precision Loss Summary:") + log(" * Position vs Expected: \(positionDriftSign)\(formatValue(positionDriftAbs)) (\(positionDriftSign)\(formatPercent(positionDriftAbs / expectedValue))%)") + log(" * YieldVault vs Expected: \(yieldVaultDriftSign)\(formatValue(yieldVaultDriftAbs)) (\(yieldVaultDriftSign)\(formatPercent(yieldVaultDriftAbs / expectedValue))%)") + log(" * Additional YieldVault Loss: \(yieldVaultVsPositionSign)\(formatValue(yieldVaultVsPositionAbs))") + + // Warning if significant drift + if yieldVaultDriftAbs > 0.00000100 { + log("\n⚠️ WARNING: Significant precision drift detected!") + } +} + diff --git a/cadence/tests/forked_rebalance_scenario3a_test.cdc b/cadence/tests/forked_rebalance_scenario3a_test.cdc new file mode 100644 index 00000000..617d3029 --- /dev/null +++ b/cadence/tests/forked_rebalance_scenario3a_test.cdc @@ -0,0 +1,450 @@ +#test_fork(network: "mainnet-fork", height: 143292255) + +import Test +import BlockchainHelpers + +import "test_helpers.cdc" +import "evm_state_helpers.cdc" + +import "FlowYieldVaults" +import "FlowToken" +import "MOET" +import "FlowYieldVaultsStrategiesV2" +import "FlowALPv0" + +// ============================================================================ +// CADENCE ACCOUNTS +// ============================================================================ + +access(all) let flowYieldVaultsAccount = Test.getAccount(0xb1d63873c3cc9f79) +access(all) let flowALPAccount = Test.getAccount(0x6b00ff876c299c61) +access(all) let bandOracleAccount = Test.getAccount(0x6801a6222ebf784a) +access(all) let whaleFlowAccount = Test.getAccount(0x92674150c9213fc9) +access(all) let coaOwnerAccount = Test.getAccount(0xe467b9dd11fa00df) + +access(all) var strategyIdentifier = Type<@FlowYieldVaultsStrategiesV2.FUSDEVStrategy>().identifier +access(all) var flowTokenIdentifier = Type<@FlowToken.Vault>().identifier +access(all) var moetTokenIdentifier = Type<@MOET.Vault>().identifier + +// ============================================================================ +// PROTOCOL ADDRESSES +// ============================================================================ + +// Uniswap V3 Factory on Flow EVM mainnet +access(all) let factoryAddress = "0xca6d7Bb03334bBf135902e1d919a5feccb461632" + +// ============================================================================ +// VAULT & TOKEN ADDRESSES +// ============================================================================ + +// FUSDEV - Morpho VaultV2 (ERC4626) +// Underlying asset: PYUSD0 +access(all) let morphoVaultAddress = "0xd069d989e2F44B70c65347d1853C0c67e10a9F8D" + +// PYUSD0 - Stablecoin (FUSDEV's underlying asset) +access(all) let pyusd0Address = "0x99aF3EeA856556646C98c8B9b2548Fe815240750" + +// MOET - Flow ALP USD +access(all) let moetAddress = "0x213979bB8A9A86966999b3AA797C1fcf3B967ae2" + +// WFLOW - Wrapped Flow +access(all) let wflowAddress = "0xd3bF53DAC106A0290B0483EcBC89d40FcC961f3e" + +// ============================================================================ +// STORAGE SLOT CONSTANTS +// ============================================================================ + +// Token balanceOf mapping slots (for EVM.store to manipulate balances) +access(all) let moetBalanceSlot = 0 as UInt256 +access(all) let pyusd0BalanceSlot = 1 as UInt256 +access(all) let fusdevBalanceSlot = 12 as UInt256 +access(all) let wflowBalanceSlot = 3 as UInt256 + +// Morpho vault storage slots +access(all) let morphoVaultTotalSupplySlot = 11 as UInt256 +access(all) let morphoVaultTotalAssetsSlot = 15 as UInt256 + +// Helper function to get Flow collateral from position +access(all) fun getFlowCollateralFromPosition(pid: UInt64): UFix64 { + let positionDetails = getPositionDetails(pid: pid, beFailed: false) + for balance in positionDetails.balances { + if balance.vaultType == Type<@FlowToken.Vault>() { + // Credit means it's a deposit (collateral) + if balance.direction == FlowALPv0.BalanceDirection.Credit { + return balance.balance + } + } + } + return 0.0 +} + +// Helper function to get MOET debt from position +access(all) fun getMOETDebtFromPosition(pid: UInt64): UFix64 { + let positionDetails = getPositionDetails(pid: pid, beFailed: false) + for balance in positionDetails.balances { + if balance.vaultType == Type<@MOET.Vault>() { + // Debit means it's borrowed (debt) + if balance.direction == FlowALPv0.BalanceDirection.Debit { + return balance.balance + } + } + } + return 0.0 +} + +access(all) +fun setup() { + // Deploy all contracts for mainnet fork + deployContractsForFork() + + // Setup Uniswap V3 pools with structurally valid state + // This sets slot0, observations, liquidity, ticks, bitmap, positions, and POOL token balances + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: pyusd0Address, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 100, reverse: false), + tokenABalanceSlot: pyusd0BalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: pyusd0Address, + tokenBAddress: wflowAddress, + fee: 3000, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 3000, reverse: false), + tokenABalanceSlot: pyusd0BalanceSlot, + tokenBBalanceSlot: wflowBalanceSlot, + signer: coaOwnerAccount + ) + + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: moetAddress, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 100, reverse: false), + tokenABalanceSlot: moetBalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: moetAddress, + tokenBAddress: pyusd0Address, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 100, reverse: false), + tokenABalanceSlot: moetBalanceSlot, + tokenBBalanceSlot: pyusd0BalanceSlot, + signer: coaOwnerAccount + ) + + // BandOracle is used for FLOW and USD (MOET) prices + let symbolPrices = { + "FLOW": 1.0, // Start at 1.0 + "USD": 1.0 // MOET is pegged to USD, always 1.0 + } + setBandOraclePrices(signer: bandOracleAccount, symbolPrices: symbolPrices) + + let reserveAmount = 100_000_00.0 + transferFlow(signer: whaleFlowAccount, recipient: flowALPAccount.address, amount: reserveAmount) + mintMoet(signer: flowALPAccount, to: flowALPAccount.address, amount: reserveAmount, beFailed: false) + + // Fund FlowYieldVaults account for scheduling fees + transferFlow(signer: whaleFlowAccount, recipient: flowYieldVaultsAccount.address, amount: 100.0) +} + +access(all) +fun test_RebalanceYieldVaultScenario3A() { + // Test.reset(to: snapshot) + + let fundingAmount = 1000.0 + let flowPriceDecrease = 0.8 + let yieldPriceIncrease = 1.2 + + let expectedYieldTokenValues = [615.38461538, 492.30769231, 460.74950690] + let expectedFlowCollateralValues = [1000.00000000, 800.00000000, 898.46153846] + let expectedDebtValues = [615.38461538, 492.30769231, 552.89940828] + + let user = Test.createAccount() + + // Likely 0.0 + let flowBalanceBefore = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + log("[TEST] flow balance before \(flowBalanceBefore)") + transferFlow(signer: whaleFlowAccount, recipient: user.address, amount: fundingAmount) + grantBeta(flowYieldVaultsAccount, user) + + // Set vault to baseline 1:1 price + // Use 1 billion (1e9) as base - large enough to prevent slippage, safe from UFix64 overflow + setVaultSharePrice( + vaultAddress: morphoVaultAddress, + assetAddress: pyusd0Address, + assetBalanceSlot: pyusd0BalanceSlot, + totalSupplySlot: morphoVaultTotalSupplySlot, + vaultTotalAssetsSlot: morphoVaultTotalAssetsSlot, + priceMultiplier: 1.0, + signer: user + ) + + createYieldVault( + signer: user, + strategyIdentifier: strategyIdentifier, + vaultIdentifier: flowTokenIdentifier, + amount: fundingAmount, + beFailed: false + ) + + // Capture the actual position ID from the FlowCreditMarket.Opened event + var pid = (getLastPositionOpenedEvent(Test.eventsOfType(Type())) as! FlowALPv0.Opened).pid + + var yieldVaultIDs = getYieldVaultIDs(address: user.address) + Test.assert(yieldVaultIDs != nil, message: "Expected user's YieldVault IDs to be non-nil but encountered nil") + Test.assertEqual(1, yieldVaultIDs!.length) + + let yieldTokensBefore = getAutoBalancerBalance(id: yieldVaultIDs![0])! + let debtBefore = getMOETDebtFromPosition(pid: pid) + let flowCollateralBefore = getFlowCollateralFromPosition(pid: pid) + let flowCollateralValueBefore = flowCollateralBefore * 1.0 // Initial price is 1.0 + + log("\n=== PRECISION COMPARISON (Initial State) ===") + log("Expected Yield Tokens: \(expectedYieldTokenValues[0])") + log("Actual Yield Tokens: \(yieldTokensBefore)") + let diff0 = yieldTokensBefore > expectedYieldTokenValues[0] ? yieldTokensBefore - expectedYieldTokenValues[0] : expectedYieldTokenValues[0] - yieldTokensBefore + let sign0 = yieldTokensBefore > expectedYieldTokenValues[0] ? "+" : "-" + log("Difference: \(sign0)\(diff0)") + log("") + log("Expected Flow Collateral Value: \(expectedFlowCollateralValues[0])") + log("Actual Flow Collateral Value: \(flowCollateralValueBefore)") + let flowDiff0 = flowCollateralValueBefore > expectedFlowCollateralValues[0] ? flowCollateralValueBefore - expectedFlowCollateralValues[0] : expectedFlowCollateralValues[0] - flowCollateralValueBefore + let flowSign0 = flowCollateralValueBefore > expectedFlowCollateralValues[0] ? "+" : "-" + log("Difference: \(flowSign0)\(flowDiff0)") + log("") + log("Expected MOET Debt: \(expectedDebtValues[0])") + log("Actual MOET Debt: \(debtBefore)") + let debtDiff0 = debtBefore > expectedDebtValues[0] ? debtBefore - expectedDebtValues[0] : expectedDebtValues[0] - debtBefore + let debtSign0 = debtBefore > expectedDebtValues[0] ? "+" : "-" + log("Difference: \(debtSign0)\(debtDiff0)") + log("=========================================================\n") + + Test.assert( + equalAmounts(a:yieldTokensBefore, b:expectedYieldTokenValues[0], tolerance: 0.01), + message: "Expected yield tokens to be \(expectedYieldTokenValues[0]) but got \(yieldTokensBefore)" + ) + Test.assert( + equalAmounts(a:flowCollateralValueBefore, b:expectedFlowCollateralValues[0], tolerance: 0.01), + message: "Expected flow collateral value to be \(expectedFlowCollateralValues[0]) but got \(flowCollateralValueBefore)" + ) + Test.assert( + equalAmounts(a:debtBefore, b:expectedDebtValues[0], tolerance: 0.01), + message: "Expected MOET debt to be \(expectedDebtValues[0]) but got \(debtBefore)" + ) + + // === FLOW PRICE DECREASE TO 0.8 === + setBandOraclePrices(signer: bandOracleAccount, symbolPrices: { + "FLOW": flowPriceDecrease, + "USD": 1.0 + }) + + // Update WFLOW/PYUSD0 pool to reflect new FLOW price + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: wflowAddress, + tokenBAddress: pyusd0Address, + fee: 3000, + priceTokenBPerTokenA: feeAdjustedPrice(UFix128(flowPriceDecrease), fee: 3000, reverse: true), + tokenABalanceSlot: wflowBalanceSlot, + tokenBBalanceSlot: pyusd0BalanceSlot, + signer: coaOwnerAccount + ) + + // Position rebalance sells FUSDEV -> MOET to repay debt (reverse direction) + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: moetAddress, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 100, reverse: true), + tokenABalanceSlot: moetBalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + // Possible path: FUSDEV -> PYUSD0 (Morpho redeem) -> PYUSD0 -> MOET (reverse on this pool) + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: moetAddress, + tokenBAddress: pyusd0Address, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 100, reverse: true), + tokenABalanceSlot: moetBalanceSlot, + tokenBBalanceSlot: pyusd0BalanceSlot, + signer: coaOwnerAccount + ) + + rebalanceYieldVault(signer: flowYieldVaultsAccount, id: yieldVaultIDs![0], force: true, beFailed: false) + rebalancePosition(signer: flowALPAccount, pid: pid, force: true, beFailed: false) + + // Debug: Log position details + let positionDetailsAfterRebalance = getPositionDetails(pid: pid, beFailed: false) + log("[DEBUG] Position details after rebalance:") + log(" Health: \(positionDetailsAfterRebalance.health)") + log(" Default token available: \(positionDetailsAfterRebalance.defaultTokenAvailableBalance)") + + let yieldTokensAfterFlowPriceDecrease = getAutoBalancerBalance(id: yieldVaultIDs![0])! + let flowCollateralAfterFlowDecrease = getFlowCollateralFromPosition(pid: pid) + let flowCollateralValueAfterFlowDecrease = flowCollateralAfterFlowDecrease * flowPriceDecrease + let debtAfterFlowDecrease = getMOETDebtFromPosition(pid: pid) + + log("\n=== PRECISION COMPARISON (After Flow Price Decrease) ===") + log("Expected Yield Tokens: \(expectedYieldTokenValues[1])") + log("Actual Yield Tokens: \(yieldTokensAfterFlowPriceDecrease)") + let diff1 = yieldTokensAfterFlowPriceDecrease > expectedYieldTokenValues[1] ? yieldTokensAfterFlowPriceDecrease - expectedYieldTokenValues[1] : expectedYieldTokenValues[1] - yieldTokensAfterFlowPriceDecrease + let sign1 = yieldTokensAfterFlowPriceDecrease > expectedYieldTokenValues[1] ? "+" : "-" + log("Difference: \(sign1)\(diff1)") + log("") + log("Expected Flow Collateral Value: \(expectedFlowCollateralValues[1])") + log("Actual Flow Collateral Value: \(flowCollateralValueAfterFlowDecrease)") + log("Actual Flow Collateral Amount: \(flowCollateralAfterFlowDecrease) Flow tokens") + let flowDiff1 = flowCollateralValueAfterFlowDecrease > expectedFlowCollateralValues[1] ? flowCollateralValueAfterFlowDecrease - expectedFlowCollateralValues[1] : expectedFlowCollateralValues[1] - flowCollateralValueAfterFlowDecrease + let flowSign1 = flowCollateralValueAfterFlowDecrease > expectedFlowCollateralValues[1] ? "+" : "-" + log("Difference: \(flowSign1)\(flowDiff1)") + log("") + log("Expected MOET Debt: \(expectedDebtValues[1])") + log("Actual MOET Debt: \(debtAfterFlowDecrease)") + let debtDiff1 = debtAfterFlowDecrease > expectedDebtValues[1] ? debtAfterFlowDecrease - expectedDebtValues[1] : expectedDebtValues[1] - debtAfterFlowDecrease + let debtSign1 = debtAfterFlowDecrease > expectedDebtValues[1] ? "+" : "-" + log("Difference: \(debtSign1)\(debtDiff1)") + log("=========================================================\n") + + Test.assert( + equalAmounts(a:yieldTokensAfterFlowPriceDecrease, b:expectedYieldTokenValues[1], tolerance: 0.01), + message: "Expected yield tokens after flow price decrease to be \(expectedYieldTokenValues[1]) but got \(yieldTokensAfterFlowPriceDecrease)" + ) + Test.assert( + equalAmounts(a:flowCollateralValueAfterFlowDecrease, b:expectedFlowCollateralValues[1], tolerance: 0.01), + message: "Expected flow collateral value after flow price decrease to be \(expectedFlowCollateralValues[1]) but got \(flowCollateralValueAfterFlowDecrease)" + ) + Test.assert( + equalAmounts(a:debtAfterFlowDecrease, b:expectedDebtValues[1], tolerance: 0.01), + message: "Expected MOET debt after flow price decrease to be \(expectedDebtValues[1]) but got \(debtAfterFlowDecrease)" + ) + + // === YIELD VAULT PRICE INCREASE TO 1.2 === + setVaultSharePrice( + vaultAddress: morphoVaultAddress, + assetAddress: pyusd0Address, + assetBalanceSlot: UInt256(1), + totalSupplySlot: morphoVaultTotalSupplySlot, + vaultTotalAssetsSlot: morphoVaultTotalAssetsSlot, + priceMultiplier: yieldPriceIncrease, + signer: user + ) + + // AutoBalancer sells FUSDEV -> PYUSD0 (reverse on this pool) + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: pyusd0Address, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0 / UFix128(yieldPriceIncrease), fee: 100, reverse: true), + tokenABalanceSlot: pyusd0BalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + // Position rebalance borrows MOET -> FUSDEV (forward on this pool) + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: moetAddress, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0 / UFix128(yieldPriceIncrease), fee: 100, reverse: false), + tokenABalanceSlot: moetBalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + rebalanceYieldVault(signer: flowYieldVaultsAccount, id: yieldVaultIDs![0], force: true, beFailed: false) + //rebalancePosition(signer: protocolAccount, pid: 0, force: true, beFailed: false) + + let yieldTokensAfterYieldPriceIncrease = getAutoBalancerBalance(id: yieldVaultIDs![0])! + let flowCollateralAfterYieldIncrease = getFlowCollateralFromPosition(pid: pid) + let flowCollateralValueAfterYieldIncrease = flowCollateralAfterYieldIncrease * flowPriceDecrease // Flow price remains at 0.8 + let debtAfterYieldIncrease = getMOETDebtFromPosition(pid: pid) + + log("\n=== PRECISION COMPARISON (After Yield Price Increase) ===") + log("Expected Yield Tokens: \(expectedYieldTokenValues[2])") + log("Actual Yield Tokens: \(yieldTokensAfterYieldPriceIncrease)") + let diff2 = yieldTokensAfterYieldPriceIncrease > expectedYieldTokenValues[2] ? yieldTokensAfterYieldPriceIncrease - expectedYieldTokenValues[2] : expectedYieldTokenValues[2] - yieldTokensAfterYieldPriceIncrease + let sign2 = yieldTokensAfterYieldPriceIncrease > expectedYieldTokenValues[2] ? "+" : "-" + log("Difference: \(sign2)\(diff2)") + log("") + log("Expected Flow Collateral Value: \(expectedFlowCollateralValues[2])") + log("Actual Flow Collateral Value: \(flowCollateralValueAfterYieldIncrease)") + log("Actual Flow Collateral Amount: \(flowCollateralAfterYieldIncrease) Flow tokens") + let flowDiff2 = flowCollateralValueAfterYieldIncrease > expectedFlowCollateralValues[2] ? flowCollateralValueAfterYieldIncrease - expectedFlowCollateralValues[2] : expectedFlowCollateralValues[2] - flowCollateralValueAfterYieldIncrease + let flowSign2 = flowCollateralValueAfterYieldIncrease > expectedFlowCollateralValues[2] ? "+" : "-" + log("Difference: \(flowSign2)\(flowDiff2)") + log("") + log("Expected MOET Debt: \(expectedDebtValues[2])") + log("Actual MOET Debt: \(debtAfterYieldIncrease)") + let debtDiff2 = debtAfterYieldIncrease > expectedDebtValues[2] ? debtAfterYieldIncrease - expectedDebtValues[2] : expectedDebtValues[2] - debtAfterYieldIncrease + let debtSign2 = debtAfterYieldIncrease > expectedDebtValues[2] ? "+" : "-" + log("Difference: \(debtSign2)\(debtDiff2)") + log("=========================================================\n") + + Test.assert( + equalAmounts(a:yieldTokensAfterYieldPriceIncrease, b:expectedYieldTokenValues[2], tolerance: 0.01), + message: "Expected yield tokens after yield price increase to be \(expectedYieldTokenValues[2]) but got \(yieldTokensAfterYieldPriceIncrease)" + ) + Test.assert( + equalAmounts(a:flowCollateralValueAfterYieldIncrease, b:expectedFlowCollateralValues[2], tolerance: 0.01), + message: "Expected flow collateral value after yield price increase to be \(expectedFlowCollateralValues[2]) but got \(flowCollateralValueAfterYieldIncrease)" + ) + Test.assert( + equalAmounts(a:debtAfterYieldIncrease, b:expectedDebtValues[2], tolerance: 0.01), + message: "Expected MOET debt after yield price increase to be \(expectedDebtValues[2]) but got \(debtAfterYieldIncrease)" + ) + + // FUSDEV -> MOET for the yield balance check (we want to sell FUSDEV) + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: moetAddress, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0 / UFix128(yieldPriceIncrease), fee: 100, reverse: true), + tokenABalanceSlot: moetBalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + // Check getYieldVaultBalance vs actual available balance before closing + let yieldVaultBalance = getYieldVaultBalance(address: user.address, yieldVaultID: yieldVaultIDs![0])! + + // Get the actual available balance from the position + let positionDetails = getPositionDetails(pid: pid, beFailed: false) + var positionFlowBalance = 0.0 + for balance in positionDetails.balances { + if balance.vaultType == Type<@FlowToken.Vault>() && balance.direction == FlowALPv0.BalanceDirection.Credit { + positionFlowBalance = balance.balance + break + } + } + + log("\n=== DIAGNOSTIC: YieldVault Balance vs Position Available ===") + log("getYieldVaultBalance() reports: \(yieldVaultBalance)") + log("Position Flow balance: \(positionFlowBalance)") + log("Difference: \(positionFlowBalance - yieldVaultBalance)") + log("========================================\n") + + // TODO: closeYieldVault currently fails due to precision issues + // closeYieldVault(signer: user, id: yieldVaultIDs![0], beFailed: false) + + log("\n=== TEST COMPLETE - All precision checks passed ===") +} + + diff --git a/cadence/tests/forked_rebalance_scenario3b_test.cdc b/cadence/tests/forked_rebalance_scenario3b_test.cdc new file mode 100644 index 00000000..2d47b62e --- /dev/null +++ b/cadence/tests/forked_rebalance_scenario3b_test.cdc @@ -0,0 +1,418 @@ +#test_fork(network: "mainnet-fork", height: 143292255) + +import Test +import BlockchainHelpers + +import "test_helpers.cdc" +import "evm_state_helpers.cdc" + +import "FlowYieldVaults" +import "FlowToken" +import "MOET" +import "FlowYieldVaultsStrategiesV2" +import "FlowALPv0" + +// ============================================================================ +// CADENCE ACCOUNTS +// ============================================================================ + +access(all) let flowYieldVaultsAccount = Test.getAccount(0xb1d63873c3cc9f79) +access(all) let flowALPAccount = Test.getAccount(0x6b00ff876c299c61) +access(all) let bandOracleAccount = Test.getAccount(0x6801a6222ebf784a) +access(all) let whaleFlowAccount = Test.getAccount(0x92674150c9213fc9) +access(all) let coaOwnerAccount = Test.getAccount(0xe467b9dd11fa00df) + +access(all) var strategyIdentifier = Type<@FlowYieldVaultsStrategiesV2.FUSDEVStrategy>().identifier +access(all) var flowTokenIdentifier = Type<@FlowToken.Vault>().identifier +access(all) var moetTokenIdentifier = Type<@MOET.Vault>().identifier + +// ============================================================================ +// PROTOCOL ADDRESSES +// ============================================================================ + +// Uniswap V3 Factory on Flow EVM mainnet +access(all) let factoryAddress = "0xca6d7Bb03334bBf135902e1d919a5feccb461632" + +// ============================================================================ +// VAULT & TOKEN ADDRESSES +// ============================================================================ + +// FUSDEV - Morpho VaultV2 (ERC4626) +// Underlying asset: PYUSD0 +access(all) let morphoVaultAddress = "0xd069d989e2F44B70c65347d1853C0c67e10a9F8D" + +// PYUSD0 - Stablecoin (FUSDEV's underlying asset) +access(all) let pyusd0Address = "0x99aF3EeA856556646C98c8B9b2548Fe815240750" + +// MOET - Flow ALP USD +access(all) let moetAddress = "0x213979bB8A9A86966999b3AA797C1fcf3B967ae2" + +// WFLOW - Wrapped Flow +access(all) let wflowAddress = "0xd3bF53DAC106A0290B0483EcBC89d40FcC961f3e" + +// ============================================================================ +// STORAGE SLOT CONSTANTS +// ============================================================================ + +// Token balanceOf mapping slots (for EVM.store to manipulate balances) +access(all) let moetBalanceSlot = 0 as UInt256 +access(all) let pyusd0BalanceSlot = 1 as UInt256 +access(all) let fusdevBalanceSlot = 12 as UInt256 +access(all) let wflowBalanceSlot = 3 as UInt256 + +// Morpho vault storage slots +access(all) let morphoVaultTotalSupplySlot = 11 as UInt256 +access(all) let morphoVaultTotalAssetsSlot = 15 as UInt256 + +// Helper function to get Flow collateral from position +access(all) fun getFlowCollateralFromPosition(pid: UInt64): UFix64 { + let positionDetails = getPositionDetails(pid: pid, beFailed: false) + for balance in positionDetails.balances { + if balance.vaultType == Type<@FlowToken.Vault>() { + // Credit means it's a deposit (collateral) + if balance.direction.rawValue == 0 { // Credit = 0 + return balance.balance + } + } + } + return 0.0 +} + +// Helper function to get MOET debt from position +access(all) fun getMOETDebtFromPosition(pid: UInt64): UFix64 { + let positionDetails = getPositionDetails(pid: pid, beFailed: false) + for balance in positionDetails.balances { + if balance.vaultType == Type<@MOET.Vault>() { + // Debit means it's borrowed (debt) + if balance.direction.rawValue == 1 { // Debit = 1 + return balance.balance + } + } + } + return 0.0 +} + +access(all) +fun setup() { + // Deploy all contracts for mainnet fork + deployContractsForFork() + + // Setup Uniswap V3 pools with structurally valid state + // This sets slot0, observations, liquidity, ticks, bitmap, positions, and POOL token balances + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: pyusd0Address, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 100, reverse: false), + tokenABalanceSlot: pyusd0BalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: pyusd0Address, + tokenBAddress: wflowAddress, + fee: 3000, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 3000, reverse: false), + tokenABalanceSlot: pyusd0BalanceSlot, + tokenBBalanceSlot: wflowBalanceSlot, + signer: coaOwnerAccount + ) + + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: moetAddress, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 100, reverse: false), + tokenABalanceSlot: moetBalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: moetAddress, + tokenBAddress: pyusd0Address, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 100, reverse: false), + tokenABalanceSlot: moetBalanceSlot, + tokenBBalanceSlot: pyusd0BalanceSlot, + signer: coaOwnerAccount + ) + + // BandOracle is used for FLOW and USD (MOET) prices + let symbolPrices = { + "FLOW": 1.0, // Start at 1.0 + "USD": 1.0 // MOET is pegged to USD, always 1.0 + } + setBandOraclePrices(signer: bandOracleAccount, symbolPrices: symbolPrices) + + let reserveAmount = 100_000_00.0 + transferFlow(signer: whaleFlowAccount, recipient: flowALPAccount.address, amount: reserveAmount) + mintMoet(signer: flowALPAccount, to: flowALPAccount.address, amount: reserveAmount, beFailed: false) + + // Fund FlowYieldVaults account for scheduling fees + transferFlow(signer: whaleFlowAccount, recipient: flowYieldVaultsAccount.address, amount: 100.0) +} + +access(all) +fun test_RebalanceYieldVaultScenario3B() { + let fundingAmount = 1000.0 + let flowPriceIncrease = 1.5 + let yieldPriceIncrease = 1.3 + + let expectedYieldTokenValues = [615.38461539, 923.07692308, 841.14701866] + let expectedFlowCollateralValues = [1000.0, 1500.0, 1776.92307692] + let expectedDebtValues = [615.38461539, 923.07692308, 1093.49112426] + + let user = Test.createAccount() + + // Likely 0.0 + let flowBalanceBefore = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + log("[TEST] flow balance before \(flowBalanceBefore)") + transferFlow(signer: whaleFlowAccount, recipient: user.address, amount: fundingAmount) + grantBeta(flowYieldVaultsAccount, user) + + // Set vault to baseline 1:1 price + setVaultSharePrice( + vaultAddress: morphoVaultAddress, + assetAddress: pyusd0Address, + assetBalanceSlot: pyusd0BalanceSlot, + totalSupplySlot: morphoVaultTotalSupplySlot, + vaultTotalAssetsSlot: morphoVaultTotalAssetsSlot, + priceMultiplier: 1.0, + signer: user + ) + + createYieldVault( + signer: user, + strategyIdentifier: strategyIdentifier, + vaultIdentifier: flowTokenIdentifier, + amount: fundingAmount, + beFailed: false + ) + + // Capture the actual position ID from the FlowCreditMarket.Opened event + var pid = (getLastPositionOpenedEvent(Test.eventsOfType(Type())) as! FlowALPv0.Opened).pid + + var yieldVaultIDs = getYieldVaultIDs(address: user.address) + Test.assert(yieldVaultIDs != nil, message: "Expected user's YieldVault IDs to be non-nil but encountered nil") + Test.assertEqual(1, yieldVaultIDs!.length) + + let yieldTokensBefore = getAutoBalancerBalance(id: yieldVaultIDs![0])! + let debtBefore = getMOETDebtFromPosition(pid: pid) + let flowCollateralBefore = getFlowCollateralFromPosition(pid: pid) + let flowCollateralValueBefore = flowCollateralBefore * 1.0 // Initial price is 1.0 + + log("\n=== PRECISION COMPARISON (Initial State) ===") + log("Expected Yield Tokens: \(expectedYieldTokenValues[0])") + log("Actual Yield Tokens: \(yieldTokensBefore)") + let diff0 = yieldTokensBefore > expectedYieldTokenValues[0] ? yieldTokensBefore - expectedYieldTokenValues[0] : expectedYieldTokenValues[0] - yieldTokensBefore + let sign0 = yieldTokensBefore > expectedYieldTokenValues[0] ? "+" : "-" + log("Difference: \(sign0)\(diff0)") + log("") + log("Expected Flow Collateral Value: \(expectedFlowCollateralValues[0])") + log("Actual Flow Collateral Value: \(flowCollateralValueBefore)") + let flowDiff0 = flowCollateralValueBefore > expectedFlowCollateralValues[0] ? flowCollateralValueBefore - expectedFlowCollateralValues[0] : expectedFlowCollateralValues[0] - flowCollateralValueBefore + let flowSign0 = flowCollateralValueBefore > expectedFlowCollateralValues[0] ? "+" : "-" + log("Difference: \(flowSign0)\(flowDiff0)") + log("") + log("Expected MOET Debt: \(expectedDebtValues[0])") + log("Actual MOET Debt: \(debtBefore)") + let debtDiff0 = debtBefore > expectedDebtValues[0] ? debtBefore - expectedDebtValues[0] : expectedDebtValues[0] - debtBefore + let debtSign0 = debtBefore > expectedDebtValues[0] ? "+" : "-" + log("Difference: \(debtSign0)\(debtDiff0)") + log("=========================================================\n") + + Test.assert( + equalAmounts(a:yieldTokensBefore, b:expectedYieldTokenValues[0], tolerance: 0.01), + message: "Expected yield tokens to be \(expectedYieldTokenValues[0]) but got \(yieldTokensBefore)" + ) + Test.assert( + equalAmounts(a:flowCollateralValueBefore, b:expectedFlowCollateralValues[0], tolerance: 0.01), + message: "Expected flow collateral value to be \(expectedFlowCollateralValues[0]) but got \(flowCollateralValueBefore)" + ) + Test.assert( + equalAmounts(a:debtBefore, b:expectedDebtValues[0], tolerance: 0.01), + message: "Expected MOET debt to be \(expectedDebtValues[0]) but got \(debtBefore)" + ) + + // === FLOW PRICE INCREASE TO 1.5 === + setBandOraclePrices(signer: bandOracleAccount, symbolPrices: { + "FLOW": flowPriceIncrease, + "USD": 1.0 + }) + + // Update WFLOW/PYUSD0 pool to reflect new FLOW price + // recollat path traverses PYUSD0 -> WFLOW (reverse on this pool) + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: wflowAddress, + tokenBAddress: pyusd0Address, + fee: 3000, + priceTokenBPerTokenA: feeAdjustedPrice(UFix128(flowPriceIncrease), fee: 3000, reverse: true), + tokenABalanceSlot: wflowBalanceSlot, + tokenBBalanceSlot: pyusd0BalanceSlot, + signer: coaOwnerAccount + ) + + rebalanceYieldVault(signer: flowYieldVaultsAccount, id: yieldVaultIDs![0], force: true, beFailed: false) + rebalancePosition(signer: flowALPAccount, pid: pid, force: true, beFailed: false) + + let yieldTokensAfterFlowPriceIncrease = getAutoBalancerBalance(id: yieldVaultIDs![0])! + let flowCollateralAfterFlowIncrease = getFlowCollateralFromPosition(pid: pid) + let flowCollateralValueAfterFlowIncrease = flowCollateralAfterFlowIncrease * flowPriceIncrease + let debtAfterFlowIncrease = getMOETDebtFromPosition(pid: pid) + + log("\n=== PRECISION COMPARISON (After Flow Price Increase) ===") + log("Expected Yield Tokens: \(expectedYieldTokenValues[1])") + log("Actual Yield Tokens: \(yieldTokensAfterFlowPriceIncrease)") + let diff1 = yieldTokensAfterFlowPriceIncrease > expectedYieldTokenValues[1] ? yieldTokensAfterFlowPriceIncrease - expectedYieldTokenValues[1] : expectedYieldTokenValues[1] - yieldTokensAfterFlowPriceIncrease + let sign1 = yieldTokensAfterFlowPriceIncrease > expectedYieldTokenValues[1] ? "+" : "-" + log("Difference: \(sign1)\(diff1)") + log("") + log("Expected Flow Collateral Value: \(expectedFlowCollateralValues[1])") + log("Actual Flow Collateral Value: \(flowCollateralValueAfterFlowIncrease)") + log("Actual Flow Collateral Amount: \(flowCollateralAfterFlowIncrease) Flow tokens") + let flowDiff1 = flowCollateralValueAfterFlowIncrease > expectedFlowCollateralValues[1] ? flowCollateralValueAfterFlowIncrease - expectedFlowCollateralValues[1] : expectedFlowCollateralValues[1] - flowCollateralValueAfterFlowIncrease + let flowSign1 = flowCollateralValueAfterFlowIncrease > expectedFlowCollateralValues[1] ? "+" : "-" + log("Difference: \(flowSign1)\(flowDiff1)") + log("") + log("Expected MOET Debt: \(expectedDebtValues[1])") + log("Actual MOET Debt: \(debtAfterFlowIncrease)") + let debtDiff1 = debtAfterFlowIncrease > expectedDebtValues[1] ? debtAfterFlowIncrease - expectedDebtValues[1] : expectedDebtValues[1] - debtAfterFlowIncrease + let debtSign1 = debtAfterFlowIncrease > expectedDebtValues[1] ? "+" : "-" + log("Difference: \(debtSign1)\(debtDiff1)") + log("=========================================================\n") + + Test.assert( + equalAmounts(a:yieldTokensAfterFlowPriceIncrease, b:expectedYieldTokenValues[1], tolerance: 0.01), + message: "Expected yield tokens after flow price increase to be \(expectedYieldTokenValues[1]) but got \(yieldTokensAfterFlowPriceIncrease)" + ) + Test.assert( + equalAmounts(a:flowCollateralValueAfterFlowIncrease, b:expectedFlowCollateralValues[1], tolerance: 0.01), + message: "Expected flow collateral value after flow price increase to be \(expectedFlowCollateralValues[1]) but got \(flowCollateralValueAfterFlowIncrease)" + ) + Test.assert( + equalAmounts(a:debtAfterFlowIncrease, b:expectedDebtValues[1], tolerance: 0.01), + message: "Expected MOET debt after flow price increase to be \(expectedDebtValues[1]) but got \(debtAfterFlowIncrease)" + ) + + // === YIELD VAULT PRICE INCREASE TO 1.3 === + setVaultSharePrice( + vaultAddress: morphoVaultAddress, + assetAddress: pyusd0Address, + assetBalanceSlot: UInt256(1), + totalSupplySlot: morphoVaultTotalSupplySlot, + vaultTotalAssetsSlot: morphoVaultTotalAssetsSlot, + priceMultiplier: yieldPriceIncrease, + signer: user + ) + + // AutoBalancer sells FUSDEV -> PYUSD0 (reverse on this pool) + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: pyusd0Address, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0 / UFix128(yieldPriceIncrease), fee: 100, reverse: true), + tokenABalanceSlot: pyusd0BalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + // Position rebalance borrows MOET -> FUSDEV (forward on this pool) + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: moetAddress, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0 / UFix128(yieldPriceIncrease), fee: 100, reverse: false), + tokenABalanceSlot: moetBalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + rebalanceYieldVault(signer: flowYieldVaultsAccount, id: yieldVaultIDs![0], force: true, beFailed: false) + //rebalancePosition(signer: protocolAccount, pid: 0, force: true, beFailed: false) + + let yieldTokensAfterYieldPriceIncrease = getAutoBalancerBalance(id: yieldVaultIDs![0])! + let flowCollateralAfterYieldIncrease = getFlowCollateralFromPosition(pid: pid) + let flowCollateralValueAfterYieldIncrease = flowCollateralAfterYieldIncrease * flowPriceIncrease // Flow price remains at 1.5 + let debtAfterYieldIncrease = getMOETDebtFromPosition(pid: pid) + + log("\n=== PRECISION COMPARISON (After Yield Price Increase) ===") + log("Expected Yield Tokens: \(expectedYieldTokenValues[2])") + log("Actual Yield Tokens: \(yieldTokensAfterYieldPriceIncrease)") + let diff2 = yieldTokensAfterYieldPriceIncrease > expectedYieldTokenValues[2] ? yieldTokensAfterYieldPriceIncrease - expectedYieldTokenValues[2] : expectedYieldTokenValues[2] - yieldTokensAfterYieldPriceIncrease + let sign2 = yieldTokensAfterYieldPriceIncrease > expectedYieldTokenValues[2] ? "+" : "-" + log("Difference: \(sign2)\(diff2)") + log("") + log("Expected Flow Collateral Value: \(expectedFlowCollateralValues[2])") + log("Actual Flow Collateral Value: \(flowCollateralValueAfterYieldIncrease)") + log("Actual Flow Collateral Amount: \(flowCollateralAfterYieldIncrease) Flow tokens") + let flowDiff2 = flowCollateralValueAfterYieldIncrease > expectedFlowCollateralValues[2] ? flowCollateralValueAfterYieldIncrease - expectedFlowCollateralValues[2] : expectedFlowCollateralValues[2] - flowCollateralValueAfterYieldIncrease + let flowSign2 = flowCollateralValueAfterYieldIncrease > expectedFlowCollateralValues[2] ? "+" : "-" + log("Difference: \(flowSign2)\(flowDiff2)") + log("") + log("Expected MOET Debt: \(expectedDebtValues[2])") + log("Actual MOET Debt: \(debtAfterYieldIncrease)") + let debtDiff2 = debtAfterYieldIncrease > expectedDebtValues[2] ? debtAfterYieldIncrease - expectedDebtValues[2] : expectedDebtValues[2] - debtAfterYieldIncrease + let debtSign2 = debtAfterYieldIncrease > expectedDebtValues[2] ? "+" : "-" + log("Difference: \(debtSign2)\(debtDiff2)") + log("=========================================================\n") + + Test.assert( + equalAmounts(a:yieldTokensAfterYieldPriceIncrease, b:expectedYieldTokenValues[2], tolerance: 0.01), + message: "Expected yield tokens after yield price increase to be \(expectedYieldTokenValues[2]) but got \(yieldTokensAfterYieldPriceIncrease)" + ) + Test.assert( + equalAmounts(a:flowCollateralValueAfterYieldIncrease, b:expectedFlowCollateralValues[2], tolerance: 0.01), + message: "Expected flow collateral value after yield price increase to be \(expectedFlowCollateralValues[2]) but got \(flowCollateralValueAfterYieldIncrease)" + ) + Test.assert( + equalAmounts(a:debtAfterYieldIncrease, b:expectedDebtValues[2], tolerance: 0.01), + message: "Expected MOET debt after yield price increase to be \(expectedDebtValues[2]) but got \(debtAfterYieldIncrease)" + ) + + // FUSDEV -> MOET for the yield balance check (we want to sell FUSDEV) + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: moetAddress, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0 / UFix128(yieldPriceIncrease), fee: 100, reverse: true), + tokenABalanceSlot: moetBalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + // Check getYieldVaultBalance vs actual available balance before closing + let yieldVaultBalance = getYieldVaultBalance(address: user.address, yieldVaultID: yieldVaultIDs![0])! + + // Get the actual available balance from the position + let positionDetails = getPositionDetails(pid: pid, beFailed: false) + var positionFlowBalance = 0.0 + for balance in positionDetails.balances { + if balance.vaultType == Type<@FlowToken.Vault>() && balance.direction == FlowALPv0.BalanceDirection.Credit { + positionFlowBalance = balance.balance + break + } + } + + log("\n=== DIAGNOSTIC: YieldVault Balance vs Position Available ===") + log("getYieldVaultBalance() reports: \(yieldVaultBalance)") + log("Position Flow balance: \(positionFlowBalance)") + log("Difference: \(positionFlowBalance - yieldVaultBalance)") + log("========================================\n") + + // Skip closeYieldVault for now due to getYieldVaultBalance precision issues + // closeYieldVault(signer: user, id: yieldVaultIDs![0], beFailed: false) + + log("\n=== TEST COMPLETE ===") +} + + diff --git a/cadence/tests/forked_rebalance_scenario3c_test.cdc b/cadence/tests/forked_rebalance_scenario3c_test.cdc new file mode 100644 index 00000000..0a85e0d0 --- /dev/null +++ b/cadence/tests/forked_rebalance_scenario3c_test.cdc @@ -0,0 +1,394 @@ +#test_fork(network: "mainnet-fork", height: 143292255) + +import Test +import BlockchainHelpers + +import "test_helpers.cdc" +import "evm_state_helpers.cdc" + +import "FlowYieldVaults" +import "FlowToken" +import "MOET" +import "FlowYieldVaultsStrategiesV2" +import "FlowALPv0" + +// ============================================================================ +// CADENCE ACCOUNTS +// ============================================================================ + +access(all) let flowYieldVaultsAccount = Test.getAccount(0xb1d63873c3cc9f79) +access(all) let flowALPAccount = Test.getAccount(0x6b00ff876c299c61) +access(all) let bandOracleAccount = Test.getAccount(0x6801a6222ebf784a) +access(all) let whaleFlowAccount = Test.getAccount(0x92674150c9213fc9) +access(all) let coaOwnerAccount = Test.getAccount(0xe467b9dd11fa00df) + +access(all) var strategyIdentifier = Type<@FlowYieldVaultsStrategiesV2.FUSDEVStrategy>().identifier +access(all) var flowTokenIdentifier = Type<@FlowToken.Vault>().identifier +access(all) var moetTokenIdentifier = Type<@MOET.Vault>().identifier + +// ============================================================================ +// PROTOCOL ADDRESSES +// ============================================================================ + +// Uniswap V3 Factory on Flow EVM mainnet +access(all) let factoryAddress = "0xca6d7Bb03334bBf135902e1d919a5feccb461632" + +// ============================================================================ +// VAULT & TOKEN ADDRESSES +// ============================================================================ + +// FUSDEV - Morpho VaultV2 (ERC4626) +// Underlying asset: PYUSD0 +access(all) let morphoVaultAddress = "0xd069d989e2F44B70c65347d1853C0c67e10a9F8D" + +// PYUSD0 - Stablecoin (FUSDEV's underlying asset) +access(all) let pyusd0Address = "0x99aF3EeA856556646C98c8B9b2548Fe815240750" + +// MOET - Flow ALP USD +access(all) let moetAddress = "0x213979bB8A9A86966999b3AA797C1fcf3B967ae2" + +// WFLOW - Wrapped Flow +access(all) let wflowAddress = "0xd3bF53DAC106A0290B0483EcBC89d40FcC961f3e" + +// ============================================================================ +// STORAGE SLOT CONSTANTS +// ============================================================================ + +// Token balanceOf mapping slots (for EVM.store to manipulate balances) +access(all) let moetBalanceSlot = 0 as UInt256 +access(all) let pyusd0BalanceSlot = 1 as UInt256 +access(all) let fusdevBalanceSlot = 12 as UInt256 +access(all) let wflowBalanceSlot = 3 as UInt256 + +// Morpho vault storage slots +access(all) let morphoVaultTotalSupplySlot = 11 as UInt256 +access(all) let morphoVaultTotalAssetsSlot = 15 as UInt256 + +access(all) +fun setup() { + // Deploy all contracts for mainnet fork + deployContractsForFork() + + // Setup Uniswap V3 pools with structurally valid state + // This sets slot0, observations, liquidity, ticks, bitmap, positions, and POOL token balances + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: pyusd0Address, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 100, reverse: false), + tokenABalanceSlot: pyusd0BalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: pyusd0Address, + tokenBAddress: wflowAddress, + fee: 3000, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 3000, reverse: false), + tokenABalanceSlot: pyusd0BalanceSlot, + tokenBBalanceSlot: wflowBalanceSlot, + signer: coaOwnerAccount + ) + + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: moetAddress, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 100, reverse: false), + tokenABalanceSlot: moetBalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: moetAddress, + tokenBAddress: pyusd0Address, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 100, reverse: false), + tokenABalanceSlot: moetBalanceSlot, + tokenBBalanceSlot: pyusd0BalanceSlot, + signer: coaOwnerAccount + ) + + // BandOracle is only used for FLOW price for FlowALP collateral + let symbolPrices: {String: UFix64} = { + "FLOW": 1.0, + "USD": 1.0 + } + setBandOraclePrices(signer: bandOracleAccount, symbolPrices: symbolPrices) + + let reserveAmount = 100_000_00.0 + transferFlow(signer: whaleFlowAccount, recipient: flowALPAccount.address, amount: reserveAmount) + mintMoet(signer: flowALPAccount, to: flowALPAccount.address, amount: reserveAmount, beFailed: false) + + // Fund FlowYieldVaults account for scheduling fees (atomic initial scheduling) + transferFlow(signer: whaleFlowAccount, recipient: flowYieldVaultsAccount.address, amount: 100.0) +} + +access(all) var testSnapshot: UInt64 = 0 +access(all) +fun test_ForkedRebalanceYieldVaultScenario3C() { + let fundingAmount = 1000.0 + let flowPriceIncrease = 2.0 + let yieldPriceIncrease = 2.0 + + // Expected values from Google sheet calculations + let expectedYieldTokenValues = [615.38461539, 1230.76923077, 994.08284024] + let expectedFlowCollateralValues = [1000.0, 2000.0, 3230.76923077] + let expectedDebtValues = [615.38461539, 1230.76923077, 1988.16568047] + + let user = Test.createAccount() + + // Likely 0.0 + let flowBalanceBefore = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + transferFlow(signer: whaleFlowAccount, recipient: user.address, amount: fundingAmount) + grantBeta(flowYieldVaultsAccount, user) + + // Set vault to baseline 1:1 price + setVaultSharePrice( + vaultAddress: morphoVaultAddress, + assetAddress: pyusd0Address, + assetBalanceSlot: pyusd0BalanceSlot, + totalSupplySlot: morphoVaultTotalSupplySlot, + vaultTotalAssetsSlot: morphoVaultTotalAssetsSlot, + priceMultiplier: 1.0, + signer: user + ) + + createYieldVault( + signer: user, + strategyIdentifier: strategyIdentifier, + vaultIdentifier: flowTokenIdentifier, + amount: fundingAmount, + beFailed: false + ) + + // Capture the actual position ID from the FlowCreditMarket.Opened event + var pid = (getLastPositionOpenedEvent(Test.eventsOfType(Type())) as! FlowALPv0.Opened).pid + log("[TEST] Captured Position ID from event: \(pid)") + + var yieldVaultIDs = getYieldVaultIDs(address: user.address) + log("[TEST] YieldVault ID: \(yieldVaultIDs![0])") + Test.assert(yieldVaultIDs != nil, message: "Expected user's YieldVault IDs to be non-nil but encountered nil") + Test.assertEqual(1, yieldVaultIDs!.length) + + let yieldTokensBefore = getAutoBalancerBalance(id: yieldVaultIDs![0])! + let debtBefore = getMOETDebtFromPosition(pid: pid) + let flowCollateralBefore = getFlowCollateralFromPosition(pid: pid) + let flowCollateralValueBefore = flowCollateralBefore * 1.0 + + log("\n=== PRECISION COMPARISON (Initial State) ===") + log("Expected Yield Tokens: \(expectedYieldTokenValues[0])") + log("Actual Yield Tokens: \(yieldTokensBefore)") + let diff0 = yieldTokensBefore > expectedYieldTokenValues[0] ? yieldTokensBefore - expectedYieldTokenValues[0] : expectedYieldTokenValues[0] - yieldTokensBefore + let sign0 = yieldTokensBefore > expectedYieldTokenValues[0] ? "+" : "-" + log("Difference: \(sign0)\(diff0)") + log("") + log("Expected Flow Collateral Value: \(expectedFlowCollateralValues[0])") + log("Actual Flow Collateral Value: \(flowCollateralValueBefore)") + let flowDiff0 = flowCollateralValueBefore > expectedFlowCollateralValues[0] ? flowCollateralValueBefore - expectedFlowCollateralValues[0] : expectedFlowCollateralValues[0] - flowCollateralValueBefore + let flowSign0 = flowCollateralValueBefore > expectedFlowCollateralValues[0] ? "+" : "-" + log("Difference: \(flowSign0)\(flowDiff0)") + log("") + log("Expected MOET Debt: \(expectedDebtValues[0])") + log("Actual MOET Debt: \(debtBefore)") + let debtDiff0 = debtBefore > expectedDebtValues[0] ? debtBefore - expectedDebtValues[0] : expectedDebtValues[0] - debtBefore + let debtSign0 = debtBefore > expectedDebtValues[0] ? "+" : "-" + log("Difference: \(debtSign0)\(debtDiff0)") + log("=========================================================\n") + + Test.assert( + equalAmounts(a: yieldTokensBefore, b: expectedYieldTokenValues[0], tolerance: 0.01), + message: "Expected yield tokens to be \(expectedYieldTokenValues[0]) but got \(yieldTokensBefore)" + ) + Test.assert( + equalAmounts(a: flowCollateralValueBefore, b: expectedFlowCollateralValues[0], tolerance: 0.01), + message: "Expected flow collateral value to be \(expectedFlowCollateralValues[0]) but got \(flowCollateralValueBefore)" + ) + Test.assert( + equalAmounts(a: debtBefore, b: expectedDebtValues[0], tolerance: 0.01), + message: "Expected MOET debt to be \(expectedDebtValues[0]) but got \(debtBefore)" + ) + + // === FLOW PRICE INCREASE TO 2.0 === + log("\n=== FLOW PRICE → 2.0x ===") + setBandOraclePrices(signer: bandOracleAccount, symbolPrices: { + "FLOW": flowPriceIncrease, + "USD": 1.0 + }) + + // FLOW=$2, so 1 WFLOW = flowPriceIncrease PYUSD0 + // Recollat traverses PYUSD0→WFLOW (reverse on this pool) + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: wflowAddress, + tokenBAddress: pyusd0Address, + fee: 3000, + priceTokenBPerTokenA: feeAdjustedPrice(UFix128(flowPriceIncrease), fee: 3000, reverse: true), + tokenABalanceSlot: wflowBalanceSlot, + tokenBBalanceSlot: pyusd0BalanceSlot, + signer: coaOwnerAccount + ) + + rebalanceYieldVault(signer: flowYieldVaultsAccount, id: yieldVaultIDs![0], force: true, beFailed: false) + rebalancePosition(signer: flowALPAccount, pid: pid, force: true, beFailed: false) + + let yieldTokensAfterFlowPriceIncrease = getAutoBalancerBalance(id: yieldVaultIDs![0])! + let flowCollateralAfterFlowIncrease = getFlowCollateralFromPosition(pid: pid) + let flowCollateralValueAfterFlowIncrease = flowCollateralAfterFlowIncrease * flowPriceIncrease + let debtAfterFlowIncrease = getMOETDebtFromPosition(pid: pid) + + log("\n=== PRECISION COMPARISON (After Flow Price Increase) ===") + log("Expected Yield Tokens: \(expectedYieldTokenValues[1])") + log("Actual Yield Tokens: \(yieldTokensAfterFlowPriceIncrease)") + let diff1 = yieldTokensAfterFlowPriceIncrease > expectedYieldTokenValues[1] ? yieldTokensAfterFlowPriceIncrease - expectedYieldTokenValues[1] : expectedYieldTokenValues[1] - yieldTokensAfterFlowPriceIncrease + let sign1 = yieldTokensAfterFlowPriceIncrease > expectedYieldTokenValues[1] ? "+" : "-" + log("Difference: \(sign1)\(diff1)") + log("") + log("Expected Flow Collateral Value: \(expectedFlowCollateralValues[1])") + log("Actual Flow Collateral Value: \(flowCollateralValueAfterFlowIncrease)") + log("Actual Flow Collateral Amount: \(flowCollateralAfterFlowIncrease) Flow tokens") + let flowDiff1 = flowCollateralValueAfterFlowIncrease > expectedFlowCollateralValues[1] ? flowCollateralValueAfterFlowIncrease - expectedFlowCollateralValues[1] : expectedFlowCollateralValues[1] - flowCollateralValueAfterFlowIncrease + let flowSign1 = flowCollateralValueAfterFlowIncrease > expectedFlowCollateralValues[1] ? "+" : "-" + log("Difference: \(flowSign1)\(flowDiff1)") + log("") + log("Expected MOET Debt: \(expectedDebtValues[1])") + log("Actual MOET Debt: \(debtAfterFlowIncrease)") + let debtDiff1 = debtAfterFlowIncrease > expectedDebtValues[1] ? debtAfterFlowIncrease - expectedDebtValues[1] : expectedDebtValues[1] - debtAfterFlowIncrease + let debtSign1 = debtAfterFlowIncrease > expectedDebtValues[1] ? "+" : "-" + log("Difference: \(debtSign1)\(debtDiff1)") + log("=========================================================\n") + + Test.assert( + equalAmounts(a: yieldTokensAfterFlowPriceIncrease, b: expectedYieldTokenValues[1], tolerance: 0.01), + message: "Expected yield tokens after flow price increase to be \(expectedYieldTokenValues[1]) but got \(yieldTokensAfterFlowPriceIncrease)" + ) + Test.assert( + equalAmounts(a: flowCollateralValueAfterFlowIncrease, b: expectedFlowCollateralValues[1], tolerance: 0.01), + message: "Expected flow collateral value after flow price increase to be \(expectedFlowCollateralValues[1]) but got \(flowCollateralValueAfterFlowIncrease)" + ) + Test.assert( + equalAmounts(a: debtAfterFlowIncrease, b: expectedDebtValues[1], tolerance: 0.01), + message: "Expected MOET debt after flow price increase to be \(expectedDebtValues[1]) but got \(debtAfterFlowIncrease)" + ) + + // === YIELD VAULT PRICE INCREASE TO 2.0 === + log("\n=== YIELD VAULT PRICE → 2.0x ===") + + setVaultSharePrice( + vaultAddress: morphoVaultAddress, + assetAddress: pyusd0Address, + assetBalanceSlot: pyusd0BalanceSlot, + totalSupplySlot: morphoVaultTotalSupplySlot, + vaultTotalAssetsSlot: morphoVaultTotalAssetsSlot, + priceMultiplier: 2.0, + signer: user + ) + + // FUSDEV is now worth 2x: 1 FUSDEV = yieldPriceIncrease PYUSD0 + // Recollat traverses FUSDEV→PYUSD0 (forward on this pool) + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: morphoVaultAddress, + tokenBAddress: pyusd0Address, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(UFix128(yieldPriceIncrease), fee: 100, reverse: false), + tokenABalanceSlot: fusdevBalanceSlot, + tokenBBalanceSlot: pyusd0BalanceSlot, + signer: coaOwnerAccount + ) + + // 1 FUSDEV = yieldPriceIncrease MOET (FUSDEV is now worth 2x) + // Surplus swaps MOET→FUSDEV (reverse on this pool) + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: morphoVaultAddress, + tokenBAddress: moetAddress, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(UFix128(yieldPriceIncrease), fee: 100, reverse: true), + tokenABalanceSlot: fusdevBalanceSlot, + tokenBBalanceSlot: moetBalanceSlot, + signer: coaOwnerAccount + ) + + rebalanceYieldVault(signer: flowYieldVaultsAccount, id: yieldVaultIDs![0], force: true, beFailed: false) + rebalancePosition(signer: flowALPAccount, pid: pid, force: true, beFailed: false) + + let yieldTokensAfterYieldPriceIncrease = getAutoBalancerBalance(id: yieldVaultIDs![0])! + let flowCollateralAfterYieldIncrease = getFlowCollateralFromPosition(pid: pid) + let flowCollateralValueAfterYieldIncrease = flowCollateralAfterYieldIncrease * flowPriceIncrease + let debtAfterYieldIncrease = getMOETDebtFromPosition(pid: pid) + + log("\n=== PRECISION COMPARISON (After Yield Price Increase) ===") + log("Expected Yield Tokens: \(expectedYieldTokenValues[2])") + log("Actual Yield Tokens: \(yieldTokensAfterYieldPriceIncrease)") + let diff2 = yieldTokensAfterYieldPriceIncrease > expectedYieldTokenValues[2] ? yieldTokensAfterYieldPriceIncrease - expectedYieldTokenValues[2] : expectedYieldTokenValues[2] - yieldTokensAfterYieldPriceIncrease + let sign2 = yieldTokensAfterYieldPriceIncrease > expectedYieldTokenValues[2] ? "+" : "-" + log("Difference: \(sign2)\(diff2)") + log("") + log("Expected Flow Collateral Value: \(expectedFlowCollateralValues[2])") + log("Actual Flow Collateral Value: \(flowCollateralValueAfterYieldIncrease)") + log("Actual Flow Collateral Amount: \(flowCollateralAfterYieldIncrease) Flow tokens") + let flowDiff2 = flowCollateralValueAfterYieldIncrease > expectedFlowCollateralValues[2] ? flowCollateralValueAfterYieldIncrease - expectedFlowCollateralValues[2] : expectedFlowCollateralValues[2] - flowCollateralValueAfterYieldIncrease + let flowSign2 = flowCollateralValueAfterYieldIncrease > expectedFlowCollateralValues[2] ? "+" : "-" + log("Difference: \(flowSign2)\(flowDiff2)") + log("") + log("Expected MOET Debt: \(expectedDebtValues[2])") + log("Actual MOET Debt: \(debtAfterYieldIncrease)") + let debtDiff2 = debtAfterYieldIncrease > expectedDebtValues[2] ? debtAfterYieldIncrease - expectedDebtValues[2] : expectedDebtValues[2] - debtAfterYieldIncrease + let debtSign2 = debtAfterYieldIncrease > expectedDebtValues[2] ? "+" : "-" + log("Difference: \(debtSign2)\(debtDiff2)") + log("=========================================================\n") + + Test.assert( + equalAmounts(a: yieldTokensAfterYieldPriceIncrease, b: expectedYieldTokenValues[2], tolerance: 0.01), + message: "Expected yield tokens after yield price increase to be \(expectedYieldTokenValues[2]) but got \(yieldTokensAfterYieldPriceIncrease)" + ) + Test.assert( + equalAmounts(a: flowCollateralValueAfterYieldIncrease, b: expectedFlowCollateralValues[2], tolerance: 0.01), + message: "Expected flow collateral value after yield price increase to be \(expectedFlowCollateralValues[2]) but got \(flowCollateralValueAfterYieldIncrease)" + ) + Test.assert( + equalAmounts(a: debtAfterYieldIncrease, b: expectedDebtValues[2], tolerance: 0.01), + message: "Expected MOET debt after yield price increase to be \(expectedDebtValues[2]) but got \(debtAfterYieldIncrease)" + ) + + // TODO: closeYieldVault currently fails due to precision issues + // closeYieldVault(signer: user, id: yieldVaultIDs![0], beFailed: false) + + log("\n=== TEST COMPLETE ===") +} + + +// Helper function to get Flow collateral from position +access(all) fun getFlowCollateralFromPosition(pid: UInt64): UFix64 { + let positionDetails = getPositionDetails(pid: pid, beFailed: false) + for balance in positionDetails.balances { + if balance.vaultType == Type<@FlowToken.Vault>() { + if balance.direction == FlowALPv0.BalanceDirection.Credit { + return balance.balance + } + } + } + return 0.0 +} + + +// Helper function to get MOET debt from position +access(all) fun getMOETDebtFromPosition(pid: UInt64): UFix64 { + let positionDetails = getPositionDetails(pid: pid, beFailed: false) + for balance in positionDetails.balances { + if balance.vaultType == Type<@MOET.Vault>() { + if balance.direction == FlowALPv0.BalanceDirection.Debit { + return balance.balance + } + } + } + return 0.0 +} + diff --git a/cadence/tests/forked_rebalance_scenario3d_test.cdc b/cadence/tests/forked_rebalance_scenario3d_test.cdc new file mode 100644 index 00000000..e4329063 --- /dev/null +++ b/cadence/tests/forked_rebalance_scenario3d_test.cdc @@ -0,0 +1,406 @@ +#test_fork(network: "mainnet-fork", height: 143292255) + +import Test +import BlockchainHelpers + +import "test_helpers.cdc" +import "evm_state_helpers.cdc" + +import "FlowYieldVaults" +import "FlowToken" +import "MOET" +import "FlowYieldVaultsStrategiesV2" +import "FlowALPv0" + + +// ============================================================================ +// CADENCE ACCOUNTS +// ============================================================================ + +access(all) let flowYieldVaultsAccount = Test.getAccount(0xb1d63873c3cc9f79) +access(all) let flowALPAccount = Test.getAccount(0x6b00ff876c299c61) +access(all) let bandOracleAccount = Test.getAccount(0x6801a6222ebf784a) +access(all) let whaleFlowAccount = Test.getAccount(0x92674150c9213fc9) +access(all) let coaOwnerAccount = Test.getAccount(0xe467b9dd11fa00df) + +access(all) var strategyIdentifier = Type<@FlowYieldVaultsStrategiesV2.FUSDEVStrategy>().identifier +access(all) var flowTokenIdentifier = Type<@FlowToken.Vault>().identifier +access(all) var moetTokenIdentifier = Type<@MOET.Vault>().identifier + +// ============================================================================ +// PROTOCOL ADDRESSES +// ============================================================================ + +// Uniswap V3 Factory on Flow EVM mainnet +access(all) let factoryAddress = "0xca6d7Bb03334bBf135902e1d919a5feccb461632" + +// ============================================================================ +// VAULT & TOKEN ADDRESSES +// ============================================================================ + +// FUSDEV - Morpho VaultV2 (ERC4626) +// Underlying asset: PYUSD0 +access(all) let morphoVaultAddress = "0xd069d989e2F44B70c65347d1853C0c67e10a9F8D" + +// PYUSD0 - Stablecoin (FUSDEV's underlying asset) +access(all) let pyusd0Address = "0x99aF3EeA856556646C98c8B9b2548Fe815240750" + +// MOET - Flow ALP USD +access(all) let moetAddress = "0x213979bB8A9A86966999b3AA797C1fcf3B967ae2" + +// WFLOW - Wrapped Flow +access(all) let wflowAddress = "0xd3bF53DAC106A0290B0483EcBC89d40FcC961f3e" + +// ============================================================================ +// STORAGE SLOT CONSTANTS +// ============================================================================ + +// Token balanceOf mapping slots (for EVM.store to manipulate balances) +access(all) let moetBalanceSlot = 0 as UInt256 +access(all) let pyusd0BalanceSlot = 1 as UInt256 +access(all) let fusdevBalanceSlot = 12 as UInt256 +access(all) let wflowBalanceSlot = 3 as UInt256 + +// Morpho vault storage slots +access(all) let morphoVaultTotalSupplySlot = 11 as UInt256 +access(all) let morphoVaultTotalAssetsSlot = 15 as UInt256 + +access(all) +fun setup() { + // Deploy all contracts for mainnet fork + deployContractsForFork() + + // Setup Uniswap V3 pools with structurally valid state + // This sets slot0, observations, liquidity, ticks, bitmap, positions, and POOL token balances + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: pyusd0Address, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 100, reverse: false), + tokenABalanceSlot: pyusd0BalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: pyusd0Address, + tokenBAddress: wflowAddress, + fee: 3000, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 3000, reverse: false), + tokenABalanceSlot: pyusd0BalanceSlot, + tokenBBalanceSlot: wflowBalanceSlot, + signer: coaOwnerAccount + ) + + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: moetAddress, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 100, reverse: false), + tokenABalanceSlot: moetBalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: moetAddress, + tokenBAddress: pyusd0Address, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 100, reverse: false), + tokenABalanceSlot: moetBalanceSlot, + tokenBBalanceSlot: pyusd0BalanceSlot, + signer: coaOwnerAccount + ) + + // BandOracle is only used for FLOW price for FlowALP collateral + let symbolPrices: {String: UFix64} = { + "FLOW": 1.0, + "USD": 1.0 + } + setBandOraclePrices(signer: bandOracleAccount, symbolPrices: symbolPrices) + + let reserveAmount = 100_000_00.0 + transferFlow(signer: whaleFlowAccount, recipient: flowALPAccount.address, amount: reserveAmount) + mintMoet(signer: flowALPAccount, to: flowALPAccount.address, amount: reserveAmount, beFailed: false) + + // Fund FlowYieldVaults account for scheduling fees (atomic initial scheduling) + transferFlow(signer: whaleFlowAccount, recipient: flowYieldVaultsAccount.address, amount: 100.0) +} + +access(all) var testSnapshot: UInt64 = 0 +access(all) +fun test_ForkedRebalanceYieldVaultScenario3D() { + let fundingAmount = 1000.0 + let flowPriceDecrease = 0.5 + let yieldPriceIncrease = 1.5 + + // Expected values from Google sheet calculations + let expectedYieldTokenValues = [615.38461539, 307.69230769, 268.24457594] + let expectedFlowCollateralValues = [1000.0, 500.0, 653.84615385] + let expectedDebtValues = [615.38461539, 307.69230769, 402.36686391] + + let user = Test.createAccount() + + // Likely 0.0 + let flowBalanceBefore = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + transferFlow(signer: whaleFlowAccount, recipient: user.address, amount: fundingAmount) + grantBeta(flowYieldVaultsAccount, user) + + // Set vault to baseline 1:1 price + setVaultSharePrice( + vaultAddress: morphoVaultAddress, + assetAddress: pyusd0Address, + assetBalanceSlot: pyusd0BalanceSlot, + totalSupplySlot: morphoVaultTotalSupplySlot, + vaultTotalAssetsSlot: morphoVaultTotalAssetsSlot, + priceMultiplier: 1.0, + signer: user + ) + + createYieldVault( + signer: user, + strategyIdentifier: strategyIdentifier, + vaultIdentifier: flowTokenIdentifier, + amount: fundingAmount, + beFailed: false + ) + + // Capture the actual position ID from the FlowCreditMarket.Opened event + var pid = (getLastPositionOpenedEvent(Test.eventsOfType(Type())) as! FlowALPv0.Opened).pid + log("[TEST] Captured Position ID from event: \(pid)") + + var yieldVaultIDs = getYieldVaultIDs(address: user.address) + log("[TEST] YieldVault ID: \(yieldVaultIDs![0])") + Test.assert(yieldVaultIDs != nil, message: "Expected user's YieldVault IDs to be non-nil but encountered nil") + Test.assertEqual(1, yieldVaultIDs!.length) + + let yieldTokensBefore = getAutoBalancerBalance(id: yieldVaultIDs![0])! + let debtBefore = getMOETDebtFromPosition(pid: pid) + let flowCollateralBefore = getFlowCollateralFromPosition(pid: pid) + let flowCollateralValueBefore = flowCollateralBefore * 1.0 + + log("\n=== PRECISION COMPARISON (Initial State) ===") + log("Expected Yield Tokens: \(expectedYieldTokenValues[0])") + log("Actual Yield Tokens: \(yieldTokensBefore)") + let diff0 = yieldTokensBefore > expectedYieldTokenValues[0] ? yieldTokensBefore - expectedYieldTokenValues[0] : expectedYieldTokenValues[0] - yieldTokensBefore + let sign0 = yieldTokensBefore > expectedYieldTokenValues[0] ? "+" : "-" + log("Difference: \(sign0)\(diff0)") + log("") + log("Expected Flow Collateral Value: \(expectedFlowCollateralValues[0])") + log("Actual Flow Collateral Value: \(flowCollateralValueBefore)") + let flowDiff0 = flowCollateralValueBefore > expectedFlowCollateralValues[0] ? flowCollateralValueBefore - expectedFlowCollateralValues[0] : expectedFlowCollateralValues[0] - flowCollateralValueBefore + let flowSign0 = flowCollateralValueBefore > expectedFlowCollateralValues[0] ? "+" : "-" + log("Difference: \(flowSign0)\(flowDiff0)") + log("") + log("Expected MOET Debt: \(expectedDebtValues[0])") + log("Actual MOET Debt: \(debtBefore)") + let debtDiff0 = debtBefore > expectedDebtValues[0] ? debtBefore - expectedDebtValues[0] : expectedDebtValues[0] - debtBefore + let debtSign0 = debtBefore > expectedDebtValues[0] ? "+" : "-" + log("Difference: \(debtSign0)\(debtDiff0)") + log("=========================================================\n") + + Test.assert( + equalAmounts(a: yieldTokensBefore, b: expectedYieldTokenValues[0], tolerance: 0.01), + message: "Expected yield tokens to be \(expectedYieldTokenValues[0]) but got \(yieldTokensBefore)" + ) + Test.assert( + equalAmounts(a: flowCollateralValueBefore, b: expectedFlowCollateralValues[0], tolerance: 0.01), + message: "Expected flow collateral value to be \(expectedFlowCollateralValues[0]) but got \(flowCollateralValueBefore)" + ) + Test.assert( + equalAmounts(a: debtBefore, b: expectedDebtValues[0], tolerance: 0.01), + message: "Expected MOET debt to be \(expectedDebtValues[0]) but got \(debtBefore)" + ) + + // === FLOW PRICE DECREASE TO 0.5 === + log("\n=== FLOW PRICE → 0.5x ===") + setBandOraclePrices(signer: bandOracleAccount, symbolPrices: { + "FLOW": flowPriceDecrease, + "USD": 1.0 + }) + + // FLOW=$0.5, so 1 WFLOW = flowPriceDecrease PYUSD0 + // Undercollat sells FUSDEV→PYUSD0→WFLOW; last hop is PYUSD0→WFLOW (reverse on this pool) + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: wflowAddress, + tokenBAddress: pyusd0Address, + fee: 3000, + priceTokenBPerTokenA: feeAdjustedPrice(UFix128(flowPriceDecrease), fee: 3000, reverse: true), + tokenABalanceSlot: wflowBalanceSlot, + tokenBBalanceSlot: pyusd0BalanceSlot, + signer: coaOwnerAccount + ) + + // MOET/FUSDEV pool: fee adjustment depends on rebalance type + // Deficit (flowPrice < 1.0): swaps FUSDEV→MOET (reverse on this pool) + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: moetAddress, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee: 100, reverse: true), + tokenABalanceSlot: moetBalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + rebalanceYieldVault(signer: flowYieldVaultsAccount, id: yieldVaultIDs![0], force: true, beFailed: false) + rebalancePosition(signer: flowALPAccount, pid: pid, force: true, beFailed: false) + + let yieldTokensAfterFlowPriceDecrease = getAutoBalancerBalance(id: yieldVaultIDs![0])! + let flowCollateralAfterFlowDecrease = getFlowCollateralFromPosition(pid: pid) + let flowCollateralValueAfterFlowDecrease = flowCollateralAfterFlowDecrease * flowPriceDecrease + let debtAfterFlowDecrease = getMOETDebtFromPosition(pid: pid) + + log("\n=== PRECISION COMPARISON (After Flow Price Decrease) ===") + log("Expected Yield Tokens: \(expectedYieldTokenValues[1])") + log("Actual Yield Tokens: \(yieldTokensAfterFlowPriceDecrease)") + let diff1 = yieldTokensAfterFlowPriceDecrease > expectedYieldTokenValues[1] ? yieldTokensAfterFlowPriceDecrease - expectedYieldTokenValues[1] : expectedYieldTokenValues[1] - yieldTokensAfterFlowPriceDecrease + let sign1 = yieldTokensAfterFlowPriceDecrease > expectedYieldTokenValues[1] ? "+" : "-" + log("Difference: \(sign1)\(diff1)") + log("") + log("Expected Flow Collateral Value: \(expectedFlowCollateralValues[1])") + log("Actual Flow Collateral Value: \(flowCollateralValueAfterFlowDecrease)") + log("Actual Flow Collateral Amount: \(flowCollateralAfterFlowDecrease) Flow tokens") + let flowDiff1 = flowCollateralValueAfterFlowDecrease > expectedFlowCollateralValues[1] ? flowCollateralValueAfterFlowDecrease - expectedFlowCollateralValues[1] : expectedFlowCollateralValues[1] - flowCollateralValueAfterFlowDecrease + let flowSign1 = flowCollateralValueAfterFlowDecrease > expectedFlowCollateralValues[1] ? "+" : "-" + log("Difference: \(flowSign1)\(flowDiff1)") + log("") + log("Expected MOET Debt: \(expectedDebtValues[1])") + log("Actual MOET Debt: \(debtAfterFlowDecrease)") + let debtDiff1 = debtAfterFlowDecrease > expectedDebtValues[1] ? debtAfterFlowDecrease - expectedDebtValues[1] : expectedDebtValues[1] - debtAfterFlowDecrease + let debtSign1 = debtAfterFlowDecrease > expectedDebtValues[1] ? "+" : "-" + log("Difference: \(debtSign1)\(debtDiff1)") + log("=========================================================\n") + + Test.assert( + equalAmounts(a: yieldTokensAfterFlowPriceDecrease, b: expectedYieldTokenValues[1], tolerance: 0.01), + message: "Expected yield tokens after flow price decrease to be \(expectedYieldTokenValues[1]) but got \(yieldTokensAfterFlowPriceDecrease)" + ) + Test.assert( + equalAmounts(a: flowCollateralValueAfterFlowDecrease, b: expectedFlowCollateralValues[1], tolerance: 0.01), + message: "Expected flow collateral value after flow price decrease to be \(expectedFlowCollateralValues[1]) but got \(flowCollateralValueAfterFlowDecrease)" + ) + Test.assert( + equalAmounts(a: debtAfterFlowDecrease, b: expectedDebtValues[1], tolerance: 0.01), + message: "Expected MOET debt after flow price decrease to be \(expectedDebtValues[1]) but got \(debtAfterFlowDecrease)" + ) + + // === YIELD VAULT PRICE INCREASE TO 1.5 === + log("\n=== YIELD VAULT PRICE → 1.5x ===") + + setVaultSharePrice( + vaultAddress: morphoVaultAddress, + assetAddress: pyusd0Address, + assetBalanceSlot: pyusd0BalanceSlot, + totalSupplySlot: morphoVaultTotalSupplySlot, + vaultTotalAssetsSlot: morphoVaultTotalAssetsSlot, + priceMultiplier: yieldPriceIncrease, + signer: user + ) + + // FUSDEV is now worth 1.5x: 1 FUSDEV = yieldPriceIncrease PYUSD0 + // Surplus swaps FUSDEV→PYUSD0 (forward on this pool) + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: morphoVaultAddress, + tokenBAddress: pyusd0Address, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(UFix128(yieldPriceIncrease), fee: 100, reverse: false), + tokenABalanceSlot: fusdevBalanceSlot, + tokenBBalanceSlot: pyusd0BalanceSlot, + signer: coaOwnerAccount + ) + + // 1 FUSDEV = yieldPriceIncrease MOET (FUSDEV is now worth 1.5x) + // Overcollat swaps MOET→FUSDEV (reverse on this pool) + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: morphoVaultAddress, + tokenBAddress: moetAddress, + fee: 100, + priceTokenBPerTokenA: feeAdjustedPrice(UFix128(yieldPriceIncrease), fee: 100, reverse: true), + tokenABalanceSlot: fusdevBalanceSlot, + tokenBBalanceSlot: moetBalanceSlot, + signer: coaOwnerAccount + ) + + rebalanceYieldVault(signer: flowYieldVaultsAccount, id: yieldVaultIDs![0], force: true, beFailed: false) + + let yieldTokensAfterYieldPriceIncrease = getAutoBalancerBalance(id: yieldVaultIDs![0])! + let flowCollateralAfterYieldIncrease = getFlowCollateralFromPosition(pid: pid) + let flowCollateralValueAfterYieldIncrease = flowCollateralAfterYieldIncrease * flowPriceDecrease // Flow price remains at 0.5 + let debtAfterYieldIncrease = getMOETDebtFromPosition(pid: pid) + + log("\n=== PRECISION COMPARISON (After Yield Price Increase) ===") + log("Expected Yield Tokens: \(expectedYieldTokenValues[2])") + log("Actual Yield Tokens: \(yieldTokensAfterYieldPriceIncrease)") + let diff2 = yieldTokensAfterYieldPriceIncrease > expectedYieldTokenValues[2] ? yieldTokensAfterYieldPriceIncrease - expectedYieldTokenValues[2] : expectedYieldTokenValues[2] - yieldTokensAfterYieldPriceIncrease + let sign2 = yieldTokensAfterYieldPriceIncrease > expectedYieldTokenValues[2] ? "+" : "-" + log("Difference: \(sign2)\(diff2)") + log("") + log("Expected Flow Collateral Value: \(expectedFlowCollateralValues[2])") + log("Actual Flow Collateral Value: \(flowCollateralValueAfterYieldIncrease)") + log("Actual Flow Collateral Amount: \(flowCollateralAfterYieldIncrease) Flow tokens") + let flowDiff2 = flowCollateralValueAfterYieldIncrease > expectedFlowCollateralValues[2] ? flowCollateralValueAfterYieldIncrease - expectedFlowCollateralValues[2] : expectedFlowCollateralValues[2] - flowCollateralValueAfterYieldIncrease + let flowSign2 = flowCollateralValueAfterYieldIncrease > expectedFlowCollateralValues[2] ? "+" : "-" + log("Difference: \(flowSign2)\(flowDiff2)") + log("") + log("Expected MOET Debt: \(expectedDebtValues[2])") + log("Actual MOET Debt: \(debtAfterYieldIncrease)") + let debtDiff2 = debtAfterYieldIncrease > expectedDebtValues[2] ? debtAfterYieldIncrease - expectedDebtValues[2] : expectedDebtValues[2] - debtAfterYieldIncrease + let debtSign2 = debtAfterYieldIncrease > expectedDebtValues[2] ? "+" : "-" + log("Difference: \(debtSign2)\(debtDiff2)") + log("=========================================================\n") + + Test.assert( + equalAmounts(a: yieldTokensAfterYieldPriceIncrease, b: expectedYieldTokenValues[2], tolerance: 0.01), + message: "Expected yield tokens after yield price increase to be \(expectedYieldTokenValues[2]) but got \(yieldTokensAfterYieldPriceIncrease)" + ) + Test.assert( + equalAmounts(a: flowCollateralValueAfterYieldIncrease, b: expectedFlowCollateralValues[2], tolerance: 0.01), + message: "Expected flow collateral value after yield price increase to be \(expectedFlowCollateralValues[2]) but got \(flowCollateralValueAfterYieldIncrease)" + ) + Test.assert( + equalAmounts(a: debtAfterYieldIncrease, b: expectedDebtValues[2], tolerance: 0.01), + message: "Expected MOET debt after yield price increase to be \(expectedDebtValues[2]) but got \(debtAfterYieldIncrease)" + ) + + // TODO: closeYieldVault currently fails due to precision issues + // closeYieldVault(signer: user, id: yieldVaultIDs![0], beFailed: false) + + log("\n=== TEST COMPLETE ===") +} + + +// Helper function to get Flow collateral from position +access(all) fun getFlowCollateralFromPosition(pid: UInt64): UFix64 { + let positionDetails = getPositionDetails(pid: pid, beFailed: false) + for balance in positionDetails.balances { + if balance.vaultType == Type<@FlowToken.Vault>() { + if balance.direction == FlowALPv0.BalanceDirection.Credit { + return balance.balance + } + } + } + return 0.0 +} + + +// Helper function to get MOET debt from position +access(all) fun getMOETDebtFromPosition(pid: UInt64): UFix64 { + let positionDetails = getPositionDetails(pid: pid, beFailed: false) + for balance in positionDetails.balances { + if balance.vaultType == Type<@MOET.Vault>() { + if balance.direction == FlowALPv0.BalanceDirection.Debit { + return balance.balance + } + } + } + return 0.0 +} diff --git a/cadence/tests/test_helpers.cdc b/cadence/tests/test_helpers.cdc index 87aedce0..cc5567ac 100644 --- a/cadence/tests/test_helpers.cdc +++ b/cadence/tests/test_helpers.cdc @@ -8,8 +8,32 @@ import "FlowALPv0" access(all) let serviceAccount = Test.serviceAccount() -/* --- Test execution helpers --- */ +access(all) struct DeploymentConfig { + access(all) let uniswapFactoryAddress: String + access(all) let uniswapRouterAddress: String + access(all) let uniswapQuoterAddress: String + access(all) let pyusd0Address: String + access(all) let morphoVaultAddress: String + access(all) let wflowAddress: String + + init( + uniswapFactoryAddress: String, + uniswapRouterAddress: String, + uniswapQuoterAddress: String, + pyusd0Address: String, + morphoVaultAddress: String, + wflowAddress: String + ) { + self.uniswapFactoryAddress = uniswapFactoryAddress + self.uniswapRouterAddress = uniswapRouterAddress + self.uniswapQuoterAddress = uniswapQuoterAddress + self.pyusd0Address = pyusd0Address + self.morphoVaultAddress = morphoVaultAddress + self.wflowAddress = wflowAddress + } +} +/* --- Test execution helpers --- */ access(all) fun _executeScript(_ path: String, _ args: [AnyStruct]): Test.ScriptResult { return Test.executeScript(Test.readFile(path), args) @@ -144,11 +168,59 @@ fun tempUpsertBridgeTemplateChunks(_ serviceAccount: Test.TestAccount) { // Common test setup function that deploys all required contracts access(all) fun deployContracts() { - + let config = DeploymentConfig( + uniswapFactoryAddress: "0x986Cb42b0557159431d48fE0A40073296414d410", + uniswapRouterAddress: "0x92657b195e22b69E4779BBD09Fa3CD46F0CF8e39", + uniswapQuoterAddress: "0x8dd92c8d0C3b304255fF9D98ae59c3385F88360C", + pyusd0Address: "0xaCCF0c4EeD4438Ad31Cd340548f4211a465B6528", + morphoVaultAddress: "0x0000000000000000000000000000000000000000", + wflowAddress: "0x0000000000000000000000000000000000000000" + ) + // TODO: remove this step once the VM bridge templates are updated for test env // see https://github.com/onflow/flow-go/issues/8184 tempUpsertBridgeTemplateChunks(serviceAccount) + + _deploy(config: config) + + var err = Test.deployContract( + name: "MockStrategies", + path: "../contracts/mocks/MockStrategies.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + err = Test.deployContract( + name: "MockStrategy", + path: "../contracts/mocks/MockStrategy.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + // Emulator-specific setup (already exists on mainnet fork) + let wflowAddress = getEVMAddressAssociated(withType: Type<@FlowToken.Vault>().identifier) + ?? panic("Failed to get WFLOW address via VM Bridge association with FlowToken.Vault") + setupBetaAccess() + setupPunchswap(deployer: serviceAccount, wflowAddress: wflowAddress) +} + +access(all) fun deployContractsForFork() { + let config = DeploymentConfig( + uniswapFactoryAddress: "0xca6d7Bb03334bBf135902e1d919a5feccb461632", + uniswapRouterAddress: "0xeEDC6Ff75e1b10B903D9013c358e446a73d35341", + uniswapQuoterAddress: "0x370A8DF17742867a44e56223EC20D82092242C85", + pyusd0Address: "0x99aF3EeA856556646C98c8B9b2548Fe815240750", + morphoVaultAddress: "0xd069d989e2F44B70c65347d1853C0c67e10a9F8D", + wflowAddress: "0xd3bF53DAC106A0290B0483EcBC89d40FcC961f3e" + ) + + // Deploy EVM mock + var err = Test.deployContract(name: "EVM", path: "../contracts/mocks/EVM.cdc", arguments: []) + + _deploy(config: config) +} +access(self) fun _deploy(config: DeploymentConfig) { // DeFiActions contracts var err = Test.deployContract( name: "DeFiActionsUtils", @@ -161,6 +233,7 @@ access(all) fun deployContracts() { path: "../../lib/FlowALP/cadence/lib/FlowALPMath.cdc", arguments: [] ) + Test.expect(err, Test.beNil()) err = Test.deployContract( name: "DeFiActions", path: "../../lib/FlowALP/FlowActions/cadence/contracts/interfaces/DeFiActions.cdc", @@ -324,36 +397,18 @@ access(all) fun deployContracts() { ) Test.expect(err, Test.beNil()) - let onboarder = Test.createAccount() - transferFlow(signer: serviceAccount, recipient: onboarder.address, amount: 100.0) - let onboardMoet = _executeTransaction( - "../../lib/flow-evm-bridge/cadence/transactions/bridge/onboarding/onboard_by_type.cdc", - [Type<@MOET.Vault>()], - onboarder - ) - Test.expect(onboardMoet, Test.beSucceeded()) - - err = Test.deployContract( - name: "MockStrategies", - path: "../contracts/mocks/MockStrategies.cdc", - arguments: [] - ) - Test.expect(err, Test.beNil()) - - err = Test.deployContract( - name: "FlowYieldVaultsStrategiesV2", - path: "../contracts/FlowYieldVaultsStrategiesV2.cdc", - arguments: [ - "0x986Cb42b0557159431d48fE0A40073296414d410", - "0x92657b195e22b69E4779BBD09Fa3CD46F0CF8e39", - "0x8dd92c8d0C3b304255fF9D98ae59c3385F88360C" - ] - ) - - Test.expect(err, Test.beNil()) + let moetAddress = getEVMAddressAssociated(withType: Type<@MOET.Vault>().identifier) + if moetAddress == nil { + let onboarder = Test.createAccount() + transferFlow(signer: serviceAccount, recipient: onboarder.address, amount: 100.0) + let onboardMoet = _executeTransaction( + "../../lib/flow-evm-bridge/cadence/transactions/bridge/onboarding/onboard_by_type.cdc", + [Type<@MOET.Vault>()], + onboarder + ) + Test.expect(onboardMoet, Test.beSucceeded()) + } - // Deploy Morpho contracts (latest local code) to the forked environment - log("Deploying Morpho contracts...") err = Test.deployContract( name: "ERC4626Utils", path: "../../lib/FlowALP/FlowActions/cadence/contracts/utils/ERC4626Utils.cdc", @@ -369,16 +424,13 @@ access(all) fun deployContracts() { Test.expect(err, Test.beNil()) err = Test.deployContract( - name: "MorphoERC4626SinkConnectors", - path: "../../lib/FlowALP/FlowActions/cadence/contracts/connectors/evm/morpho/MorphoERC4626SinkConnectors.cdc", - arguments: [] - ) - Test.expect(err, Test.beNil()) - - err = Test.deployContract( - name: "MorphoERC4626SwapConnectors", - path: "../../lib/FlowALP/FlowActions/cadence/contracts/connectors/evm/morpho/MorphoERC4626SwapConnectors.cdc", - arguments: [] + name: "FlowYieldVaultsStrategiesV2", + path: "../contracts/FlowYieldVaultsStrategiesV2.cdc", + arguments: [ + config.uniswapFactoryAddress, + config.uniswapRouterAddress, + config.uniswapQuoterAddress + ] ) Test.expect(err, Test.beNil()) @@ -387,27 +439,12 @@ access(all) fun deployContracts() { name: "PMStrategiesV1", path: "../contracts/PMStrategiesV1.cdc", arguments: [ - "0x0000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000" + config.uniswapRouterAddress, + config.uniswapQuoterAddress, + config.pyusd0Address ] ) - - Test.expect(err, Test.beNil()) - - // Mocked Strategy - err = Test.deployContract( - name: "MockStrategy", - path: "../contracts/mocks/MockStrategy.cdc", - arguments: [] - ) Test.expect(err, Test.beNil()) - - let wflowAddress = getEVMAddressAssociated(withType: Type<@FlowToken.Vault>().identifier) - ?? panic("Failed to get WFLOW address via VM Bridge association with FlowToken.Vault") - - setupBetaAccess() - setupPunchswap(deployer: serviceAccount, wflowAddress: wflowAddress) } access(all) @@ -501,6 +538,9 @@ fun createAndStorePool(signer: Test.TestAccount, defaultTokenIdentifier: String, [defaultTokenIdentifier], signer ) + if createRes.error != nil { + log("createAndStorePool error: ".concat(createRes.error!.message)) + } Test.expect(createRes, beFailed ? Test.beFailed() : Test.beSucceeded()) } @@ -648,6 +688,30 @@ fun equalAmounts(a: UFix64, b: UFix64, tolerance: UFix64): Bool { return b - a <= tolerance } +/// Sets multiple BandOracle prices at once +/// +access(all) +fun setBandOraclePrices(signer: Test.TestAccount, symbolPrices: {String: UFix64}) { + // Move time by 1 second to ensure that the resolve time is in the future + // This prevents race conditions between consecutive calls to setBandOraclePrices + Test.moveTime(by: 1.0) + + let symbolsRates: {String: UInt64} = {} + for symbol in symbolPrices.keys { + // BandOracle uses 1e9 multiplier for prices + // e.g., $1.00 = 1_000_000_000, $0.50 = 500_000_000 + let price = symbolPrices[symbol]! + symbolsRates[symbol] = UInt64(price * 1_000_000_000.0) + } + + let setRes = _executeTransaction( + "../../lib/FlowALP/FlowActions/cadence/tests/transactions/band-oracle/update_data.cdc", + [ symbolsRates ], + signer + ) + Test.expect(setRes, Test.beSucceeded()) +} + /* --- Formatting helpers --- */ access(all) fun formatValue(_ value: UFix64): String { @@ -933,4 +997,4 @@ fun setupPunchswap(deployer: Test.TestAccount, wflowAddress: String): {String: S swapRouter02Address: swapRouter02Address, punchswapV3FactoryAddress: punchswapV3FactoryAddress } -} +} \ No newline at end of file diff --git a/cadence/tests/transactions/deposit_flow_to_coa.cdc b/cadence/tests/transactions/deposit_flow_to_coa.cdc new file mode 100644 index 00000000..1534312a --- /dev/null +++ b/cadence/tests/transactions/deposit_flow_to_coa.cdc @@ -0,0 +1,16 @@ +// Deposits FLOW from signer's FlowToken vault to the signer's COA (native EVM balance). +// Use before swaps/bridges that need the COA to pay gas or bridge fees. +import "FungibleToken" +import "FlowToken" +import "EVM" + +transaction(amount: UFix64) { + prepare(signer: auth(Storage, BorrowValue) &Account) { + let coa = signer.storage.borrow(from: /storage/evm) + ?? panic("No COA at /storage/evm") + let flowVault = signer.storage.borrow(from: /storage/flowTokenVault) + ?? panic("No FlowToken vault") + let deposit <- flowVault.withdraw(amount: amount) as! @FlowToken.Vault + coa.deposit(from: <-deposit) + } +} diff --git a/cadence/tests/transactions/execute_morpho_deposit.cdc b/cadence/tests/transactions/execute_morpho_deposit.cdc new file mode 100644 index 00000000..b6f673fd --- /dev/null +++ b/cadence/tests/transactions/execute_morpho_deposit.cdc @@ -0,0 +1,72 @@ +// Morpho ERC4626 deposit: asset -> vault shares using MorphoERC4626SwapConnectors. +// Signer must have COA, FlowToken vault (for bridge fees), asset vault with balance, and shares vault (created if missing). +import "FungibleToken" +import "FungibleTokenMetadataViews" +import "MetadataViews" +import "FlowToken" +import "EVM" +import "FlowEVMBridgeConfig" +import "DeFiActions" +import "FungibleTokenConnectors" +import "MorphoERC4626SwapConnectors" + +transaction( + assetVaultIdentifier: String, + erc4626VaultEVMAddressHex: String, + amountIn: UFix64 +) { + prepare(signer: auth(Storage, Capabilities, BorrowValue) &Account) { + let erc4626VaultEVMAddress = EVM.addressFromString(erc4626VaultEVMAddressHex) + let sharesType = FlowEVMBridgeConfig.getTypeAssociated(with: erc4626VaultEVMAddress) + ?? panic("ERC4626 vault not associated with a Cadence type") + + let assetVaultData = MetadataViews.resolveContractViewFromTypeIdentifier( + resourceTypeIdentifier: assetVaultIdentifier, + viewType: Type() + ) as? FungibleTokenMetadataViews.FTVaultData + ?? panic("Could not resolve FTVaultData for asset") + let sharesVaultData = MetadataViews.resolveContractViewFromTypeIdentifier( + resourceTypeIdentifier: sharesType.identifier, + viewType: Type() + ) as? FungibleTokenMetadataViews.FTVaultData + ?? panic("Could not resolve FTVaultData for shares") + + if signer.storage.borrow<&{FungibleToken.Vault}>(from: sharesVaultData.storagePath) == nil { + signer.storage.save(<-sharesVaultData.createEmptyVault(), to: sharesVaultData.storagePath) + signer.capabilities.unpublish(sharesVaultData.receiverPath) + signer.capabilities.unpublish(sharesVaultData.metadataPath) + let receiverCap = signer.capabilities.storage.issue<&{FungibleToken.Vault}>(sharesVaultData.storagePath) + signer.capabilities.publish(receiverCap, at: sharesVaultData.receiverPath) + signer.capabilities.publish(receiverCap, at: sharesVaultData.metadataPath) + } + + let coa = signer.capabilities.storage.issue(/storage/evm) + let feeVault = signer.capabilities.storage.issue(/storage/flowTokenVault) + let feeSource = FungibleTokenConnectors.VaultSinkAndSource( + min: nil, + max: nil, + vault: feeVault, + uniqueID: nil + ) + + let swapper = MorphoERC4626SwapConnectors.Swapper( + vaultEVMAddress: erc4626VaultEVMAddress, + coa: coa, + feeSource: feeSource, + uniqueID: nil, + isReversed: false + ) + + let assetVault = signer.storage.borrow(from: assetVaultData.storagePath) + ?? panic("Missing asset vault") + let sharesVault = signer.storage.borrow<&{FungibleToken.Vault}>(from: sharesVaultData.storagePath) + ?? panic("Missing shares vault") + + let inVault <- assetVault.withdraw(amount: amountIn) + let quote = swapper.quoteOut(forProvided: amountIn, reverse: false) + let outVault <- swapper.swap(quote: quote, inVault: <-inVault) + sharesVault.deposit(from: <-outVault) + } + + execute {} +} diff --git a/cadence/tests/transactions/execute_univ3_swap.cdc b/cadence/tests/transactions/execute_univ3_swap.cdc new file mode 100644 index 00000000..54be4017 --- /dev/null +++ b/cadence/tests/transactions/execute_univ3_swap.cdc @@ -0,0 +1,90 @@ +// Generic Uniswap V3 swap: inToken -> outToken on COA. +// Pulls in-token from the COA's EVM balance via EVMTokenConnectors.Source (bridge fee from signer's FlowToken vault), +// then swaps inToken -> outToken. Set the COA's in-token balance first (e.g. set_evm_token_balance for WFLOW). +import "FungibleToken" +import "FungibleTokenMetadataViews" +import "ViewResolver" +import "FlowToken" +import "EVM" +import "FlowEVMBridgeUtils" +import "FlowEVMBridgeConfig" +import "DeFiActions" +import "FungibleTokenConnectors" +import "EVMTokenConnectors" +import "UniswapV3SwapConnectors" + +transaction( + factoryAddress: String, + routerAddress: String, + quoterAddress: String, + inTokenAddress: String, + outTokenAddress: String, + poolFee: UInt64, + amountIn: UFix64 +) { + let coaCap: Capability + let tokenSource: {DeFiActions.Source} + let outReceiver: &{FungibleToken.Vault} + + prepare(signer: auth(Storage, Capabilities, BorrowValue, SaveValue, IssueStorageCapabilityController, PublishCapability, UnpublishCapability) &Account) { + self.coaCap = signer.capabilities.storage.issue(/storage/evm) + + let inAddr = EVM.addressFromString(inTokenAddress) + let inType = FlowEVMBridgeConfig.getTypeAssociated(with: inAddr)! + let feeVault = signer.capabilities.storage.issue(/storage/flowTokenVault) + self.tokenSource = FungibleTokenConnectors.VaultSinkAndSource( + min: nil, + max: nil, + vault: feeVault, + uniqueID: nil + ) + + let outAddr = EVM.addressFromString(outTokenAddress) + let outType = FlowEVMBridgeConfig.getTypeAssociated(with: outAddr)! + let tokenContractAddress = FlowEVMBridgeUtils.getContractAddress(fromType: outType)! + let tokenContractName = FlowEVMBridgeUtils.getContractName(fromType: outType)! + let viewResolver = getAccount(tokenContractAddress).contracts.borrow<&{ViewResolver}>(name: tokenContractName)! + let vaultData = viewResolver.resolveContractView( + resourceType: outType, + viewType: Type() + ) as! FungibleTokenMetadataViews.FTVaultData? + ?? panic("No FTVaultData for out token") + if signer.storage.borrow<&{FungibleToken.Vault}>(from: vaultData.storagePath) == nil { + signer.storage.save(<-vaultData.createEmptyVault(), to: vaultData.storagePath) + signer.capabilities.unpublish(vaultData.receiverPath) + signer.capabilities.unpublish(vaultData.metadataPath) + let receiverCap = signer.capabilities.storage.issue<&{FungibleToken.Vault}>(vaultData.storagePath) + let metadataCap = signer.capabilities.storage.issue<&{FungibleToken.Vault}>(vaultData.storagePath) + signer.capabilities.publish(receiverCap, at: vaultData.receiverPath) + signer.capabilities.publish(metadataCap, at: vaultData.metadataPath) + } + self.outReceiver = signer.storage.borrow<&{FungibleToken.Vault}>(from: vaultData.storagePath)! + } + + execute { + let inAddr = EVM.addressFromString(inTokenAddress) + let outAddr = EVM.addressFromString(outTokenAddress) + let inType = FlowEVMBridgeConfig.getTypeAssociated(with: inAddr)! + let outType = FlowEVMBridgeConfig.getTypeAssociated(with: outAddr)! + + let inVault <- self.tokenSource.withdrawAvailable(maxAmount: amountIn) + + let factory = EVM.addressFromString(factoryAddress) + let router = EVM.addressFromString(routerAddress) + let quoter = EVM.addressFromString(quoterAddress) + let swapper = UniswapV3SwapConnectors.Swapper( + factoryAddress: factory, + routerAddress: router, + quoterAddress: quoter, + tokenPath: [inAddr, outAddr], + feePath: [UInt32(poolFee)], + inVault: inType, + outVault: outType, + coaCapability: self.coaCap, + uniqueID: nil + ) + let quote = swapper.quoteOut(forProvided: inVault.balance, reverse: false) + let outVault <- swapper.swap(quote: quote, inVault: <-inVault) + self.outReceiver.deposit(from: <-outVault) + } +} diff --git a/cadence/tests/transactions/set_erc4626_vault_price.cdc b/cadence/tests/transactions/set_erc4626_vault_price.cdc new file mode 100644 index 00000000..3d870b2e --- /dev/null +++ b/cadence/tests/transactions/set_erc4626_vault_price.cdc @@ -0,0 +1,125 @@ +import EVM from "MockEVM" +import "ERC4626Utils" +import "FlowEVMBridgeUtils" + +// Helper: Compute Solidity mapping storage slot +access(all) fun computeMappingSlot(_ values: [AnyStruct]): String { + let encoded = EVM.encodeABI(values) + let hashBytes = HashAlgorithm.KECCAK_256.hash(encoded) + return String.encodeHex(hashBytes) +} + +// Helper: Compute ERC20 balanceOf storage slot +access(all) fun computeBalanceOfSlot(holderAddress: String, balanceSlot: UInt256): String { + var addrHex = holderAddress + if holderAddress.slice(from: 0, upTo: 2) == "0x" { + addrHex = holderAddress.slice(from: 2, upTo: holderAddress.length) + } + let addrBytes = addrHex.decodeHex() + let address = EVM.EVMAddress(bytes: addrBytes.toConstantSized<[UInt8; 20]>()!) + return computeMappingSlot([address, balanceSlot]) +} + +// Atomically set ERC4626 vault share price +// This manipulates both the underlying asset balance and vault's _totalAssets storage slot +// priceMultiplier: share price as a multiplier (e.g. 2.0 for 2x price) +transaction( + vaultAddress: String, + assetAddress: String, + assetBalanceSlot: UInt256, + totalSupplySlot: UInt256, + vaultTotalAssetsSlot: UInt256, + priceMultiplier: UFix64 +) { + prepare(signer: &Account) {} + + execute { + let vault = EVM.addressFromString(vaultAddress) + let asset = EVM.addressFromString(assetAddress) + + // Query asset decimals from the ERC20 contract + let zeroAddress = EVM.addressFromString("0x0000000000000000000000000000000000000000") + let decimalsCalldata = EVM.encodeABIWithSignature("decimals()", []) + let decimalsResult = EVM.dryCall( + from: zeroAddress, + to: asset, + data: decimalsCalldata, + gasLimit: 100000, + value: EVM.Balance(attoflow: 0) + ) + assert(decimalsResult.status == EVM.Status.successful, message: "Failed to query asset decimals") + let assetDecimals = (EVM.decodeABI(types: [Type()], data: decimalsResult.data)[0] as! UInt8) + + // Query vault decimals + let vaultDecimalsResult = EVM.dryCall( + from: zeroAddress, + to: vault, + data: decimalsCalldata, + gasLimit: 100000, + value: EVM.Balance(attoflow: 0) + ) + assert(vaultDecimalsResult.status == EVM.Status.successful, message: "Failed to query vault decimals") + let vaultDecimals = (EVM.decodeABI(types: [Type()], data: vaultDecimalsResult.data)[0] as! UInt8) + + // Use 2^120 as base — massive value to drown out interest accrual noise, + // with room for multipliers up to ~256x within 128-bit _totalAssets field + let targetAssets: UInt256 = 1 << 120 + // Apply price multiplier via raw fixed-point arithmetic + // UFix64 internally stores value * 10^8, so we extract the raw representation + // and do: finalTargetAssets = targetAssets * rawMultiplier / 10^8 + let multiplierBytes = priceMultiplier.toBigEndianBytes() + var rawMultiplier: UInt256 = 0 + for byte in multiplierBytes { + rawMultiplier = (rawMultiplier << 8) + UInt256(byte) + } + let scale: UInt256 = 100_000_000 // 10^8 + let finalTargetAssets = (targetAssets * rawMultiplier) / scale + + // For a 1:1 price (1 share = 1 asset), we need: + // totalAssets (in assetDecimals) / totalSupply (vault decimals) = 1 + // So: supply_raw = assets_raw * 10^(vaultDecimals - assetDecimals) + // IMPORTANT: Supply should be based on BASE assets, not multiplied assets (to change price per share) + let decimalDifference = vaultDecimals - assetDecimals + let supplyMultiplier = FlowEVMBridgeUtils.pow(base: 10, exponent: decimalDifference) + let finalTargetSupply = targetAssets * supplyMultiplier + + let supplyValue = String.encodeHex(finalTargetSupply.toBigEndianBytes()) + EVM.store(target: vault, slot: String.encodeHex(totalSupplySlot.toBigEndianBytes()), value: supplyValue) + + // Update asset.balanceOf(vault) to finalTargetAssets + let vaultBalanceSlot = computeBalanceOfSlot(holderAddress: vaultAddress, balanceSlot: assetBalanceSlot) + let targetAssetsValue = String.encodeHex(finalTargetAssets.toBigEndianBytes()) + EVM.store(target: asset, slot: vaultBalanceSlot, value: targetAssetsValue) + + // Set vault storage slot (lastUpdate, maxRate, totalAssets packed) + // For testing, we'll set maxRate to 0 to disable interest rate caps + let currentTimestamp = UInt64(getCurrentBlock().timestamp) + let lastUpdateBytes = currentTimestamp.toBigEndianBytes() + let maxRateBytes: [UInt8] = [0, 0, 0, 0, 0, 0, 0, 0] // maxRate = 0 + + // Pad finalTargetAssets to 16 bytes for the slot (bytes 16-31, 16 bytes in slot) + let assetsBytesForSlot = finalTargetAssets.toBigEndianBytes() + var paddedAssets: [UInt8] = [] + var assetsPadCount = 16 - assetsBytesForSlot.length + while assetsPadCount > 0 { + paddedAssets.append(0) + assetsPadCount = assetsPadCount - 1 + } + if assetsBytesForSlot.length <= 16 { + paddedAssets.appendAll(assetsBytesForSlot) + } else { + paddedAssets.appendAll(assetsBytesForSlot.slice(from: assetsBytesForSlot.length - 16, upTo: assetsBytesForSlot.length)) + } + + // Pack the slot: [lastUpdate(8)] [maxRate(8)] [totalAssets(16)] + var newSlotBytes: [UInt8] = [] + newSlotBytes.appendAll(lastUpdateBytes) + newSlotBytes.appendAll(maxRateBytes) + newSlotBytes.appendAll(paddedAssets) + + assert(newSlotBytes.length == 32, message: "Vault storage slot must be exactly 32 bytes, got \(newSlotBytes.length) (lastUpdate: \(lastUpdateBytes.length), maxRate: \(maxRateBytes.length), assets: \(paddedAssets.length))") + + let newSlotValue = String.encodeHex(newSlotBytes) + EVM.store(target: vault, slot: String.encodeHex(vaultTotalAssetsSlot.toBigEndianBytes()), value: newSlotValue) + } +} diff --git a/cadence/tests/transactions/set_uniswap_v3_pool_price.cdc b/cadence/tests/transactions/set_uniswap_v3_pool_price.cdc new file mode 100644 index 00000000..6b4b1b0a --- /dev/null +++ b/cadence/tests/transactions/set_uniswap_v3_pool_price.cdc @@ -0,0 +1,685 @@ +import EVM from "MockEVM" + +// Helper: Compute Solidity mapping storage slot +access(all) fun computeMappingSlot(_ values: [AnyStruct]): String { + let encoded = EVM.encodeABI(values) + let hashBytes = HashAlgorithm.KECCAK_256.hash(encoded) + return String.encodeHex(hashBytes) +} + +// Helper: Compute ERC20 balanceOf storage slot +access(all) fun computeBalanceOfSlot(holderAddress: String, balanceSlot: UInt256): String { + var addrHex = holderAddress + if holderAddress.slice(from: 0, upTo: 2) == "0x" { + addrHex = holderAddress.slice(from: 2, upTo: holderAddress.length) + } + let addrBytes = addrHex.decodeHex() + let address = EVM.EVMAddress(bytes: addrBytes.toConstantSized<[UInt8; 20]>()!) + return computeMappingSlot([address, balanceSlot]) +} + +// Helper: Convert UInt256 to zero-padded 64-char hex string (32 bytes) +access(all) fun toHex32(_ value: UInt256): String { + let raw = value.toBigEndianBytes() + var padded: [UInt8] = [] + var padCount = 32 - raw.length + while padCount > 0 { + padded.append(0) + padCount = padCount - 1 + } + padded = padded.concat(raw) + return String.encodeHex(padded) +} + +// Helper: Convert a slot number (UInt256) to its padded hex string for EVM.store/load +access(all) fun slotHex(_ slotNum: UInt256): String { + return toHex32(slotNum) +} + +// Helper: Parse a hex slot string back to UInt256 +access(all) fun slotToNum(_ slotHex: String): UInt256 { + let bytes = slotHex.decodeHex() + var num = 0 as UInt256 + for byte in bytes { + num = num * 256 + UInt256(byte) + } + return num +} + +// Properly seed Uniswap V3 pool with STRUCTURALLY VALID state +// This creates: slot0, observations, liquidity, ticks (with initialized flag), bitmap, and token balances +transaction( + factoryAddress: String, + tokenAAddress: String, + tokenBAddress: String, + fee: UInt64, + priceTokenBPerTokenA: UFix128, + tokenABalanceSlot: UInt256, + tokenBBalanceSlot: UInt256 +) { + let coa: auth(EVM.Call) &EVM.CadenceOwnedAccount + prepare(signer: auth(Storage) &Account) { + self.coa = signer.storage.borrow(from: /storage/evm) + ?? panic("Could not borrow COA") + } + + execute { + // Convert UFix128 (scale 1e24) to num/den fraction for exact integer arithmetic + let priceBytes = priceTokenBPerTokenA.toBigEndianBytes() + var priceNum: UInt256 = 0 + for byte in priceBytes { + priceNum = (priceNum << 8) + UInt256(byte) + } + let priceDen: UInt256 = 1_000_000_000_000_000_000_000_000 // 1e24 + + // Sort tokens (Uniswap V3 requires token0 < token1) + let factory = EVM.addressFromString(factoryAddress) + let token0 = EVM.addressFromString(tokenAAddress < tokenBAddress ? tokenAAddress : tokenBAddress) + let token1 = EVM.addressFromString(tokenAAddress < tokenBAddress ? tokenBAddress : tokenAAddress) + let token0BalanceSlot = tokenAAddress < tokenBAddress ? tokenABalanceSlot : tokenBBalanceSlot + let token1BalanceSlot = tokenAAddress < tokenBAddress ? tokenBBalanceSlot : tokenABalanceSlot + + // Price is token1/token0. If tokenA < tokenB, priceTokenBPerTokenA = token1/token0 = num/den. + // If tokenA > tokenB, we need to invert: token1/token0 = den/num. + let poolPriceNum = tokenAAddress < tokenBAddress ? priceNum : priceDen + let poolPriceDen = tokenAAddress < tokenBAddress ? priceDen : priceNum + + // Read decimals from EVM + let token0Decimals = getTokenDecimals(evmContractAddress: token0) + let token1Decimals = getTokenDecimals(evmContractAddress: token1) + let decOffset = Int(token1Decimals) - Int(token0Decimals) + + // Compute sqrtPriceX96 from price fraction with full precision. + // poolPrice = poolPriceNum / poolPriceDen (token1/token0 in whole-token terms) + // rawPrice = poolPrice * 10^decOffset (converts to smallest-unit ratio) + // sqrtPriceX96 = floor(sqrt(rawPrice) * 2^96) computed via 512-bit binary search. + + let targetSqrtPriceX96 = sqrtPriceX96FromPrice( + priceNum: poolPriceNum, + priceDen: poolPriceDen, + decOffset: decOffset + ) + let targetTick = getTickAtSqrtRatio(sqrtPriceX96: targetSqrtPriceX96) + + // First check if pool already exists + var getPoolCalldata = EVM.encodeABIWithSignature( + "getPool(address,address,uint24)", + [token0, token1, UInt256(fee)] + ) + var getPoolResult = self.coa.dryCall( + to: factory, + data: getPoolCalldata, + gasLimit: 100000, + value: EVM.Balance(attoflow: 0) + ) + + assert(getPoolResult.status == EVM.Status.successful, message: "Failed to query pool from factory") + + // Decode pool address + var poolAddr = (EVM.decodeABI(types: [Type()], data: getPoolResult.data)[0] as! EVM.EVMAddress) + let zeroAddress = EVM.EVMAddress(bytes: [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]) + + // If pool doesn't exist, create and initialize it + if poolAddr.bytes == zeroAddress.bytes { + // Pool doesn't exist, create it + var calldata = EVM.encodeABIWithSignature( + "createPool(address,address,uint24)", + [token0, token1, UInt256(fee)] + ) + var result = self.coa.call( + to: factory, + data: calldata, + gasLimit: 5000000, + value: EVM.Balance(attoflow: 0) + ) + + assert(result.status == EVM.Status.successful, message: "Pool creation failed") + + // Get the newly created pool address + getPoolResult = self.coa.dryCall(to: factory, data: getPoolCalldata, gasLimit: 100000, value: EVM.Balance(attoflow: 0)) + + assert(getPoolResult.status == EVM.Status.successful && getPoolResult.data.length >= 20, message: "Failed to get pool address after creation") + + poolAddr = (EVM.decodeABI(types: [Type()], data: getPoolResult.data)[0] as! EVM.EVMAddress) + + // Initialize the pool with the target price + calldata = EVM.encodeABIWithSignature( + "initialize(uint160)", + [targetSqrtPriceX96] + ) + result = self.coa.call( + to: poolAddr, + data: calldata, + gasLimit: 5000000, + value: EVM.Balance(attoflow: 0) + ) + + assert(result.status == EVM.Status.successful, message: "Pool initialization failed") + } + + let poolAddress = poolAddr.toString() + + // Read pool parameters (tickSpacing) + let tickSpacingCalldata = EVM.encodeABIWithSignature("tickSpacing()", []) + let spacingResult = self.coa.dryCall( + to: poolAddr, + data: tickSpacingCalldata, + gasLimit: 100000, + value: EVM.Balance(attoflow: 0) + ) + assert(spacingResult.status == EVM.Status.successful, message: "Failed to read tickSpacing") + + let tickSpacing = (EVM.decodeABI(types: [Type()], data: spacingResult.data)[0] as! Int256) + + // Use FULL RANGE ticks (min/max for Uniswap V3), aligned to tickSpacing + let tickLower = (-887272 as Int256) / tickSpacing * tickSpacing + let tickUpper = (887272 as Int256) / tickSpacing * tickSpacing + + // Pack slot0 for Solidity storage layout + // Struct fields packed right-to-left (LSB to MSB): + // sqrtPriceX96 (160 bits) | tick (24 bits) | observationIndex (16 bits) | + // observationCardinality (16 bits) | observationCardinalityNext (16 bits) | + // feeProtocol (8 bits) | unlocked (8 bits) + + // Convert tick to 24-bit two's complement + let tickMask = UInt256(((1 as Int256) << 24) - 1) // 0xFFFFFF + let tickU = UInt256( + targetTick < 0 + ? ((1 as Int256) << 24) + targetTick + : targetTick + ) & tickMask + + var packedValue = targetSqrtPriceX96 // bits [0:159] + packedValue = packedValue + (tickU << UInt256(160)) // bits [160:183] + // observationIndex = 0 // bits [184:199] + packedValue = packedValue + (UInt256(1) << UInt256(200)) // observationCardinality = 1 at bits [200:215] + packedValue = packedValue + (UInt256(1) << UInt256(216)) // observationCardinalityNext = 1 at bits [216:231] + // feeProtocol = 0 // bits [232:239] + packedValue = packedValue + (UInt256(1) << UInt256(240)) // unlocked = 1 at bits [240:247] + + let slot0Value = toHex32(packedValue) + assert(slot0Value.length == 64, message: "slot0 must be 64 hex chars") + + // --- Slot 0: slot0 (packed) --- + EVM.store(target: poolAddr, slot: "0", value: slot0Value) + + // Verify round-trip + let readBack = EVM.load(target: poolAddr, slot: "0") + let readBackHex = String.encodeHex(readBack) + assert(readBackHex == slot0Value, message: "slot0 read-back mismatch - storage corruption!") + + // --- Slots 1-3: feeGrowthGlobal0X128, feeGrowthGlobal1X128, protocolFees = 0 --- + let zero32 = "0000000000000000000000000000000000000000000000000000000000000000" + EVM.store(target: poolAddr, slot: "1", value: zero32) + EVM.store(target: poolAddr, slot: "2", value: zero32) + EVM.store(target: poolAddr, slot: "3", value: zero32) + + // --- Slot 4: liquidity = uint128 max --- + let liquidityAmount: UInt256 = 340282366920938463463374607431768211455 // 2^128 - 1 + EVM.store(target: poolAddr, slot: "4", value: toHex32(liquidityAmount)) + + // --- Initialize boundary ticks --- + // Tick storage layout per tick (4 consecutive slots): + // Slot 0: [liquidityNet (int128, upper 128 bits)] [liquidityGross (uint128, lower 128 bits)] + // Slot 1: feeGrowthOutside0X128 + // Slot 2: feeGrowthOutside1X128 + // Slot 3: packed(tickCumulativeOutside, secondsPerLiquidity, secondsOutside, initialized) + + // Pack tick slot 0: liquidityGross (lower 128) + liquidityNet (upper 128) + // For lower tick: liquidityNet = +L, for upper tick: liquidityNet = -L + let liquidityGross = liquidityAmount + let liquidityNetPositive = liquidityAmount + // Two's complement of -L in 128 bits: 2^128 - L + let twoTo128 = UInt256(1) << 128 + let liquidityNetNegative = twoTo128 - liquidityAmount + + // Lower tick: liquidityNet = +L (upper 128 bits), liquidityGross = L (lower 128 bits) + let tickLowerData0 = toHex32((liquidityNetPositive << 128) + liquidityGross) + + let tickLowerSlot = computeMappingSlot([tickLower, 5]) + let tickLowerSlotNum = slotToNum(tickLowerSlot) + + EVM.store(target: poolAddr, slot: tickLowerSlot, value: tickLowerData0) + EVM.store(target: poolAddr, slot: slotHex(tickLowerSlotNum + 1), value: zero32) // feeGrowthOutside0X128 + EVM.store(target: poolAddr, slot: slotHex(tickLowerSlotNum + 2), value: zero32) // feeGrowthOutside1X128 + // Slot 3: initialized=true (highest byte) + EVM.store(target: poolAddr, slot: slotHex(tickLowerSlotNum + 3), value: "0100000000000000000000000000000000000000000000000000000000000000") + + // Upper tick: liquidityNet = -L (upper 128 bits), liquidityGross = L (lower 128 bits) + let tickUpperData0 = toHex32((liquidityNetNegative << 128) + liquidityGross) + + let tickUpperSlot = computeMappingSlot([tickUpper, 5]) + let tickUpperSlotNum = slotToNum(tickUpperSlot) + + EVM.store(target: poolAddr, slot: tickUpperSlot, value: tickUpperData0) + EVM.store(target: poolAddr, slot: slotHex(tickUpperSlotNum + 1), value: zero32) + EVM.store(target: poolAddr, slot: slotHex(tickUpperSlotNum + 2), value: zero32) + EVM.store(target: poolAddr, slot: slotHex(tickUpperSlotNum + 3), value: "0100000000000000000000000000000000000000000000000000000000000000") + + // --- Set tick bitmaps (OR with existing values) --- + + let compressedLower = tickLower / tickSpacing + let wordPosLower = compressedLower / 256 + var bitPosLower = compressedLower % 256 + if bitPosLower < 0 { bitPosLower = bitPosLower + 256 } + + let compressedUpper = tickUpper / tickSpacing + let wordPosUpper = compressedUpper / 256 + var bitPosUpper = compressedUpper % 256 + if bitPosUpper < 0 { bitPosUpper = bitPosUpper + 256 } + + // Lower tick bitmap: OR with existing + let bitmapLowerSlot = computeMappingSlot([wordPosLower, 6]) + let existingLowerBitmap = bytesToUInt256(EVM.load(target: poolAddr, slot: bitmapLowerSlot)) + let newLowerBitmap = existingLowerBitmap | (UInt256(1) << UInt256(bitPosLower)) + EVM.store(target: poolAddr, slot: bitmapLowerSlot, value: toHex32(newLowerBitmap)) + + // Upper tick bitmap: OR with existing + let bitmapUpperSlot = computeMappingSlot([wordPosUpper, UInt256(6)]) + let existingUpperBitmap = bytesToUInt256(EVM.load(target: poolAddr, slot: bitmapUpperSlot)) + let newUpperBitmap = existingUpperBitmap | (UInt256(1) << UInt256(bitPosUpper)) + EVM.store(target: poolAddr, slot: bitmapUpperSlot, value: toHex32(newUpperBitmap)) + + // --- Slot 8: observations[0] (REQUIRED or swaps will revert!) --- + // Solidity packing (big-endian storage word): + // [initialized(1)] [secondsPerLiquidity(20)] [tickCumulative(7)] [blockTimestamp(4)] + let currentTimestamp = UInt32(getCurrentBlock().timestamp) + + var obs0Bytes: [UInt8] = [] + obs0Bytes.append(1) // initialized = true + obs0Bytes.appendAll([0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]) // secondsPerLiquidityCumulativeX128 + obs0Bytes.appendAll([0,0,0,0,0,0,0]) // tickCumulative + obs0Bytes.appendAll(currentTimestamp.toBigEndianBytes()) // blockTimestamp + + assert(obs0Bytes.length == 32, message: "observations[0] must be exactly 32 bytes") + + EVM.store(target: poolAddr, slot: "8", value: String.encodeHex(obs0Bytes)) + + // --- Fund pool with token balances --- + // Calculate 1 billion tokens in each token's decimal format + var token0Balance: UInt256 = 1000000000 + var i: UInt8 = 0 + while i < token0Decimals { + token0Balance = token0Balance * 10 + i = i + 1 + } + + var token1Balance: UInt256 = 1000000000 + i = 0 + while i < token1Decimals { + token1Balance = token1Balance * 10 + i = i + 1 + } + + // Set token balances (padded to 32 bytes) + let token0BalanceSlotComputed = computeBalanceOfSlot(holderAddress: poolAddress, balanceSlot: token0BalanceSlot) + EVM.store(target: token0, slot: token0BalanceSlotComputed, value: toHex32(token0Balance)) + + let token1BalanceSlotComputed = computeBalanceOfSlot(holderAddress: poolAddress, balanceSlot: token1BalanceSlot) + EVM.store(target: token1, slot: token1BalanceSlotComputed, value: toHex32(token1Balance)) + } +} + +// ============================================================================ +// Canonical Uniswap V3 TickMath — ported from Solidity +// ============================================================================ + +/// Canonical port of TickMath.getSqrtRatioAtTick +/// Calculates sqrt(1.0001^tick) * 2^96 using the exact same bit-decomposition +/// and fixed-point constants as the Solidity implementation. +access(all) fun getSqrtRatioAtTick(tick: Int256): UInt256 { + let absTick: UInt256 = tick < 0 ? UInt256(-tick) : UInt256(tick) + assert(absTick <= 887272, message: "T") + + var ratio: UInt256 = (absTick & 0x1) != 0 + ? 0xfffcb933bd6fad37aa2d162d1a594001 + : 0x100000000000000000000000000000000 + + if (absTick & 0x2) != 0 { ratio = (ratio * 0xfff97272373d413259a46990580e213a) >> 128 } + if (absTick & 0x4) != 0 { ratio = (ratio * 0xfff2e50f5f656932ef12357cf3c7fdcc) >> 128 } + if (absTick & 0x8) != 0 { ratio = (ratio * 0xffe5caca7e10e4e61c3624eaa0941cd0) >> 128 } + if (absTick & 0x10) != 0 { ratio = (ratio * 0xffcb9843d60f6159c9db58835c926644) >> 128 } + if (absTick & 0x20) != 0 { ratio = (ratio * 0xff973b41fa98c081472e6896dfb254c0) >> 128 } + if (absTick & 0x40) != 0 { ratio = (ratio * 0xff2ea16466c96a3843ec78b326b52861) >> 128 } + if (absTick & 0x80) != 0 { ratio = (ratio * 0xfe5dee046a99a2a811c461f1969c3053) >> 128 } + if (absTick & 0x100) != 0 { ratio = (ratio * 0xfcbe86c7900a88aedcffc83b479aa3a4) >> 128 } + if (absTick & 0x200) != 0 { ratio = (ratio * 0xf987a7253ac413176f2b074cf7815e54) >> 128 } + if (absTick & 0x400) != 0 { ratio = (ratio * 0xf3392b0822b70005940c7a398e4b70f3) >> 128 } + if (absTick & 0x800) != 0 { ratio = (ratio * 0xe7159475a2c29b7443b29c7fa6e889d9) >> 128 } + if (absTick & 0x1000) != 0 { ratio = (ratio * 0xd097f3bdfd2022b8845ad8f792aa5825) >> 128 } + if (absTick & 0x2000) != 0 { ratio = (ratio * 0xa9f746462d870fdf8a65dc1f90e061e5) >> 128 } + if (absTick & 0x4000) != 0 { ratio = (ratio * 0x70d869a156d2a1b890bb3df62baf32f7) >> 128 } + if (absTick & 0x8000) != 0 { ratio = (ratio * 0x31be135f97d08fd981231505542fcfa6) >> 128 } + if (absTick & 0x10000) != 0 { ratio = (ratio * 0x9aa508b5b7a84e1c677de54f3e99bc9) >> 128 } + if (absTick & 0x20000) != 0 { ratio = (ratio * 0x5d6af8dedb81196699c329225ee604) >> 128 } + if (absTick & 0x40000) != 0 { ratio = (ratio * 0x2216e584f5fa1ea926041bedfe98) >> 128 } + if (absTick & 0x80000) != 0 { ratio = (ratio * 0x48a170391f7dc42444e8fa2) >> 128 } + + if tick > 0 { + // type(uint256).max / ratio + ratio = UInt256.max / ratio + } + + // Divide by 1<<32, rounding up: (ratio >> 32) + (ratio % (1 << 32) == 0 ? 0 : 1) + let remainder = ratio % (UInt256(1) << 32) + let sqrtPriceX96 = (ratio >> 32) + (remainder == 0 ? 0 : 1 as UInt256) + + return sqrtPriceX96 +} + +/// Canonical port of TickMath.getTickAtSqrtRatio +/// Calculates the greatest tick value such that getSqrtRatioAtTick(tick) <= sqrtPriceX96 +access(all) fun getTickAtSqrtRatio(sqrtPriceX96: UInt256): Int256 { + assert(sqrtPriceX96 >= 4295128739 && sqrtPriceX96 < 1461446703485210103287273052203988822378723970342 as UInt256, message: "R") + + let ratio = sqrtPriceX96 << 32 + var r = ratio + var msb: UInt256 = 0 + + // Find MSB using binary search + // f = (r > 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) ? 128 : 0 + var f: UInt256 = r > 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF ? 128 : 0 + msb = msb | f + r = r >> f + + f = r > 0xFFFFFFFFFFFFFFFF ? 64 : 0 + msb = msb | f + r = r >> f + + f = r > 0xFFFFFFFF ? 32 : 0 + msb = msb | f + r = r >> f + + f = r > 0xFFFF ? 16 : 0 + msb = msb | f + r = r >> f + + f = r > 0xFF ? 8 : 0 + msb = msb | f + r = r >> f + + f = r > 0xF ? 4 : 0 + msb = msb | f + r = r >> f + + f = r > 0x3 ? 2 : 0 + msb = msb | f + r = r >> f + + f = r > 0x1 ? 1 : 0 + msb = msb | f + + if msb >= 128 { + r = ratio >> (msb - 127) + } else { + r = ratio << (127 - msb) + } + + // Compute log_2 in Q64.64 fixed-point + let _2_64: Int256 = 1 << 64 + var log_2: Int256 = (Int256(msb) - 128) * _2_64 + + // 14 iterations of squaring to refine the fractional part + r = (r * r) >> 127 + f = r >> 128 + log_2 = log_2 | Int256(f << 63) + r = r >> f + + r = (r * r) >> 127 + f = r >> 128 + log_2 = log_2 | Int256(f << 62) + r = r >> f + + r = (r * r) >> 127 + f = r >> 128 + log_2 = log_2 | Int256(f << 61) + r = r >> f + + r = (r * r) >> 127 + f = r >> 128 + log_2 = log_2 | Int256(f << 60) + r = r >> f + + r = (r * r) >> 127 + f = r >> 128 + log_2 = log_2 | Int256(f << 59) + r = r >> f + + r = (r * r) >> 127 + f = r >> 128 + log_2 = log_2 | Int256(f << 58) + r = r >> f + + r = (r * r) >> 127 + f = r >> 128 + log_2 = log_2 | Int256(f << 57) + r = r >> f + + r = (r * r) >> 127 + f = r >> 128 + log_2 = log_2 | Int256(f << 56) + r = r >> f + + r = (r * r) >> 127 + f = r >> 128 + log_2 = log_2 | Int256(f << 55) + r = r >> f + + r = (r * r) >> 127 + f = r >> 128 + log_2 = log_2 | Int256(f << 54) + r = r >> f + + r = (r * r) >> 127 + f = r >> 128 + log_2 = log_2 | Int256(f << 53) + r = r >> f + + r = (r * r) >> 127 + f = r >> 128 + log_2 = log_2 | Int256(f << 52) + r = r >> f + + r = (r * r) >> 127 + f = r >> 128 + log_2 = log_2 | Int256(f << 51) + r = r >> f + + r = (r * r) >> 127 + f = r >> 128 + log_2 = log_2 | Int256(f << 50) + + // log_sqrt10001 = log_2 * 255738958999603826347141 (128.128 number) + let log_sqrt10001 = log_2 * 255738958999603826347141 + + // Compute tick bounds + let tickLow = Int256((log_sqrt10001 - 3402992956809132418596140100660247210) >> 128) + let tickHi = Int256((log_sqrt10001 + 291339464771989622907027621153398088495) >> 128) + + if tickLow == tickHi { + return tickLow + } + + // Check which tick is correct + let sqrtRatioAtTickHi = getSqrtRatioAtTick(tick: tickHi) + if sqrtRatioAtTickHi <= sqrtPriceX96 { + return tickHi + } + return tickLow +} + +// ============================================================================ +// 512-bit arithmetic for exact sqrtPriceX96 computation +// ============================================================================ + +/// Multiply two UInt256 values, returning a 512-bit result as [hi, lo]. +/// +/// Uses 64-bit limb decomposition to avoid any overflow in Cadence's non-wrapping arithmetic. +/// Each operand is split into four 64-bit limbs. Partial products (64×64→128 bits) fit +/// comfortably in UInt256, and we accumulate with carries tracked explicitly. +access(all) fun mul256x256(_ a: UInt256, _ b: UInt256): [UInt256; 2] { + let MASK64: UInt256 = (1 << 64) - 1 + + // Split a into 64-bit limbs: a = a3*2^192 + a2*2^128 + a1*2^64 + a0 + let a0 = a & MASK64 + let a1 = (a >> 64) & MASK64 + let a2 = (a >> 128) & MASK64 + let a3 = (a >> 192) & MASK64 + + // Split b into 64-bit limbs + let b0 = b & MASK64 + let b1 = (b >> 64) & MASK64 + let b2 = (b >> 128) & MASK64 + let b3 = (b >> 192) & MASK64 + + // Result has 8 limbs (r0..r7), each 64 bits. + // We accumulate into a carry variable as we go. + // For each output limb position k, sum all ai*bj where i+j=k, plus carry from previous. + + // Limb 0 (position 0): a0*b0 + var acc = a0 * b0 // max 128 bits, fits in UInt256 + let r0 = acc & MASK64 + acc = acc >> 64 + + // Limb 1 (position 64): a0*b1 + a1*b0 + acc = acc + a0 * b1 + a1 * b0 + let r1 = acc & MASK64 + acc = acc >> 64 + + // Limb 2 (position 128): a0*b2 + a1*b1 + a2*b0 + acc = acc + a0 * b2 + a1 * b1 + a2 * b0 + let r2 = acc & MASK64 + acc = acc >> 64 + + // Limb 3 (position 192): a0*b3 + a1*b2 + a2*b1 + a3*b0 + acc = acc + a0 * b3 + a1 * b2 + a2 * b1 + a3 * b0 + let r3 = acc & MASK64 + acc = acc >> 64 + + // Limb 4 (position 256): a1*b3 + a2*b2 + a3*b1 + acc = acc + a1 * b3 + a2 * b2 + a3 * b1 + let r4 = acc & MASK64 + acc = acc >> 64 + + // Limb 5 (position 320): a2*b3 + a3*b2 + acc = acc + a2 * b3 + a3 * b2 + let r5 = acc & MASK64 + acc = acc >> 64 + + // Limb 6 (position 384): a3*b3 + acc = acc + a3 * b3 + let r6 = acc & MASK64 + let r7 = acc >> 64 + + let lo = r0 + (r1 << 64) + (r2 << 128) + (r3 << 192) + let hi = r4 + (r5 << 64) + (r6 << 128) + (r7 << 192) + + return [hi, lo] +} + +/// Compare two 512-bit numbers: (aHi, aLo) <= (bHi, bLo) +access(all) fun lte512(aHi: UInt256, aLo: UInt256, bHi: UInt256, bLo: UInt256): Bool { + if aHi != bHi { return aHi < bHi } + return aLo <= bLo +} + +/// Compute sqrtPriceX96 = floor(sqrt(price) * 2^96) exactly from a price fraction. +/// +/// priceNum/priceDen: human price as an exact fraction (e.g. 1/3 for 0.333...) +/// decOffset: token1Decimals - token0Decimals +/// +/// The raw price in smallest-unit terms is: rawPrice = (priceNum/priceDen) * 10^decOffset +/// We represent this as a fraction: num / den, where both are UInt256. +/// +/// We want the largest y such that: y^2 / 2^192 <= num / den +/// Equivalently: y^2 * den <= num * 2^192 +/// +/// Both sides can exceed 256 bits (y is up to 160 bits, so y^2 is up to 320 bits), +/// so we use 512-bit arithmetic via mul256x256. +access(all) fun sqrtPriceX96FromPrice(priceNum: UInt256, priceDen: UInt256, decOffset: Int): UInt256 { + // Build num and den such that rawPrice = num / den + // rawPrice = (priceNum / priceDen) * 10^decOffset + var num = priceNum + var den = priceDen + + if decOffset >= 0 { + var p = 0 + while p < decOffset { + num = num * 10 + p = p + 1 + } + } else { + var p = 0 + while p < -decOffset { + den = den * 10 + p = p + 1 + } + } + + // We want largest y where y^2 * den <= num * 2^192 + // Compute RHS = num * 2^192 as 512-bit: num * 2^192 = (num << 192) split into (hi, lo) + // num << 192: if num fits in 64 bits, num << 192 fits in ~256 bits + // But to be safe, compute as: mul256x256(num, 2^192) + // 2^192 = UInt256, so this is just a shift — but num could be large after scaling. + // Use: rhsHi = num >> 64, rhsLo = num << 192 + let rhsHi = num >> 64 + let rhsLo = num << 192 + + // Binary search over y in [MIN_SQRT_RATIO, MAX_SQRT_RATIO] + let MIN_SQRT_RATIO: UInt256 = 4295128739 + let MAX_SQRT_RATIO: UInt256 = 1461446703485210103287273052203988822378723970341 + + var lo = MIN_SQRT_RATIO + var hi = MAX_SQRT_RATIO + + while lo < hi { + // Use upper-mid to find the greatest y satisfying the condition + let mid = lo + (hi - lo + 1) / 2 + + // Compute mid^2 * den as 512-bit + // sq[0] = hi, sq[1] = lo + let sq = mul256x256(mid, mid) + // Now multiply (sq[0], sq[1]) by den + // = sq[0]*den * 2^256 + sq[1]*den + // sq[1] * den may produce a 512-bit result + let loProd = mul256x256(sq[1], den) + let hiProd = sq[0] * den // fits if sq[0] is small (which it is for valid sqrt ratios) + let lhsHi = hiProd + loProd[0] + let lhsLo = loProd[1] + + if lte512(aHi: lhsHi, aLo: lhsLo, bHi: rhsHi, bLo: rhsLo) { + lo = mid + } else { + hi = mid - 1 + } + } + + return lo +} + +// ============================================================================ +// Byte helpers +// ============================================================================ + +/// Parse raw bytes (from EVM.load) into UInt256. Works for any length <= 32. +access(all) fun bytesToUInt256(_ bytes: [UInt8]): UInt256 { + var result: UInt256 = 0 + for byte in bytes { + result = result * 256 + UInt256(byte) + } + return result +} + +access(all) fun getTokenDecimals(evmContractAddress: EVM.EVMAddress): UInt8 { + let zeroAddress = EVM.addressFromString("0x0000000000000000000000000000000000000000") + let callResult = EVM.dryCall( + from: zeroAddress, + to: evmContractAddress, + data: EVM.encodeABIWithSignature("decimals()", []), + gasLimit: 100000, + value: EVM.Balance(attoflow: 0) + ) + + assert(callResult.status == EVM.Status.successful, message: "Call for EVM asset decimals failed") + return (EVM.decodeABI(types: [Type()], data: callResult.data)[0] as! UInt8) +} diff --git a/flow.json b/flow.json index dd1f2ee7..d0764ea7 100644 --- a/flow.json +++ b/flow.json @@ -212,9 +212,19 @@ "source": "./lib/FlowALP/cadence/contracts/mocks/MockDexSwapper.cdc", "aliases": { "emulator": "045a1763c93006ca", + "mainnet": "b1d63873c3cc9f79", + "mainnet-fork": "b1d63873c3cc9f79", "testing": "0000000000000007" } }, + "MockEVM": { + "source": "./cadence/contracts/mocks/EVM.cdc", + "aliases": { + "emulator": "f8d6e0586b0a20c7", + "mainnet": "e467b9dd11fa00df", + "testnet": "8c5303eaa26202d6" + } + }, "MockOracle": { "source": "cadence/contracts/mocks/MockOracle.cdc", "aliases": {