diff --git a/.github/renovate.json b/.github/renovate.json index 8ad2b13bca..f04adb7000 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -17,6 +17,7 @@ "draftPR": true, "cloneSubmodules": true, "forkProcessing": "enabled", + "ignorePaths": ["packages/contracts-core/**"], "packageRules": [ { "matchUpdateTypes": [ diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 9288fca1bc..5f7537182b 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -31,3 +31,10 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} # Optional filesizelimit: 15MB + + - name: Add 'fe-release' label + if: github.event.pull_request.base.ref == 'fe-release' + uses: actions-ecosystem/action-add-labels@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + labels: 'fe-release' diff --git a/docs/bridge/CHANGELOG.md b/docs/bridge/CHANGELOG.md index 60f5634ae4..73efed7d90 100644 --- a/docs/bridge/CHANGELOG.md +++ b/docs/bridge/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.3.8](https://github.com/synapsecns/sanguine/compare/@synapsecns/bridge-docs@0.3.7...@synapsecns/bridge-docs@0.3.8) (2024-10-07) + +**Note:** Version bump only for package @synapsecns/bridge-docs + + + + + ## [0.3.7](https://github.com/synapsecns/sanguine/compare/@synapsecns/bridge-docs@0.3.6...@synapsecns/bridge-docs@0.3.7) (2024-10-05) **Note:** Version bump only for package @synapsecns/bridge-docs diff --git a/docs/bridge/docs/01-About/03-Routes.md b/docs/bridge/docs/01-About/03-Routes.md new file mode 100644 index 0000000000..5a6f844eb6 --- /dev/null +++ b/docs/bridge/docs/01-About/03-Routes.md @@ -0,0 +1,7 @@ +import Routes from '@site/src/components/Routes' + +# Chains & Tokens + +This page contains a list of supported tokens, listed per-chain. For a given pair, use the [Synapse Bridge](https://synapseprotocol.com) to see if a route between them exists. + + diff --git a/docs/bridge/docs/02-Bridge/04-Sample-Code.md b/docs/bridge/docs/02-Bridge/04-Code-Examples.md similarity index 99% rename from docs/bridge/docs/02-Bridge/04-Sample-Code.md rename to docs/bridge/docs/02-Bridge/04-Code-Examples.md index ca625344a3..1816550110 100644 --- a/docs/bridge/docs/02-Bridge/04-Sample-Code.md +++ b/docs/bridge/docs/02-Bridge/04-Code-Examples.md @@ -1,8 +1,8 @@ --- -sidebar_label: Sample Code +sidebar_label: Examples --- -# Sample Code +# Example Code Example SDK & API implementations diff --git a/docs/bridge/docs/02-Bridge/05-Supported-Routes.md b/docs/bridge/docs/02-Bridge/05-Supported-Routes.md deleted file mode 100644 index f47a6918f2..0000000000 --- a/docs/bridge/docs/02-Bridge/05-Supported-Routes.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -sidebar_label: Supported Rotues ---- - -# Supported Routes - -Use the [Synapse Bridge](https://synapseprotocol.com) to browse supported tokens and chains. - -:::tip Routes - -Route availability is determined by the amount you wish to bridge. Use the [Synapse Bridge](https://synapseprotocol.com) to see if a route between a given pair exists. - -::: - diff --git a/docs/bridge/docs/02-Bridge/_05-Supported-Routes.md b/docs/bridge/docs/02-Bridge/_05-Supported-Routes.md deleted file mode 100644 index 13bbab1dc7..0000000000 --- a/docs/bridge/docs/02-Bridge/_05-Supported-Routes.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -sidebar_label: Supported Rotues ---- - -import Routes from '@site/src/components/Routes' - -# Supported Routes - -Supported tokens for each chain. - -:::tip Routes - -Route availability is determined by the amount you wish to bridge. Use the [Synapse Bridge](https://synapseprotocol.com) to see if a route between a given pair exists. - -::: - - diff --git a/docs/bridge/docs/02-Bridge/index.md b/docs/bridge/docs/02-Bridge/index.md index 5da83d208a..dfa2ba2519 100644 --- a/docs/bridge/docs/02-Bridge/index.md +++ b/docs/bridge/docs/02-Bridge/index.md @@ -7,7 +7,7 @@ import SVGBridge from '@site/src/components/SVGBridge' # Synapse Bridge -The [Synapse Bridge](https://synapseprotocol.com) and [Solana Bridge](https://solana.synapseprotocol.com/) seamlessly swap on-chain assets between [20+ EVM and non-EVM blockchains](./Supported-Routes) in a safe and secure manner. +The [Synapse Bridge](https://synapseprotocol.com) and [Solana Bridge](https://solana.synapseprotocol.com/) seamlessly swap on-chain assets between [20+ EVM and non-EVM blockchains](/docs/About/Routes) in a safe and secure manner.
diff --git a/docs/bridge/package.json b/docs/bridge/package.json index cf64b44003..6ec028a64d 100644 --- a/docs/bridge/package.json +++ b/docs/bridge/package.json @@ -1,6 +1,6 @@ { "name": "@synapsecns/bridge-docs", - "version": "0.3.7", + "version": "0.3.8", "private": true, "scripts": { "docusaurus": "docusaurus", diff --git a/docs/bridge/src/components/Routes.tsx b/docs/bridge/src/components/Routes.tsx index 02af8a2312..36e035fe3d 100644 --- a/docs/bridge/src/components/Routes.tsx +++ b/docs/bridge/src/components/Routes.tsx @@ -1,17 +1,10 @@ import { BRIDGABLE_TOKENS, CHAINS } from '@synapsecns/synapse-constants' -const CHAINS_BY_ID = {} - -for (const { chainImg, id, name } of Object.values(CHAINS)) { - if (id && name) { - CHAINS_BY_ID[id] = { name, chainImg } - } -} - export default () => Object.entries(BRIDGABLE_TOKENS).map(([id, tokens]) => { - const chain = CHAINS_BY_ID[id] - const chainImg = chain.chainImg({ width: 28, height: 28 }) + const chain = CHAINS.CHAINS_BY_ID[id] + const chainImg = chain.chainImg + return (

alignItems: 'center', }} > - {chainImg} {chain.name} {id} + {chain.name} + {chain.name} {id}

{Object.values(tokens).map((token) => { - const tokenImg = - typeof token.icon === 'string' ? ( - - ) : ( - token.icon({ width: 16, height: 16 }) - ) - return ( padding: '.25rem .5rem', }} > - {tokenImg} {token.symbol} + {token.symbol}{' '} + {token.symbol} ) })} diff --git a/packages/contracts-rfq/CHANGELOG.md b/packages/contracts-rfq/CHANGELOG.md index 16614416b6..66d89dce04 100644 --- a/packages/contracts-rfq/CHANGELOG.md +++ b/packages/contracts-rfq/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [0.7.0](https://github.com/synapsecns/sanguine/compare/@synapsecns/contracts-rfq@0.6.2...@synapsecns/contracts-rfq@0.7.0) (2024-10-07) + + +### Features + +* **contracts-rfq:** arbitrary calls without additional native value [SLT-233] ([#3215](https://github.com/synapsecns/sanguine/issues/3215)) ([6dc151c](https://github.com/synapsecns/sanguine/commit/6dc151c709970e2a891a56d10c8ffc67bdf95522)) + + + + + ## [0.6.2](https://github.com/synapsecns/sanguine/compare/@synapsecns/contracts-rfq@0.6.1...@synapsecns/contracts-rfq@0.6.2) (2024-10-03) diff --git a/packages/contracts-rfq/contracts/FastBridgeV2.sol b/packages/contracts-rfq/contracts/FastBridgeV2.sol index 5a764a7c95..1a64515a33 100644 --- a/packages/contracts-rfq/contracts/FastBridgeV2.sol +++ b/packages/contracts-rfq/contracts/FastBridgeV2.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.24; import {SafeERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; import {UniversalTokenLib} from "./libs/UniversalToken.sol"; @@ -9,6 +10,7 @@ import {Admin} from "./Admin.sol"; import {IFastBridge} from "./interfaces/IFastBridge.sol"; import {IFastBridgeV2} from "./interfaces/IFastBridgeV2.sol"; import {IFastBridgeV2Errors} from "./interfaces/IFastBridgeV2Errors.sol"; +import {IFastBridgeRecipient} from "./interfaces/IFastBridgeRecipient.sol"; /// @notice FastBridgeV2 is a contract for bridging tokens across chains. contract FastBridgeV2 is Admin, IFastBridgeV2, IFastBridgeV2Errors { @@ -24,6 +26,9 @@ contract FastBridgeV2 is Admin, IFastBridgeV2, IFastBridgeV2Errors { /// @notice Minimum deadline period to relay a requested bridge transaction uint256 public constant MIN_DEADLINE_PERIOD = 30 minutes; + /// @notice Maximum length of accepted callParams + uint256 public constant MAX_CALL_PARAMS_LENGTH = 2 ** 16 - 1; + /// @notice Status of the bridge tx on origin chain mapping(bytes32 => BridgeTxDetails) public bridgeTxDetails; /// @notice Relay details on destination chain @@ -45,7 +50,12 @@ contract FastBridgeV2 is Admin, IFastBridgeV2, IFastBridgeV2Errors { function bridge(BridgeParams memory params) external payable { bridge({ params: params, - paramsV2: BridgeParamsV2({quoteRelayer: address(0), quoteExclusivitySeconds: 0, quoteId: bytes("")}) + paramsV2: BridgeParamsV2({ + quoteRelayer: address(0), + quoteExclusivitySeconds: 0, + quoteId: bytes(""), + callParams: bytes("") + }) }); } @@ -117,6 +127,9 @@ contract FastBridgeV2 is Admin, IFastBridgeV2, IFastBridgeV2Errors { /// @inheritdoc IFastBridge function getBridgeTransaction(bytes memory request) external pure returns (BridgeTransaction memory) { + // TODO: the note below isn't true anymore with the BridgeTransactionV2 struct + // since the variable length `callParams` was added. This needs to be fixed/acknowledged. + // Note: when passing V2 request, this will decode the V1 fields correctly since the new fields were // added as the last fields of the struct and hence the ABI decoder will simply ignore the extra data. return abi.decode(request, (BridgeTransaction)); @@ -132,6 +145,7 @@ contract FastBridgeV2 is Admin, IFastBridgeV2, IFastBridgeV2Errors { if (params.sender == address(0) || params.to == address(0)) revert ZeroAddress(); if (params.originToken == address(0) || params.destToken == address(0)) revert ZeroAddress(); if (params.deadline < block.timestamp + MIN_DEADLINE_PERIOD) revert DeadlineTooShort(); + if (paramsV2.callParams.length > MAX_CALL_PARAMS_LENGTH) revert CallParamsLengthAboveMax(); int256 exclusivityEndTime = int256(block.timestamp) + paramsV2.quoteExclusivitySeconds; // exclusivityEndTime must be in range (0 .. params.deadline] if (exclusivityEndTime <= 0 || exclusivityEndTime > int256(params.deadline)) { @@ -163,7 +177,8 @@ contract FastBridgeV2 is Admin, IFastBridgeV2, IFastBridgeV2Errors { nonce: senderNonces[params.sender]++, // increment nonce on every bridge exclusivityRelayer: paramsV2.quoteRelayer, // We checked exclusivityEndTime to be in range (0 .. params.deadline] above, so can safely cast - exclusivityEndTime: uint256(exclusivityEndTime) + exclusivityEndTime: uint256(exclusivityEndTime), + callParams: paramsV2.callParams }) ); bytes32 transactionId = keccak256(request); @@ -214,18 +229,32 @@ contract FastBridgeV2 is Admin, IFastBridgeV2, IFastBridgeV2Errors { address token = transaction.destToken; uint256 amount = transaction.destAmount; - uint256 rebate = chainGasAmount; - if (!transaction.sendChainGas) { - // forward erc20 - rebate = 0; + // All state changes have been done at this point, can proceed to the external calls. + // This follows the checks-effects-interactions pattern to mitigate potential reentrancy attacks. + if (transaction.callParams.length == 0) { + // No arbitrary call requested, so we just pull the tokens from the Relayer to the recipient, + // or transfer ETH to the recipient (if token is ETH_ADDRESS) _pullToken(to, token, amount); - } else if (token == UniversalTokenLib.ETH_ADDRESS) { - // lump in gas rebate into amount in native gas token - _pullToken(to, token, amount + rebate); - } else { - // forward erc20 then forward gas rebate in native gas token + } else if (token != UniversalTokenLib.ETH_ADDRESS) { + // Arbitrary call requested with ERC20: pull the tokens from the Relayer to the recipient first _pullToken(to, token, amount); - _pullToken(to, UniversalTokenLib.ETH_ADDRESS, rebate); + // Follow up with the hook function call + _checkedCallRecipient({ + recipient: to, + msgValue: 0, + token: token, + amount: amount, + callParams: transaction.callParams + }); + } else { + // Arbitrary call requested with ETH: combine the ETH transfer with the call + _checkedCallRecipient({ + recipient: to, + msgValue: amount, + token: token, + amount: amount, + callParams: transaction.callParams + }); } emit BridgeRelayed( @@ -237,7 +266,8 @@ contract FastBridgeV2 is Admin, IFastBridgeV2, IFastBridgeV2Errors { transaction.destToken, transaction.originAmount, transaction.destAmount, - rebate + // chainGasAmount is 0 since the gas rebate function is deprecated + 0 ); } @@ -327,6 +357,31 @@ contract FastBridgeV2 is Admin, IFastBridgeV2, IFastBridgeV2Errors { } } + /// @notice Calls the Recipient's hook function with the specified callParams and performs + /// all the necessary checks for the returned value. + function _checkedCallRecipient( + address recipient, + uint256 msgValue, + address token, + uint256 amount, + bytes memory callParams + ) + internal + { + bytes memory hookData = + abi.encodeCall(IFastBridgeRecipient.fastBridgeTransferReceived, (token, amount, callParams)); + // This will bubble any revert messages from the hook function + bytes memory returnData = Address.functionCallWithValue({target: recipient, data: hookData, value: msgValue}); + // Explicit revert if no return data at all + if (returnData.length == 0) revert RecipientNoReturnValue(); + // Check that exactly a single return value was returned + if (returnData.length != 32) revert RecipientIncorrectReturnValue(); + // Return value should be abi-encoded hook function selector + if (bytes32(returnData) != bytes32(IFastBridgeRecipient.fastBridgeTransferReceived.selector)) { + revert RecipientIncorrectReturnValue(); + } + } + /// @notice Calculates time since proof submitted /// @dev proof.timestamp stores casted uint40(block.timestamp) block timestamps for gas optimization /// _timeSince(proof) can accomodate rollover case when block.timestamp > type(uint40).max but diff --git a/packages/contracts-rfq/contracts/interfaces/IFastBridgeRecipient.sol b/packages/contracts-rfq/contracts/interfaces/IFastBridgeRecipient.sol new file mode 100644 index 0000000000..e76ee865cd --- /dev/null +++ b/packages/contracts-rfq/contracts/interfaces/IFastBridgeRecipient.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface IFastBridgeRecipient { + function fastBridgeTransferReceived( + address token, + uint256 amount, + bytes memory callParams + ) + external + payable + returns (bytes4); +} diff --git a/packages/contracts-rfq/contracts/interfaces/IFastBridgeV2.sol b/packages/contracts-rfq/contracts/interfaces/IFastBridgeV2.sol index 81a67636b5..c4c3751a5b 100644 --- a/packages/contracts-rfq/contracts/interfaces/IFastBridgeV2.sol +++ b/packages/contracts-rfq/contracts/interfaces/IFastBridgeV2.sol @@ -33,10 +33,12 @@ interface IFastBridgeV2 is IFastBridge { /// @param quoteRelayer Relayer that provided the quote for the transaction /// @param quoteExclusivitySeconds Period of time the quote relayer is guaranteed exclusivity after user's deposit /// @param quoteId Unique quote identifier used for tracking the quote + /// @param callParams Parameters for the arbitrary call to the destination recipient (if any) struct BridgeParamsV2 { address quoteRelayer; int256 quoteExclusivitySeconds; bytes quoteId; + bytes callParams; } /// @notice Updated bridge transaction struct to include parameters introduced in FastBridgeV2. @@ -57,6 +59,7 @@ interface IFastBridgeV2 is IFastBridge { uint256 nonce; address exclusivityRelayer; uint256 exclusivityEndTime; + bytes callParams; } event BridgeQuoteDetails(bytes32 indexed transactionId, bytes quoteId); diff --git a/packages/contracts-rfq/contracts/interfaces/IFastBridgeV2Errors.sol b/packages/contracts-rfq/contracts/interfaces/IFastBridgeV2Errors.sol index 85f3bacdcf..7cc1423a84 100644 --- a/packages/contracts-rfq/contracts/interfaces/IFastBridgeV2Errors.sol +++ b/packages/contracts-rfq/contracts/interfaces/IFastBridgeV2Errors.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.0; interface IFastBridgeV2Errors { error AmountIncorrect(); + error CallParamsLengthAboveMax(); error ChainIncorrect(); error ExclusivityParamsIncorrect(); error MsgValueIncorrect(); @@ -10,6 +11,9 @@ interface IFastBridgeV2Errors { error StatusIncorrect(); error ZeroAddress(); + error RecipientIncorrectReturnValue(); + error RecipientNoReturnValue(); + error DeadlineExceeded(); error DeadlineNotExceeded(); error DeadlineTooShort(); diff --git a/packages/contracts-rfq/package.json b/packages/contracts-rfq/package.json index 288df494f0..83164f1f24 100644 --- a/packages/contracts-rfq/package.json +++ b/packages/contracts-rfq/package.json @@ -1,7 +1,7 @@ { "name": "@synapsecns/contracts-rfq", "license": "MIT", - "version": "0.6.2", + "version": "0.7.0", "description": "FastBridge contracts.", "private": true, "files": [ diff --git a/packages/contracts-rfq/test/FastBridgeV2.Dst.ArbitraryCall.t.sol b/packages/contracts-rfq/test/FastBridgeV2.Dst.ArbitraryCall.t.sol new file mode 100644 index 0000000000..af4f6235c6 --- /dev/null +++ b/packages/contracts-rfq/test/FastBridgeV2.Dst.ArbitraryCall.t.sol @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {FastBridgeV2DstExclusivityTest, IFastBridgeV2} from "./FastBridgeV2.Dst.Exclusivity.t.sol"; +import {RecipientMock} from "./mocks/RecipientMock.sol"; + +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; + +// solhint-disable func-name-mixedcase, ordering +contract FastBridgeV2DstArbitraryCallTest is FastBridgeV2DstExclusivityTest { + bytes public constant CALL_PARAMS = abi.encode("Hello, world!"); + bytes public constant REVERT_MSG = "GM, this is a revert"; + + function createFixtures() public virtual override { + // In the inherited tests userB is always used as the recipient of the tokens. + userB = address(new RecipientMock()); + vm.label(userB, "ContractRecipient"); + super.createFixtures(); + } + + function createFixturesV2() public virtual override { + super.createFixturesV2(); + setTokenTestCallParams(CALL_PARAMS); + setEthTestCallParams(CALL_PARAMS); + } + + /// @notice We override the "expect event" function to also check for the arbitrary call + /// made to the token recipient. + function expectBridgeRelayed( + IFastBridgeV2.BridgeTransactionV2 memory bridgeTx, + bytes32 txId, + address relayer + ) + public + virtual + override + { + vm.expectCall({callee: userB, data: getExpectedCalldata(bridgeTx), count: 1}); + super.expectBridgeRelayed(bridgeTx, txId, relayer); + } + + function mockRecipientRevert(IFastBridgeV2.BridgeTransactionV2 memory bridgeTx) public { + vm.mockCallRevert({callee: userB, data: getExpectedCalldata(bridgeTx), revertData: bytes(REVERT_MSG)}); + } + + function getExpectedCalldata(IFastBridgeV2.BridgeTransactionV2 memory bridgeTx) + public + pure + returns (bytes memory) + { + // fastBridgeTransferReceived(token, amount, callParams) + return abi.encodeCall( + RecipientMock.fastBridgeTransferReceived, (bridgeTx.destToken, bridgeTx.destAmount, CALL_PARAMS) + ); + } + + // ═══════════════════════════════════════════════ RECIPIENT EOA ═══════════════════════════════════════════════════ + + function test_relay_token_revert_recipientNotContract() public { + setTokenTestRecipient(userA); + vm.expectRevert(abi.encodeWithSelector(Address.AddressEmptyCode.selector, userA)); + relay({caller: relayerA, msgValue: 0, bridgeTx: tokenTx}); + } + + function test_relay_token_withRelayerAddress_revert_recipientNotContract() public { + setTokenTestRecipient(userA); + vm.expectRevert(abi.encodeWithSelector(Address.AddressEmptyCode.selector, userA)); + relayWithAddress({caller: relayerB, relayer: relayerA, msgValue: 0, bridgeTx: tokenTx}); + } + + function test_relay_eth_revert_recipientNotContract() public { + setEthTestRecipient(userA); + vm.expectRevert(abi.encodeWithSelector(Address.AddressEmptyCode.selector, userA)); + relay({caller: relayerB, msgValue: ethParams.destAmount, bridgeTx: ethTx}); + } + + function test_relay_eth_withRelayerAddress_revert_recipientNotContract() public { + setEthTestRecipient(userA); + vm.expectRevert(abi.encodeWithSelector(Address.AddressEmptyCode.selector, userA)); + relayWithAddress({caller: relayerA, relayer: relayerB, msgValue: ethParams.destAmount, bridgeTx: ethTx}); + } + + // ═════════════════════════════════════ EXCESSIVE RETURN VALUE RECIPIENT ══════════════════════════════════════════ + + function test_relay_token_excessiveReturnValueRecipient_revertWhenCallParamsPresent() public virtual override { + setTokenTestRecipient(excessiveReturnValueRecipient); + vm.expectRevert(RecipientIncorrectReturnValue.selector); + relay({caller: relayerA, msgValue: 0, bridgeTx: tokenTx}); + } + + function test_relay_token_withRelayerAddress_excessiveReturnValueRecipient_revertWhenCallParamsPresent() + public + virtual + override + { + setTokenTestRecipient(excessiveReturnValueRecipient); + vm.expectRevert(RecipientIncorrectReturnValue.selector); + relayWithAddress({caller: relayerB, relayer: relayerA, msgValue: 0, bridgeTx: tokenTx}); + } + + function test_relay_eth_excessiveReturnValueRecipient_revertWhenCallParamsPresent() public virtual override { + setEthTestRecipient(excessiveReturnValueRecipient); + vm.expectRevert(RecipientIncorrectReturnValue.selector); + relay({caller: relayerB, msgValue: ethParams.destAmount, bridgeTx: ethTx}); + } + + function test_relay_eth_withRelayerAddress_excessiveReturnValueRecipient_revertWhenCallParamsPresent() + public + virtual + override + { + setEthTestRecipient(excessiveReturnValueRecipient); + vm.expectRevert(RecipientIncorrectReturnValue.selector); + relayWithAddress({caller: relayerA, relayer: relayerB, msgValue: ethParams.destAmount, bridgeTx: ethTx}); + } + + // ═════════════════════════════════════ INCORRECT RETURN VALUE RECIPIENT ══════════════════════════════════════════ + + function test_relay_token_incorrectReturnValueRecipient_revertWhenCallParamsPresent() public virtual override { + setTokenTestRecipient(incorrectReturnValueRecipient); + vm.expectRevert(RecipientIncorrectReturnValue.selector); + relay({caller: relayerA, msgValue: 0, bridgeTx: tokenTx}); + } + + function test_relay_token_withRelayerAddress_incorrectReturnValueRecipient_revertWhenCallParamsPresent() + public + virtual + override + { + setTokenTestRecipient(incorrectReturnValueRecipient); + vm.expectRevert(RecipientIncorrectReturnValue.selector); + relayWithAddress({caller: relayerB, relayer: relayerA, msgValue: 0, bridgeTx: tokenTx}); + } + + function test_relay_eth_incorrectReturnValueRecipient_revertWhenCallParamsPresent() public virtual override { + setEthTestRecipient(incorrectReturnValueRecipient); + vm.expectRevert(RecipientIncorrectReturnValue.selector); + relay({caller: relayerB, msgValue: ethParams.destAmount, bridgeTx: ethTx}); + } + + function test_relay_eth_withRelayerAddress_incorrectReturnValueRecipient_revertWhenCallParamsPresent() + public + virtual + override + { + setEthTestRecipient(incorrectReturnValueRecipient); + vm.expectRevert(RecipientIncorrectReturnValue.selector); + relayWithAddress({caller: relayerA, relayer: relayerB, msgValue: ethParams.destAmount, bridgeTx: ethTx}); + } + + // ══════════════════════════════════════════════ NO-OP RECIPIENT ══════════════════════════════════════════════════ + + function test_relay_token_noOpRecipient_revertWhenCallParamsPresent() public virtual override { + setTokenTestRecipient(noOpRecipient); + vm.expectRevert(Address.FailedInnerCall.selector); + relay({caller: relayerA, msgValue: 0, bridgeTx: tokenTx}); + } + + function test_relay_token_withRelayerAddress_noOpRecipient_revertWhenCallParamsPresent() public virtual override { + setTokenTestRecipient(noOpRecipient); + vm.expectRevert(Address.FailedInnerCall.selector); + relayWithAddress({caller: relayerB, relayer: relayerA, msgValue: 0, bridgeTx: tokenTx}); + } + + function test_relay_eth_noOpRecipient_revertWhenCallParamsPresent() public virtual override { + setEthTestRecipient(noOpRecipient); + vm.expectRevert(Address.FailedInnerCall.selector); + relay({caller: relayerB, msgValue: ethParams.destAmount, bridgeTx: ethTx}); + } + + function test_relay_eth_withRelayerAddress_noOpRecipient_revertWhenCallParamsPresent() public virtual override { + setEthTestRecipient(noOpRecipient); + vm.expectRevert(Address.FailedInnerCall.selector); + relayWithAddress({caller: relayerA, relayer: relayerB, msgValue: ethParams.destAmount, bridgeTx: ethTx}); + } + + // ═════════════════════════════════════════ NO RETURN VALUE RECIPIENT ═════════════════════════════════════════════ + + function test_relay_token_noReturnValueRecipient_revertWhenCallParamsPresent() public virtual override { + setTokenTestRecipient(noReturnValueRecipient); + vm.expectRevert(RecipientNoReturnValue.selector); + relay({caller: relayerA, msgValue: 0, bridgeTx: tokenTx}); + } + + function test_relay_token_withRelayerAddress_noReturnValueRecipient_revertWhenCallParamsPresent() + public + virtual + override + { + setTokenTestRecipient(noReturnValueRecipient); + vm.expectRevert(RecipientNoReturnValue.selector); + relayWithAddress({caller: relayerB, relayer: relayerA, msgValue: 0, bridgeTx: tokenTx}); + } + + function test_relay_eth_noReturnValueRecipient_revertWhenCallParamsPresent() public virtual override { + setEthTestRecipient(noReturnValueRecipient); + vm.expectRevert(RecipientNoReturnValue.selector); + relay({caller: relayerB, msgValue: ethParams.destAmount, bridgeTx: ethTx}); + } + + function test_relay_eth_withRelayerAddress_noReturnValueRecipient_revertWhenCallParamsPresent() + public + virtual + override + { + setEthTestRecipient(noReturnValueRecipient); + vm.expectRevert(RecipientNoReturnValue.selector); + relayWithAddress({caller: relayerA, relayer: relayerB, msgValue: ethParams.destAmount, bridgeTx: ethTx}); + } + + // ═════════════════════════════════════════════ RECIPIENT REVERTS ═════════════════════════════════════════════════ + + function test_relay_token_revert_recipientReverts() public { + mockRecipientRevert(tokenTx); + vm.expectRevert(REVERT_MSG); + relay({caller: relayerA, msgValue: 0, bridgeTx: tokenTx}); + } + + function test_relay_token_withRelayerAddress_revert_recipientReverts() public { + mockRecipientRevert(tokenTx); + vm.expectRevert(REVERT_MSG); + relayWithAddress({caller: relayerB, relayer: relayerA, msgValue: 0, bridgeTx: tokenTx}); + } + + function test_relay_eth_revert_recipientReverts() public { + mockRecipientRevert(ethTx); + vm.expectRevert(REVERT_MSG); + relay({caller: relayerB, msgValue: ethParams.destAmount, bridgeTx: ethTx}); + } + + function test_relay_eth_withRelayerAddress_revert_recipientReverts() public { + mockRecipientRevert(ethTx); + vm.expectRevert(REVERT_MSG); + relayWithAddress({caller: relayerA, relayer: relayerB, msgValue: ethParams.destAmount, bridgeTx: ethTx}); + } + + function test_relay_eth_noCallParams_revert_recipientReverts() public { + setEthTestCallParams(""); + vm.mockCallRevert({callee: userB, data: "", revertData: bytes(REVERT_MSG)}); + vm.expectRevert("ETH transfer failed"); + relay({caller: relayerB, msgValue: ethParams.destAmount, bridgeTx: ethTx}); + } + + function test_relay_eth_withRelayerAddress_noCallParams_revert_recipientReverts() public { + setEthTestCallParams(""); + vm.mockCallRevert({callee: userB, data: "", revertData: bytes(REVERT_MSG)}); + vm.expectRevert("ETH transfer failed"); + relayWithAddress({caller: relayerA, relayer: relayerB, msgValue: ethParams.destAmount, bridgeTx: ethTx}); + } +} diff --git a/packages/contracts-rfq/test/FastBridgeV2.Dst.Exclusivity.t.sol b/packages/contracts-rfq/test/FastBridgeV2.Dst.Exclusivity.t.sol index 9d0a414306..a616872f93 100644 --- a/packages/contracts-rfq/test/FastBridgeV2.Dst.Exclusivity.t.sol +++ b/packages/contracts-rfq/test/FastBridgeV2.Dst.Exclusivity.t.sol @@ -11,12 +11,14 @@ contract FastBridgeV2DstExclusivityTest is FastBridgeV2DstTest { tokenParamsV2 = IFastBridgeV2.BridgeParamsV2({ quoteRelayer: relayerA, quoteExclusivitySeconds: int256(EXCLUSIVITY_PERIOD), - quoteId: "" + quoteId: "", + callParams: "" }); ethParamsV2 = IFastBridgeV2.BridgeParamsV2({ quoteRelayer: relayerB, quoteExclusivitySeconds: int256(EXCLUSIVITY_PERIOD), - quoteId: "" + quoteId: "", + callParams: "" }); tokenTx.exclusivityRelayer = relayerA; diff --git a/packages/contracts-rfq/test/FastBridgeV2.Dst.t.sol b/packages/contracts-rfq/test/FastBridgeV2.Dst.t.sol index e441a9f3c4..89ef62af91 100644 --- a/packages/contracts-rfq/test/FastBridgeV2.Dst.t.sol +++ b/packages/contracts-rfq/test/FastBridgeV2.Dst.t.sol @@ -3,6 +3,12 @@ pragma solidity ^0.8.20; import {FastBridgeV2DstBaseTest, IFastBridgeV2} from "./FastBridgeV2.Dst.Base.t.sol"; +import {ExcessiveReturnValueRecipient} from "./mocks/ExcessiveReturnValueRecipient.sol"; +import {IncorrectReturnValueRecipient} from "./mocks/IncorrectReturnValueRecipient.sol"; +import {NoOpContract} from "./mocks/NoOpContract.sol"; +import {NoReturnValueRecipient} from "./mocks/NoReturnValueRecipient.sol"; +import {NonPayableRecipient} from "./mocks/NonPayableRecipient.sol"; + // solhint-disable func-name-mixedcase, ordering contract FastBridgeV2DstTest is FastBridgeV2DstBaseTest { event BridgeRelayed( @@ -17,12 +23,49 @@ contract FastBridgeV2DstTest is FastBridgeV2DstBaseTest { uint256 chainGasAmount ); + address public excessiveReturnValueRecipient; + address public incorrectReturnValueRecipient; + address public noOpRecipient; + address public noReturnValueRecipient; + address public nonPayableRecipient; + + function setUp() public virtual override { + super.setUp(); + excessiveReturnValueRecipient = address(new ExcessiveReturnValueRecipient()); + vm.label(excessiveReturnValueRecipient, "ExcessiveReturnValueRecipient"); + incorrectReturnValueRecipient = address(new IncorrectReturnValueRecipient()); + vm.label(incorrectReturnValueRecipient, "IncorrectReturnValueRecipient"); + noOpRecipient = address(new NoOpContract()); + vm.label(noOpRecipient, "NoOpRecipient"); + noReturnValueRecipient = address(new NoReturnValueRecipient()); + vm.label(noReturnValueRecipient, "NoReturnValueRecipient"); + nonPayableRecipient = address(new NonPayableRecipient()); + vm.label(nonPayableRecipient, "NonPayableRecipient"); + } + + function setTokenTestRecipient(address recipient) public { + userB = recipient; + tokenParams.to = recipient; + tokenTx.destRecipient = recipient; + } + + function setEthTestRecipient(address recipient) public { + userB = recipient; + ethParams.to = recipient; + ethTx.destRecipient = recipient; + } + + function assertEmptyCallParams(bytes memory callParams) public pure { + assertEq(callParams.length, 0, "Invalid setup: callParams are not empty"); + } + function expectBridgeRelayed( IFastBridgeV2.BridgeTransactionV2 memory bridgeTx, bytes32 txId, address relayer ) public + virtual { vm.expectEmit(address(fastBridge)); emit BridgeRelayed({ @@ -107,6 +150,160 @@ contract FastBridgeV2DstTest is FastBridgeV2DstBaseTest { assertEq(relayerA.balance, LEFTOVER_BALANCE); assertEq(address(fastBridge).balance, 0); } + + // ═════════════════════════════════════ EXCESSIVE RETURN VALUE RECIPIENT ══════════════════════════════════════════ + + // Note: in this test, the callParams are not present, and the below test functions succeed. + // Override them in the derived tests where callParams are present to check for a revert. + + function test_relay_token_excessiveReturnValueRecipient_revertWhenCallParamsPresent() public virtual { + assertEmptyCallParams(tokenTx.callParams); + setTokenTestRecipient(excessiveReturnValueRecipient); + test_relay_token(); + } + + function test_relay_token_withRelayerAddress_excessiveReturnValueRecipient_revertWhenCallParamsPresent() + public + virtual + { + assertEmptyCallParams(tokenTx.callParams); + setTokenTestRecipient(excessiveReturnValueRecipient); + test_relay_token_withRelayerAddress(); + } + + function test_relay_eth_excessiveReturnValueRecipient_revertWhenCallParamsPresent() public virtual { + assertEmptyCallParams(ethTx.callParams); + setEthTestRecipient(excessiveReturnValueRecipient); + test_relay_eth(); + } + + function test_relay_eth_withRelayerAddress_excessiveReturnValueRecipient_revertWhenCallParamsPresent() + public + virtual + { + assertEmptyCallParams(ethTx.callParams); + setEthTestRecipient(excessiveReturnValueRecipient); + test_relay_eth_withRelayerAddress(); + } + + // ═════════════════════════════════════ INCORRECT RETURN VALUE RECIPIENT ══════════════════════════════════════════ + + // Note: in this test, the callParams are not present, and the below test functions succeed. + // Override them in the derived tests where callParams are present to check for a revert. + + function test_relay_token_incorrectReturnValueRecipient_revertWhenCallParamsPresent() public virtual { + assertEmptyCallParams(tokenTx.callParams); + setTokenTestRecipient(incorrectReturnValueRecipient); + test_relay_token(); + } + + function test_relay_token_withRelayerAddress_incorrectReturnValueRecipient_revertWhenCallParamsPresent() + public + virtual + { + assertEmptyCallParams(tokenTx.callParams); + setTokenTestRecipient(incorrectReturnValueRecipient); + test_relay_token_withRelayerAddress(); + } + + function test_relay_eth_incorrectReturnValueRecipient_revertWhenCallParamsPresent() public virtual { + assertEmptyCallParams(ethTx.callParams); + setEthTestRecipient(incorrectReturnValueRecipient); + test_relay_eth(); + } + + function test_relay_eth_withRelayerAddress_incorrectReturnValueRecipient_revertWhenCallParamsPresent() + public + virtual + { + assertEmptyCallParams(ethTx.callParams); + setEthTestRecipient(incorrectReturnValueRecipient); + test_relay_eth_withRelayerAddress(); + } + + // ═══════════════════════════════════════════ NON PAYABLE RECIPIENT ═══════════════════════════════════════════════ + + /// @notice Should not affect the ERC20 transfer + function test_relay_token_nonPayableRecipient() public { + setTokenTestRecipient(nonPayableRecipient); + test_relay_token(); + } + + function test_relay_token_withRelayerAddress_nonPayableRecipient() public { + setTokenTestRecipient(nonPayableRecipient); + test_relay_token_withRelayerAddress(); + } + + function test_relay_eth_revert_nonPayableRecipient() public { + setEthTestRecipient(nonPayableRecipient); + vm.expectRevert(); + relay({caller: relayerB, msgValue: ethParams.destAmount, bridgeTx: ethTx}); + } + + function test_relay_eth_withRelayerAddress_revert_nonPayableRecipient() public { + setEthTestRecipient(nonPayableRecipient); + vm.expectRevert(); + relayWithAddress({caller: relayerA, relayer: relayerB, msgValue: ethParams.destAmount, bridgeTx: ethTx}); + } + + // ══════════════════════════════════════════════ NO-OP RECIPIENT ══════════════════════════════════════════════════ + + // Note: in this test, the callParams are not present, and the below test functions succeed. + // Override them in the derived tests where callParams are present to check for a revert. + + function test_relay_token_noOpRecipient_revertWhenCallParamsPresent() public virtual { + assertEmptyCallParams(tokenTx.callParams); + setTokenTestRecipient(noOpRecipient); + test_relay_token(); + } + + function test_relay_token_withRelayerAddress_noOpRecipient_revertWhenCallParamsPresent() public virtual { + assertEmptyCallParams(tokenTx.callParams); + setTokenTestRecipient(noOpRecipient); + test_relay_token_withRelayerAddress(); + } + + function test_relay_eth_noOpRecipient_revertWhenCallParamsPresent() public virtual { + assertEmptyCallParams(ethTx.callParams); + setEthTestRecipient(noOpRecipient); + test_relay_eth(); + } + + function test_relay_eth_withRelayerAddress_noOpRecipient_revertWhenCallParamsPresent() public virtual { + assertEmptyCallParams(ethTx.callParams); + setEthTestRecipient(noOpRecipient); + test_relay_eth_withRelayerAddress(); + } + + // ═════════════════════════════════════════ NO RETURN VALUE RECIPIENT ═════════════════════════════════════════════ + + // Note: in this test, the callParams are not present, and the below test functions succeed. + // Override them in the derived tests where callParams are present to check for a revert. + + function test_relay_token_noReturnValueRecipient_revertWhenCallParamsPresent() public virtual { + assertEmptyCallParams(tokenTx.callParams); + setTokenTestRecipient(noReturnValueRecipient); + test_relay_token(); + } + + function test_relay_token_withRelayerAddress_noReturnValueRecipient_revertWhenCallParamsPresent() public virtual { + assertEmptyCallParams(tokenTx.callParams); + setTokenTestRecipient(noReturnValueRecipient); + test_relay_token_withRelayerAddress(); + } + + function test_relay_eth_noReturnValueRecipient_revertWhenCallParamsPresent() public virtual { + assertEmptyCallParams(ethTx.callParams); + setEthTestRecipient(noReturnValueRecipient); + test_relay_eth(); + } + + function test_relay_eth_withRelayerAddress_noReturnValueRecipient_revertWhenCallParamsPresent() public virtual { + assertEmptyCallParams(ethTx.callParams); + setEthTestRecipient(noReturnValueRecipient); + test_relay_eth_withRelayerAddress(); + } + // ══════════════════════════════════════════════════ REVERTS ══════════════════════════════════════════════════════ function test_relay_revert_usedRequestV1() public { diff --git a/packages/contracts-rfq/test/FastBridgeV2.Encoding.t.sol b/packages/contracts-rfq/test/FastBridgeV2.Encoding.t.sol index 58123fad28..0918c66bf5 100644 --- a/packages/contracts-rfq/test/FastBridgeV2.Encoding.t.sol +++ b/packages/contracts-rfq/test/FastBridgeV2.Encoding.t.sol @@ -47,7 +47,14 @@ contract FastBridgeV2EncodingTest is FastBridgeV2Test { assertEq(decodedTx, bridgeTx); } - function test_getBridgeTransaction_supportsV2(IFastBridgeV2.BridgeTransactionV2 memory bridgeTxV2) public view { + // The addition of variable length field (callParams) in BridgeTransactionV2 breaks the compatibility + // with the original BridgeTransaction struct. + // Solidity's abi.encode(bridgeTxV2) will use the first 32 bytes to encode the data offset for the whole struct, + // which is ALWAYS equal to 32 (data starts right after the offset). This is weird, but it is what it is. + // https://ethereum.stackexchange.com/questions/152971/abi-encode-decode-mystery-additional-32-byte-field-uniswap-v2 + function test_getBridgeTransaction_supportsV2(IFastBridgeV2.BridgeTransactionV2 memory bridgeTxV2) public { + // TODO: reevaluate the necessity of this test if/when the encoding scheme is changed + vm.skip(true); bytes memory request = abi.encode(bridgeTxV2); IFastBridge.BridgeTransaction memory decodedTx = fastBridge.getBridgeTransaction(request); assertEq(decodedTx, extractV1(bridgeTxV2)); diff --git a/packages/contracts-rfq/test/FastBridgeV2.GasBench.Dst.Excl.t.sol b/packages/contracts-rfq/test/FastBridgeV2.GasBench.Dst.Excl.t.sol index dc6b5953f5..eae0dffaa1 100644 --- a/packages/contracts-rfq/test/FastBridgeV2.GasBench.Dst.Excl.t.sol +++ b/packages/contracts-rfq/test/FastBridgeV2.GasBench.Dst.Excl.t.sol @@ -13,13 +13,7 @@ contract FastBridgeV2DstExclusivityTest is FastBridgeV2DstGasBenchmarkTest { } function createFixturesV2() public virtual override { - tokenParamsV2.quoteRelayer = relayerA; - tokenParamsV2.quoteExclusivitySeconds = int256(EXCLUSIVITY_PERIOD); - ethParamsV2.quoteRelayer = relayerA; - ethParamsV2.quoteExclusivitySeconds = int256(EXCLUSIVITY_PERIOD); - tokenTx.exclusivityRelayer = relayerA; - tokenTx.exclusivityEndTime = block.timestamp + EXCLUSIVITY_PERIOD; - ethTx.exclusivityRelayer = relayerA; - ethTx.exclusivityEndTime = block.timestamp + EXCLUSIVITY_PERIOD; + setTokenTestExclusivityParams(relayerA, EXCLUSIVITY_PERIOD); + setEthTestExclusivityParams(relayerA, EXCLUSIVITY_PERIOD); } } diff --git a/packages/contracts-rfq/test/FastBridgeV2.GasBench.Src.t.sol b/packages/contracts-rfq/test/FastBridgeV2.GasBench.Src.t.sol index 0d6314baac..0b6834d6ff 100644 --- a/packages/contracts-rfq/test/FastBridgeV2.GasBench.Src.t.sol +++ b/packages/contracts-rfq/test/FastBridgeV2.GasBench.Src.t.sol @@ -104,11 +104,7 @@ contract FastBridgeV2GasBenchmarkSrcTest is FastBridgeV2SrcBaseTest { } function test_bridge_token_withExclusivity() public { - tokenParamsV2.quoteRelayer = relayerA; - tokenParamsV2.quoteExclusivitySeconds = int256(EXCLUSIVITY_PERIOD); - tokenParamsV2.quoteId = bytes("Created by Relayer A"); - tokenTx.exclusivityRelayer = relayerA; - tokenTx.exclusivityEndTime = block.timestamp + EXCLUSIVITY_PERIOD; + setTokenTestExclusivityParams(relayerA, EXCLUSIVITY_PERIOD); bridge({caller: userA, msgValue: 0, params: tokenParams, paramsV2: tokenParamsV2}); assertEq(fastBridge.bridgeStatuses(getTxId(tokenTx)), IFastBridgeV2.BridgeStatus.REQUESTED); assertEq(srcToken.balanceOf(userA), initialUserBalanceToken - tokenParams.originAmount); @@ -189,11 +185,7 @@ contract FastBridgeV2GasBenchmarkSrcTest is FastBridgeV2SrcBaseTest { } function test_bridge_eth_withExclusivity() public { - ethParamsV2.quoteRelayer = relayerA; - ethParamsV2.quoteExclusivitySeconds = int256(EXCLUSIVITY_PERIOD); - ethParamsV2.quoteId = bytes("Created by Relayer A"); - ethTx.exclusivityRelayer = relayerA; - ethTx.exclusivityEndTime = block.timestamp + EXCLUSIVITY_PERIOD; + setEthTestExclusivityParams(relayerA, EXCLUSIVITY_PERIOD); bridge({caller: userA, msgValue: ethParams.originAmount, params: ethParams, paramsV2: ethParamsV2}); assertEq(fastBridge.bridgeStatuses(getTxId(ethTx)), IFastBridgeV2.BridgeStatus.REQUESTED); assertEq(userA.balance, initialUserBalanceEth - ethParams.originAmount); diff --git a/packages/contracts-rfq/test/FastBridgeV2.Src.ArbitraryCall.t.sol b/packages/contracts-rfq/test/FastBridgeV2.Src.ArbitraryCall.t.sol new file mode 100644 index 0000000000..2b1e83a30c --- /dev/null +++ b/packages/contracts-rfq/test/FastBridgeV2.Src.ArbitraryCall.t.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {FastBridgeV2SrcExclusivityTest} from "./FastBridgeV2.Src.Exclusivity.t.sol"; + +// solhint-disable func-name-mixedcase, ordering +contract FastBridgeV2SrcArbitraryCallTest is FastBridgeV2SrcExclusivityTest { + bytes public constant CALL_PARAMS = abi.encode("Hello, World!"); + + function createFixturesV2() public virtual override { + super.createFixturesV2(); + setTokenTestCallParams(CALL_PARAMS); + setEthTestCallParams(CALL_PARAMS); + } + + // Contract should accept callParams with length up to 2^16 - 1, + // so that the callParams.length is encoded in 2 bytes. + + function test_bridge_token_callParamsLengthMax() public { + bytes memory callParams = new bytes(2 ** 16 - 1); + setTokenTestCallParams(callParams); + test_bridge_token(); + } + + function test_bridge_eth_callParamsLengthMax() public { + bytes memory callParams = new bytes(2 ** 16 - 1); + setEthTestCallParams(callParams); + test_bridge_eth(); + } + + function test_bridge_token_revert_callParamsLengthAboveMax() public { + bytes memory callParams = new bytes(2 ** 16); + setTokenTestCallParams(callParams); + vm.expectRevert(CallParamsLengthAboveMax.selector); + bridge({caller: userA, msgValue: 0, params: tokenParams, paramsV2: tokenParamsV2}); + } + + function test_bridge_eth_revert_callParamsLengthAboveMax() public { + bytes memory callParams = new bytes(2 ** 16); + setEthTestCallParams(callParams); + vm.expectRevert(CallParamsLengthAboveMax.selector); + bridge({caller: userA, msgValue: ethParams.originAmount, params: ethParams, paramsV2: ethParamsV2}); + } +} diff --git a/packages/contracts-rfq/test/FastBridgeV2.Src.Exclusivity.Negative.t.sol b/packages/contracts-rfq/test/FastBridgeV2.Src.Exclusivity.Negative.t.sol index 0b19f097f3..4eeacb2f88 100644 --- a/packages/contracts-rfq/test/FastBridgeV2.Src.Exclusivity.Negative.t.sol +++ b/packages/contracts-rfq/test/FastBridgeV2.Src.Exclusivity.Negative.t.sol @@ -8,16 +8,13 @@ contract FastBridgeV2SrcExclusivityNegativeTest is FastBridgeV2SrcTest { uint256 public constant EXCLUSIVITY_PERIOD_ABS = 60 seconds; function createFixturesV2() public virtual override { - tokenParamsV2.quoteRelayer = relayerA; + // Populate the fields using the absolute exclusivity period + setTokenTestExclusivityParams(relayerA, EXCLUSIVITY_PERIOD_ABS); + setEthTestExclusivityParams(relayerB, EXCLUSIVITY_PERIOD_ABS); + // Override with negative exclusivity period tokenParamsV2.quoteExclusivitySeconds = -int256(EXCLUSIVITY_PERIOD_ABS); - tokenParamsV2.quoteId = bytes("Created by Relayer A"); - ethParamsV2.quoteRelayer = relayerB; ethParamsV2.quoteExclusivitySeconds = -int256(EXCLUSIVITY_PERIOD_ABS); - ethParamsV2.quoteId = bytes("Created by Relayer B"); - - tokenTx.exclusivityRelayer = relayerA; tokenTx.exclusivityEndTime = block.timestamp - EXCLUSIVITY_PERIOD_ABS; - ethTx.exclusivityRelayer = relayerB; ethTx.exclusivityEndTime = block.timestamp - EXCLUSIVITY_PERIOD_ABS; } diff --git a/packages/contracts-rfq/test/FastBridgeV2.Src.Exclusivity.t.sol b/packages/contracts-rfq/test/FastBridgeV2.Src.Exclusivity.t.sol index 259ef0bacd..230c24778c 100644 --- a/packages/contracts-rfq/test/FastBridgeV2.Src.Exclusivity.t.sol +++ b/packages/contracts-rfq/test/FastBridgeV2.Src.Exclusivity.t.sol @@ -8,17 +8,8 @@ contract FastBridgeV2SrcExclusivityTest is FastBridgeV2SrcTest { uint256 public constant EXCLUSIVITY_PERIOD = 60 seconds; function createFixturesV2() public virtual override { - tokenParamsV2.quoteRelayer = relayerA; - tokenParamsV2.quoteExclusivitySeconds = int256(EXCLUSIVITY_PERIOD); - tokenParamsV2.quoteId = bytes("Created by Relayer A"); - ethParamsV2.quoteRelayer = relayerB; - ethParamsV2.quoteExclusivitySeconds = int256(EXCLUSIVITY_PERIOD); - ethParamsV2.quoteId = bytes("Created by Relayer B"); - - tokenTx.exclusivityRelayer = relayerA; - tokenTx.exclusivityEndTime = block.timestamp + EXCLUSIVITY_PERIOD; - ethTx.exclusivityRelayer = relayerB; - ethTx.exclusivityEndTime = block.timestamp + EXCLUSIVITY_PERIOD; + setTokenTestExclusivityParams(relayerA, EXCLUSIVITY_PERIOD); + setEthTestExclusivityParams(relayerB, EXCLUSIVITY_PERIOD); } function bridge(address caller, uint256 msgValue, IFastBridge.BridgeParams memory params) public virtual override { diff --git a/packages/contracts-rfq/test/FastBridgeV2.t.sol b/packages/contracts-rfq/test/FastBridgeV2.t.sol index 3828076dac..8011f8a8e2 100644 --- a/packages/contracts-rfq/test/FastBridgeV2.t.sol +++ b/packages/contracts-rfq/test/FastBridgeV2.t.sol @@ -124,9 +124,18 @@ abstract contract FastBridgeV2Test is Test, IFastBridgeV2Errors { function createFixturesV2() public virtual { // Override in tests with exclusivity params - tokenParamsV2 = - IFastBridgeV2.BridgeParamsV2({quoteRelayer: address(0), quoteExclusivitySeconds: 0, quoteId: ""}); - ethParamsV2 = IFastBridgeV2.BridgeParamsV2({quoteRelayer: address(0), quoteExclusivitySeconds: 0, quoteId: ""}); + tokenParamsV2 = IFastBridgeV2.BridgeParamsV2({ + quoteRelayer: address(0), + quoteExclusivitySeconds: 0, + quoteId: bytes(""), + callParams: bytes("") + }); + ethParamsV2 = IFastBridgeV2.BridgeParamsV2({ + quoteRelayer: address(0), + quoteExclusivitySeconds: 0, + quoteId: bytes(""), + callParams: bytes("") + }); tokenTx.exclusivityRelayer = address(0); tokenTx.exclusivityEndTime = block.timestamp; @@ -154,6 +163,34 @@ abstract contract FastBridgeV2Test is Test, IFastBridgeV2Errors { txV2.nonce = txV1.nonce; } + function setTokenTestCallParams(bytes memory callParams) public { + tokenParamsV2.callParams = callParams; + tokenTx.callParams = callParams; + } + + function setTokenTestExclusivityParams(address relayer, uint256 exclusivitySeconds) public { + tokenParamsV2.quoteRelayer = relayer; + tokenParamsV2.quoteExclusivitySeconds = int256(exclusivitySeconds); + tokenParamsV2.quoteId = bytes.concat("Token:", getMockQuoteId(relayer)); + + tokenTx.exclusivityRelayer = relayer; + tokenTx.exclusivityEndTime = block.timestamp + exclusivitySeconds; + } + + function setEthTestCallParams(bytes memory callParams) public { + ethParamsV2.callParams = callParams; + ethTx.callParams = callParams; + } + + function setEthTestExclusivityParams(address relayer, uint256 exclusivitySeconds) public { + ethParamsV2.quoteRelayer = relayer; + ethParamsV2.quoteExclusivitySeconds = int256(exclusivitySeconds); + ethParamsV2.quoteId = bytes.concat("ETH:", getMockQuoteId(relayer)); + + ethTx.exclusivityRelayer = relayer; + ethTx.exclusivityEndTime = block.timestamp + exclusivitySeconds; + } + function extractV1(IFastBridgeV2.BridgeTransactionV2 memory txV2) public pure @@ -173,6 +210,16 @@ abstract contract FastBridgeV2Test is Test, IFastBridgeV2Errors { txV1.nonce = txV2.nonce; } + function getMockQuoteId(address relayer) public view returns (bytes memory) { + if (relayer == relayerA) { + return bytes("created by Relayer A"); + } else if (relayer == relayerB) { + return bytes("created by Relayer B"); + } else { + return bytes("created by unknown relayer"); + } + } + function getTxId(IFastBridgeV2.BridgeTransactionV2 memory bridgeTx) public pure returns (bytes32) { return keccak256(abi.encode(bridgeTx)); } diff --git a/packages/contracts-rfq/test/mocks/ExcessiveReturnValueRecipient.sol b/packages/contracts-rfq/test/mocks/ExcessiveReturnValueRecipient.sol new file mode 100644 index 0000000000..9c3be02502 --- /dev/null +++ b/packages/contracts-rfq/test/mocks/ExcessiveReturnValueRecipient.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IFastBridgeRecipient} from "../../contracts/interfaces/IFastBridgeRecipient.sol"; + +/// @notice Incorrectly implemented recipient mock for testing purposes. DO NOT USE IN PRODUCTION. +contract ExcessiveReturnValueRecipient { + /// @notice Mock needs to accept ETH + receive() external payable {} + + /// @notice Incorrectly implemented - method returns excessive bytes. + function fastBridgeTransferReceived(address, uint256, bytes memory) external payable returns (bytes4, uint256) { + return (IFastBridgeRecipient.fastBridgeTransferReceived.selector, 1337); + } +} diff --git a/packages/contracts-rfq/test/mocks/IncorrectReturnValueRecipient.sol b/packages/contracts-rfq/test/mocks/IncorrectReturnValueRecipient.sol new file mode 100644 index 0000000000..2bf955da7f --- /dev/null +++ b/packages/contracts-rfq/test/mocks/IncorrectReturnValueRecipient.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IFastBridgeRecipient} from "../../contracts/interfaces/IFastBridgeRecipient.sol"; + +/// @notice Incorrectly implemented recipient mock for testing purposes. DO NOT USE IN PRODUCTION. +contract IncorrectReturnValueRecipient is IFastBridgeRecipient { + /// @notice Mock needs to accept ETH + receive() external payable {} + + /// @notice Incorrectly implemented - method returns incorrect value. + function fastBridgeTransferReceived(address, uint256, bytes memory) external payable returns (bytes4) { + // Flip the last bit + return IFastBridgeRecipient.fastBridgeTransferReceived.selector ^ 0x00000001; + } +} diff --git a/packages/contracts-rfq/test/mocks/NoOpContract.sol b/packages/contracts-rfq/test/mocks/NoOpContract.sol new file mode 100644 index 0000000000..ae66cfbcce --- /dev/null +++ b/packages/contracts-rfq/test/mocks/NoOpContract.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +// solhint-disable-next-line no-empty-blocks +contract NoOpContract { + /// @notice Mock needs to accept ETH + receive() external payable {} +} diff --git a/packages/contracts-rfq/test/mocks/NoReturnValueRecipient.sol b/packages/contracts-rfq/test/mocks/NoReturnValueRecipient.sol new file mode 100644 index 0000000000..e10c8b6ded --- /dev/null +++ b/packages/contracts-rfq/test/mocks/NoReturnValueRecipient.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +// solhint-disable + +/// @notice Incorrectly implemented recipient mock for testing purposes. DO NOT USE IN PRODUCTION. +contract NoReturnValueRecipient { + /// @notice Mock needs to accept ETH + receive() external payable {} + + /// @notice Incorrectly implemented - method does not return anything. + function fastBridgeTransferReceived(address, uint256, bytes memory) external payable {} +} diff --git a/packages/contracts-rfq/test/mocks/NonPayableRecipient.sol b/packages/contracts-rfq/test/mocks/NonPayableRecipient.sol new file mode 100644 index 0000000000..1f53dabfd1 --- /dev/null +++ b/packages/contracts-rfq/test/mocks/NonPayableRecipient.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @notice Incorrectly implemented recipient mock for testing purposes. DO NOT USE IN PRODUCTION. +contract NonPayableRecipient { + /// @notice Incorrectly implemented - method is not payable. + function fastBridgeTransferReceived(address, uint256, bytes memory) external pure returns (bytes4) { + return NonPayableRecipient.fastBridgeTransferReceived.selector; + } +} diff --git a/packages/contracts-rfq/test/mocks/RecipientMock.sol b/packages/contracts-rfq/test/mocks/RecipientMock.sol new file mode 100644 index 0000000000..a35d4ac5ec --- /dev/null +++ b/packages/contracts-rfq/test/mocks/RecipientMock.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IFastBridgeRecipient} from "../../contracts/interfaces/IFastBridgeRecipient.sol"; + +/// @notice Recipient mock for testing purposes. DO NOT USE IN PRODUCTION. +contract RecipientMock is IFastBridgeRecipient { + /// @notice Mock needs to accept ETH + receive() external payable {} + + /// @notice Minimal viable implementation of the fastBridgeTransferReceived hook. + function fastBridgeTransferReceived(address, uint256, bytes memory) external payable returns (bytes4) { + return IFastBridgeRecipient.fastBridgeTransferReceived.selector; + } +} diff --git a/packages/rfq-indexer/api/CHANGELOG.md b/packages/rfq-indexer/api/CHANGELOG.md index 407b13c1ab..3a70fb5e6e 100644 --- a/packages/rfq-indexer/api/CHANGELOG.md +++ b/packages/rfq-indexer/api/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.0.6](https://github.com/synapsecns/sanguine/compare/@synapsecns/rfq-indexer-api@1.0.5...@synapsecns/rfq-indexer-api@1.0.6) (2024-10-07) + +**Note:** Version bump only for package @synapsecns/rfq-indexer-api + + + + + ## [1.0.5](https://github.com/synapsecns/sanguine/compare/@synapsecns/rfq-indexer-api@1.0.4...@synapsecns/rfq-indexer-api@1.0.5) (2024-10-03) **Note:** Version bump only for package @synapsecns/rfq-indexer-api diff --git a/packages/rfq-indexer/api/README.md b/packages/rfq-indexer/api/README.md index 171802c523..8ab888a27d 100644 --- a/packages/rfq-indexer/api/README.md +++ b/packages/rfq-indexer/api/README.md @@ -6,34 +6,40 @@ To make requests, use: https://triumphant-magic-production.up.railway.app , and ## API Calls -1. GET /api/hello - - Description: A simple hello world endpoint - - Example: `curl http://localhost:3001/api/hello` -2. GET /api/pending-transactions-missing-relay +All API calls can be viewed in Swagger: + +[Swagger Documentation](http://localhost:3001/api-docs) + +1. GET /api/pending-transactions-missing-relay - Description: Retrieves pending transactions that are missing relay events - Example: ``` - curl http://localhost:3001/api/pending-transactions-missing-relay + curl http://localhost:3001/api/pending-transactions/missing-relay ``` -3. GET /api/pending-transactions-missing-proof +2. GET /api/pending-transactions-missing-proof - Description: Retrieves pending transactions that are missing proof events - Example: ``` - curl http://localhost:3001/api/pending-transactions-missing-proof + curl http://localhost:3001/api/pending-transactions/missing-proof ``` -4. GET /api/pending-transactions-missing-claim +3. GET /api/pending-transactions-missing-claim - Description: Retrieves pending transactions that are missing claim events - Example: ``` - curl http://localhost:3001/api/pending-transactions-missing-claim + curl http://localhost:3001/api/pending-transactions/missing-claim ``` -5. GraphQL endpoint: /graphql +4. GraphQL endpoint: /graphql - Description: Provides a GraphQL interface for querying indexed data, the user is surfaced an interface to query the data via GraphiQL +## Env Vars + +- **NODE_ENV**: Set to `"development"` for localhost testing. +- **DATABASE_URL**: PostgreSQL connection URL for the ponder index. + ## Important Scripts - `yarn dev:local`: Runs the API in development mode using local environment variables diff --git a/packages/rfq-indexer/api/package.json b/packages/rfq-indexer/api/package.json index b38b6706a8..e69511c2b5 100644 --- a/packages/rfq-indexer/api/package.json +++ b/packages/rfq-indexer/api/package.json @@ -1,7 +1,7 @@ { "name": "@synapsecns/rfq-indexer-api", "private": true, - "version": "1.0.5", + "version": "1.0.6", "description": "", "main": "index.js", "scripts": { diff --git a/packages/rfq-indexer/api/src/controllers/conflictingProofsController.ts b/packages/rfq-indexer/api/src/controllers/conflictingProofsController.ts index 5079bb0d5f..7f1de3137a 100644 --- a/packages/rfq-indexer/api/src/controllers/conflictingProofsController.ts +++ b/packages/rfq-indexer/api/src/controllers/conflictingProofsController.ts @@ -13,7 +13,7 @@ export const conflictingProofsController = async ( const query = db .with('deposits', () => qDeposits()) .with('relays', () => qRelays()) - .with('proofs', () => qProofs()) + .with('proofs', () => qProofs({activeOnly: true})) .with('combined', (qb) => qb .selectFrom('deposits') @@ -41,10 +41,10 @@ export const conflictingProofsController = async ( if (conflictingProofs && conflictingProofs.length > 0) { res.json(conflictingProofs) } else { - res.status(200).json({ message: 'No conflicting proofs found' }) + res.status(200).json({ message: 'No active conflicting proofs found' }) } } catch (error) { - console.error('Error fetching conflicting proofs:', error) + console.error('Error fetching active conflicting proofs:', error) res.status(500).json({ message: 'Internal server error' }) } } diff --git a/packages/rfq-indexer/api/src/controllers/disputesController.ts b/packages/rfq-indexer/api/src/controllers/disputesController.ts new file mode 100644 index 0000000000..2e65632c77 --- /dev/null +++ b/packages/rfq-indexer/api/src/controllers/disputesController.ts @@ -0,0 +1,27 @@ +import { Request, Response } from 'express' + +import { db } from '../db' +import { qDisputes } from '../queries' +import { nest_results } from '../utils/nestResults' + +export const disputesController = async (req: Request, res: Response) => { + try { + const query = db + .with('disputes', () => qDisputes({activeOnly: true})) + .selectFrom('disputes') + .selectAll() + .orderBy('blockTimestamp_dispute', 'desc') + + const results = await query.execute() + const disputes = nest_results(results) + + if (disputes && disputes.length > 0) { + res.json(disputes) + } else { + res.status(200).json({ message: 'No active disputes found' }) + } + } catch (error) { + console.error('Error fetching active disputes:', error) + res.status(500).json({ message: 'Internal server error' }) + } +} diff --git a/packages/rfq-indexer/api/src/controllers/pendingTransactionsController.ts b/packages/rfq-indexer/api/src/controllers/pendingTransactionsController.ts index dfef98c11e..a2012c53c4 100644 --- a/packages/rfq-indexer/api/src/controllers/pendingTransactionsController.ts +++ b/packages/rfq-indexer/api/src/controllers/pendingTransactionsController.ts @@ -1,9 +1,18 @@ import { Request, Response } from 'express' import { db } from '../db' -import { qDeposits, qRelays, qProofs, qClaims, qRefunds } from '../queries' +import { + qDeposits, + qRelays, + qProofs, + qClaims, + qRefunds, + qDisputes, +} from '../queries' import { nest_results } from '../utils/nestResults' +const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 24 * 60 * 60 + export const pendingTransactionsMissingClaimController = async ( req: Request, res: Response @@ -12,7 +21,7 @@ export const pendingTransactionsMissingClaimController = async ( const query = db .with('deposits', () => qDeposits()) .with('relays', () => qRelays()) - .with('proofs', () => qProofs()) + .with('proofs', () => qProofs({activeOnly: true})) .with('claims', () => qClaims()) .with('combined', (qb) => qb @@ -45,7 +54,6 @@ export const pendingTransactionsMissingClaimController = async ( } } - export const pendingTransactionsMissingProofController = async ( req: Request, res: Response @@ -54,7 +62,7 @@ export const pendingTransactionsMissingProofController = async ( const query = db .with('deposits', () => qDeposits()) .with('relays', () => qRelays()) - .with('proofs', () => qProofs()) + .with('proofs', () => qProofs({activeOnly: true})) .with('combined', (qb) => qb .selectFrom('deposits') @@ -111,6 +119,52 @@ export const pendingTransactionsMissingRelayController = async ( .selectFrom('combined') .selectAll() .orderBy('blockTimestamp_deposit', 'desc') + .where('blockTimestamp_deposit', '>', sevenDaysAgo) + + const results = await query.execute() + const nestedResults = nest_results(results) + + if (nestedResults && nestedResults.length > 0) { + res.json(nestedResults) + } else { + res + .status(404) + .json({ message: 'No pending transactions missing relay found' }) + } + } catch (error) { + console.error('Error fetching pending transactions missing relay:', error) + res.status(500).json({ message: 'Internal server error' }) + } +} + +export const pendingTransactionsMissingRelayExceedDeadlineController = async ( + req: Request, + res: Response +) => { + try { + const query = db + .with('deposits', () => qDeposits()) + .with('relays', () => qRelays()) + .with('refunds', () => qRefunds()) + .with( + 'combined', + (qb) => + qb + .selectFrom('deposits') + .selectAll('deposits') + .leftJoin('relays', 'transactionId_deposit', 'transactionId_relay') + .leftJoin( + 'refunds', + 'transactionId_deposit', + 'transactionId_refund' + ) + .where('transactionId_relay', 'is', null) // is not relayed + .where('transactionId_refund', 'is', null) // is not refunded + ) + .selectFrom('combined') + .selectAll() + .orderBy('blockTimestamp_deposit', 'desc') + .where('blockTimestamp_deposit', '<=', sevenDaysAgo) const results = await query.execute() const nestedResults = nest_results(results) diff --git a/packages/rfq-indexer/api/src/controllers/transactionIdController.ts b/packages/rfq-indexer/api/src/controllers/transactionIdController.ts index 73857496fa..586590220a 100644 --- a/packages/rfq-indexer/api/src/controllers/transactionIdController.ts +++ b/packages/rfq-indexer/api/src/controllers/transactionIdController.ts @@ -1,7 +1,7 @@ import { Request, Response } from 'express' import { db } from '../db' -import { qDeposits, qRelays, qProofs, qClaims, qRefunds } from '../queries' +import { qDeposits, qRelays, qProofs, qClaims, qRefunds, qDisputes } from '../queries' import { nest_results } from '../utils/nestResults' export const getTransactionById = async (req: Request, res: Response) => { @@ -13,7 +13,8 @@ export const getTransactionById = async (req: Request, res: Response) => { qDeposits().where('transactionId', '=', transactionId as string) ) .with('relays', () => qRelays()) - .with('proofs', () => qProofs()) + .with('proofs', () => qProofs({activeOnly: false})) // display proofs even if they have been invalidated/replaced by a dispute + .with('disputes', () => qDisputes({activeOnly: true})) // do not show disputes that have been invalidated/replaced by a proof .with('claims', () => qClaims()) .with('refunds', () => qRefunds()) .with('combined', (qb) => @@ -21,11 +22,13 @@ export const getTransactionById = async (req: Request, res: Response) => { .selectFrom('deposits') .leftJoin('relays', 'transactionId_deposit', 'transactionId_relay') .leftJoin('proofs', 'transactionId_deposit', 'transactionId_proof') + .leftJoin('disputes', 'transactionId_deposit', 'transactionId_dispute') .leftJoin('claims', 'transactionId_deposit', 'transactionId_claim') .leftJoin('refunds', 'transactionId_deposit', 'transactionId_refund') .selectAll('deposits') .selectAll('relays') .selectAll('proofs') + .selectAll('disputes') .selectAll('claims') .selectAll('refunds') ) diff --git a/packages/rfq-indexer/api/src/db/index.ts b/packages/rfq-indexer/api/src/db/index.ts index 6e9826ad35..197eef0f60 100644 --- a/packages/rfq-indexer/api/src/db/index.ts +++ b/packages/rfq-indexer/api/src/db/index.ts @@ -7,6 +7,7 @@ import type { BridgeProofProvidedEvents, BridgeDepositRefundedEvents, BridgeDepositClaimedEvents, + BridgeProofDisputedEvents, } from '../types' const { DATABASE_URL } = process.env @@ -21,6 +22,7 @@ export interface Database { BridgeProofProvidedEvents: BridgeProofProvidedEvents BridgeDepositRefundedEvents: BridgeDepositRefundedEvents BridgeDepositClaimedEvents: BridgeDepositClaimedEvents + BridgeProofDisputedEvents: BridgeProofDisputedEvents } export const db = new Kysely({ dialect }) diff --git a/packages/rfq-indexer/api/src/graphql/queries/queries.graphql b/packages/rfq-indexer/api/src/graphql/queries/queries.graphql index 89398bb2ab..4dcfbf84eb 100644 --- a/packages/rfq-indexer/api/src/graphql/queries/queries.graphql +++ b/packages/rfq-indexer/api/src/graphql/queries/queries.graphql @@ -112,14 +112,21 @@ type ConflictingProof { BridgeProof: Proof! } +type DisputedRelay { + Bridge: Transaction! + BridgeProof: Proof! +} + type Query { pendingTransactionsMissingRelay: [PendingTransactionMissingRelay!]! + pendingTransactionsMissingRelayExceedDeadline: [PendingTransactionMissingRelay!]! pendingTransactionsMissingProof: [PendingTransactionMissingProof!]! pendingTransactionsMissingClaim: [PendingTransactionMissingClaim!]! transactionById(transactionId: String!): [CompleteTransaction!]! recentInvalidRelays: [InvalidRelay!]! refundedAndRelayedTransactions: [RefundedAndRelayedTransaction!]! conflictingProofs: [ConflictingProof!]! + disputedRelays: [DisputedRelay!]! } diff --git a/packages/rfq-indexer/api/src/graphql/resolvers.ts b/packages/rfq-indexer/api/src/graphql/resolvers.ts index 3ef04819ea..1deb7950d9 100644 --- a/packages/rfq-indexer/api/src/graphql/resolvers.ts +++ b/packages/rfq-indexer/api/src/graphql/resolvers.ts @@ -88,6 +88,20 @@ const qRefunds = () => { ]) } +// typical fields to return for a BridgeProofDisputed event when it is joined to a BridgeRequest +const qDisputes = () => { + return db + .selectFrom('BridgeProofDisputedEvents') + .select([ + 'BridgeProofDisputedEvents.transactionId as transactionId_dispute', + 'BridgeProofDisputedEvents.blockNumber as blockNumber_dispute', + 'BridgeProofDisputedEvents.blockTimestamp as blockTimestamp_dispute', + 'BridgeProofDisputedEvents.transactionHash as transactionHash_dispute', + 'BridgeProofDisputedEvents.originChainId as originChainId_dispute', + 'BridgeProofDisputedEvents.originChain as originChain_dispute', + ]) +} + // using the suffix of a field, move it into a nested sub-object. This is a cleaner final resultset // example: transactionHash_deposit:0xyz would get moved into BridgeRequest{transactionHash:0xyz} // @@ -220,6 +234,19 @@ const resolvers = { 'BridgeDepositClaimedEvents.originChain', ]) ) + .unionAll( + db + .selectFrom('BridgeProofDisputedEvents') + .select([ + 'BridgeProofDisputedEvents.id', + 'BridgeProofDisputedEvents.transactionId', + 'BridgeProofDisputedEvents.blockNumber', + 'BridgeProofDisputedEvents.blockTimestamp', + 'BridgeProofDisputedEvents.transactionHash', + 'BridgeProofDisputedEvents.originChainId', + 'BridgeProofDisputedEvents.originChain', + ]) + ) if (filter) { if (filter.transactionId) { @@ -466,6 +493,29 @@ const resolvers = { return nest_results(await query.execute()) }, + disputedRelays: async () => { + const query = db + .with('deposits', () => qDeposits()) + .with('relays', () => qRelays()) + .with('proofs', () => qProofs()) + .with('disputes', () => qDisputes()) + .with('combined', (qb) => + qb + .selectFrom('proofs') + .leftJoin( + 'disputes', + 'transactionId_proof', + 'transactionId_dispute' + ) + .selectAll('proofs') + .selectAll('disputes') + ) + .selectFrom('combined') + .selectAll() + .orderBy('blockTimestamp_proof', 'desc') + + return nest_results(await query.execute()) + }, }, BridgeEvent: { // eslint-disable-next-line prefer-arrow/prefer-arrow-functions diff --git a/packages/rfq-indexer/api/src/graphql/types/events.graphql b/packages/rfq-indexer/api/src/graphql/types/events.graphql index 61f0f4cb4b..2e3716de0e 100644 --- a/packages/rfq-indexer/api/src/graphql/types/events.graphql +++ b/packages/rfq-indexer/api/src/graphql/types/events.graphql @@ -19,7 +19,7 @@ scalar BigInt destChain: String! sendChainGas: Boolean! } - + type BridgeRelayedEvent { id: String! transactionId: String! @@ -39,7 +39,7 @@ scalar BigInt destChainId: Int! destChain: String! } - + type BridgeProofProvidedEvent { id: String! transactionId: String! @@ -50,7 +50,7 @@ scalar BigInt originChain: String! relayer: String! } - + type BridgeDepositRefundedEvent { id: String! transactionId: String! @@ -64,7 +64,7 @@ scalar BigInt amount: BigInt! amountFormatted: String! } - + type BridgeDepositClaimedEvent { id: String! transactionId: String! @@ -78,4 +78,14 @@ scalar BigInt token: String! amount: BigInt! amountFormatted: String! - } \ No newline at end of file +} + + type BridgeProofDisputedEvent { + id: String! + transactionId: String! + blockNumber: BigInt! + blockTimestamp: Int! + transactionHash: String! + originChainId: Int! + originChain: String! + } diff --git a/packages/rfq-indexer/api/src/queries/disputesQueries.ts b/packages/rfq-indexer/api/src/queries/disputesQueries.ts new file mode 100644 index 0000000000..829ba08a35 --- /dev/null +++ b/packages/rfq-indexer/api/src/queries/disputesQueries.ts @@ -0,0 +1,26 @@ +import { db } from '../db' + +export const qDisputes = ({ activeOnly }: { activeOnly: boolean } = { activeOnly: false}) => { + let query = db + .selectFrom('BridgeProofDisputedEvents') + .leftJoin('BridgeProofProvidedEvents', (join) => + // if a proof occurred after this dispute, consider the dispute to be stale/invalid & ignore it + join + .onRef('BridgeProofProvidedEvents.transactionId', '=', 'BridgeProofDisputedEvents.transactionId') + .onRef('BridgeProofProvidedEvents.blockTimestamp', '>', 'BridgeProofDisputedEvents.blockTimestamp') + ) + .select([ + 'BridgeProofDisputedEvents.transactionId as transactionId_dispute', + 'BridgeProofDisputedEvents.blockNumber as blockNumber_dispute', + 'BridgeProofDisputedEvents.blockTimestamp as blockTimestamp_dispute', + 'BridgeProofDisputedEvents.transactionHash as transactionHash_dispute', + 'BridgeProofDisputedEvents.originChainId as originChainId_dispute', + 'BridgeProofDisputedEvents.originChain as originChain_dispute', + ]); + + if (activeOnly) { + query = query.where('BridgeProofProvidedEvents.transactionId', 'is', null); + } + + return query; +} diff --git a/packages/rfq-indexer/api/src/queries/index.ts b/packages/rfq-indexer/api/src/queries/index.ts index 72bad2522e..da82fe8f87 100644 --- a/packages/rfq-indexer/api/src/queries/index.ts +++ b/packages/rfq-indexer/api/src/queries/index.ts @@ -1,5 +1,6 @@ export { qClaims } from './claimsQueries' export { qDeposits } from './depositsQueries' +export { qDisputes } from './disputesQueries' export { qProofs } from './proofsQueries' export { qRefunds } from './refundsQueries' export { qRelays } from './relaysQueries' diff --git a/packages/rfq-indexer/api/src/queries/proofsQueries.ts b/packages/rfq-indexer/api/src/queries/proofsQueries.ts index e7a1ccc012..18a7b50325 100644 --- a/packages/rfq-indexer/api/src/queries/proofsQueries.ts +++ b/packages/rfq-indexer/api/src/queries/proofsQueries.ts @@ -1,15 +1,25 @@ import { db } from '../db' // typical fields to return for a BridgeProofProvided event when it is joined to a BridgeRequest -export const qProofs = () => { - return db +export const qProofs = ({ activeOnly}: { activeOnly: boolean} = { activeOnly: false}) => { + let query = db .selectFrom('BridgeProofProvidedEvents') + .leftJoin('BridgeProofDisputedEvents', (join) => + join + .onRef('BridgeProofDisputedEvents.transactionId', '=', 'BridgeProofProvidedEvents.transactionId') + .onRef('BridgeProofDisputedEvents.blockTimestamp', '>', 'BridgeProofProvidedEvents.blockTimestamp') + ) .select([ 'BridgeProofProvidedEvents.transactionId as transactionId_proof', 'BridgeProofProvidedEvents.blockNumber as blockNumber_proof', 'BridgeProofProvidedEvents.blockTimestamp as blockTimestamp_proof', 'BridgeProofProvidedEvents.transactionHash as transactionHash_proof', - 'BridgeProofProvidedEvents.relayer as relayer_proof', - ]) -} + ]); + + if (activeOnly) { + query = query.where('BridgeProofDisputedEvents.transactionId', 'is', null); + } + + return query; +} \ No newline at end of file diff --git a/packages/rfq-indexer/api/src/routes/disputesRoute.ts b/packages/rfq-indexer/api/src/routes/disputesRoute.ts new file mode 100644 index 0000000000..de964220b0 --- /dev/null +++ b/packages/rfq-indexer/api/src/routes/disputesRoute.ts @@ -0,0 +1,65 @@ +import express from 'express' + +import { disputesController } from '../controllers/disputesController' + +const router = express.Router() + +/** + * @openapi + * /disputes: + * get: + * summary: Get all active disputes + * description: Retrieves a list of all active disputes + * responses: + * 200: + * description: Successful response + * content: + * application/json: + * schema: + * type: array + * items: + * type: object + * properties: + * Bridge: + * type: object + * description: General transaction fields + * BridgeRequest: + * type: object + * description: Deposit information + * BridgeRelay: + * type: object + * description: Relay information + * BridgeRefund: + * type: object + * description: Refund information + * BridgeProof: + * type: object + * description: Proof information (if available) + * BridgeClaim: + * type: object + * description: Claim information (if available) + * BridgeDispute: + * type: object + * description: Dispute information (if available) + * 404: + * description: No disputes found + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * 500: + * description: Server error + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + */ +router.get('/', disputesController) + +export default router diff --git a/packages/rfq-indexer/api/src/routes/index.ts b/packages/rfq-indexer/api/src/routes/index.ts index 302f878781..d85758c97b 100644 --- a/packages/rfq-indexer/api/src/routes/index.ts +++ b/packages/rfq-indexer/api/src/routes/index.ts @@ -5,6 +5,7 @@ import refundedAndRelayedRoute from './refundedAndRelayedRoute' import invalidRelaysRoute from './invalidRelaysRoute' import conflictingProofsRoute from './conflictingProofsRoute' import transactionIdRoute from './transactionIdRoute' +import disputesRoute from './disputesRoute' const router = express.Router() @@ -13,5 +14,5 @@ router.use('/refunded-and-relayed', refundedAndRelayedRoute) router.use('/invalid-relays', invalidRelaysRoute) router.use('/conflicting-proofs', conflictingProofsRoute) router.use('/transaction-id', transactionIdRoute) - +router.use('/disputes', disputesRoute) export default router diff --git a/packages/rfq-indexer/api/src/routes/pendingTransactionsRoute.ts b/packages/rfq-indexer/api/src/routes/pendingTransactionsRoute.ts index 2dbeafb121..e80dbd7fac 100644 --- a/packages/rfq-indexer/api/src/routes/pendingTransactionsRoute.ts +++ b/packages/rfq-indexer/api/src/routes/pendingTransactionsRoute.ts @@ -3,7 +3,8 @@ import express from 'express' import { pendingTransactionsMissingClaimController, pendingTransactionsMissingProofController, - pendingTransactionsMissingRelayController + pendingTransactionsMissingRelayController, + pendingTransactionsMissingRelayExceedDeadlineController } from '../controllers/pendingTransactionsController' const router = express.Router() @@ -146,4 +147,46 @@ router.get('/missing-proof', pendingTransactionsMissingProofController) */ router.get('/missing-relay', pendingTransactionsMissingRelayController) +/** + * @openapi + * /pending-transactions/exceed-deadline: + * get: + * summary: Get pending transactions exceed deadline + * description: Retrieves a list of transactions that have been deposited, but not yet relayed or refunded and have exceeded the deadline + * responses: + * 200: + * description: Successful response + * content: + * application/json: + * schema: + * type: array + * items: + * type: object + * properties: + * deposit: + * type: object + * 404: + * description: No pending transactionst that exceed the deadline found + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * 500: + * description: Server error + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + */ +router.get( + '/exceed-deadline', + pendingTransactionsMissingRelayExceedDeadlineController +) + export default router diff --git a/packages/rfq-indexer/api/src/types/index.ts b/packages/rfq-indexer/api/src/types/index.ts index a536bead99..738e9469fd 100644 --- a/packages/rfq-indexer/api/src/types/index.ts +++ b/packages/rfq-indexer/api/src/types/index.ts @@ -81,6 +81,15 @@ export interface BridgeDepositClaimedEvents { amountFormatted: ColumnType } +export interface BridgeProofDisputedEvents { + id: ColumnType + transactionId: ColumnType + blockNumber: ColumnType + blockTimestamp: ColumnType + transactionHash: ColumnType + chainId: ColumnType + chain: ColumnType +} // Add any other shared types used across the API export type EventType = | 'REQUEST' @@ -88,7 +97,7 @@ export type EventType = | 'PROOF_PROVIDED' | 'DEPOSIT_REFUNDED' | 'DEPOSIT_CLAIMED' - + | 'DISPUTE' export interface EventFilter { type?: EventType transactionId?: string