diff --git a/.env.example b/.env.example index 80c7efe..389b82d 100644 --- a/.env.example +++ b/.env.example @@ -57,6 +57,10 @@ CLAIM_CONTRACT_ADDRESS=0x ROLLUP_CONTRACT_ADDRESS=0x MINTER_CONTRACT_ADDRESS=0x MINTER_CONTRACT_DEPLOYED_BLOCK=0 +MAINNET_BRIDGE_O_APP_CONTRACT_ADDRESS=0x +MAINNET_BRIDGE_O_APP_CONTRACT_DEPLOYED_BLOCK=0 +BASE_BRIDGE_O_APP_CONTRACT_ADDRESS=0x +BASE_BRIDGE_O_APP_CONTRACT_DEPLOYED_BLOCK=0 # messenger contract L1_SCROLL_MESSENGER_CONTRACT_ADDRESS=0x diff --git a/README.md b/README.md index 847c4e6..e1ecaf5 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ The project is divided into the following workspaces: ```sh packages ├── block-sync-monitor +├── bridge-event-watcher +├── bridge-monitor ├── deposit-analyzer ├── indexer ├── indexer-cache-validator @@ -74,7 +76,6 @@ gcloud emulators firestore start # Set the FIRESTORE_EMULATOR_HOST variable in the same terminal where you will run your application. export FIRESTORE_EMULATOR_HOST="HOST:PORT" -export FIRESTORE_EMULATOR_HOST="HOST:PORT" # We will use what is displayed in the console. ``` ## Docker diff --git a/packages/bridge-event-watcher/README.md b/packages/bridge-event-watcher/README.md new file mode 100644 index 0000000..e4abab1 --- /dev/null +++ b/packages/bridge-event-watcher/README.md @@ -0,0 +1,17 @@ +# bridge-event-watcher + +bridge-event-watcher is a tool designed to monitor and process bridge transactions on blockchain networks. + +## Usage + +To set up the development environment: + +```bash +# install +yarn + +# dev +yarn workspace bridge-event-watcher dev + +# build +yarn build diff --git a/packages/bridge-event-watcher/package.json b/packages/bridge-event-watcher/package.json new file mode 100644 index 0000000..76a4004 --- /dev/null +++ b/packages/bridge-event-watcher/package.json @@ -0,0 +1,19 @@ +{ + "name": "bridge-event-watcher", + "version": "1.0.0", + "dependencies": { + "@intmax2-function/shared": "workspace:*" + }, + "scripts": { + "start": "node dist/index.js", + "dev": "tsx watch --env-file=./../../.env src/index.ts", + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest" + }, + "devDependencies": { + "tsx": "^4.20.4", + "typescript": "^5.9.2", + "vitest": "^3.2.4" + } +} diff --git a/packages/bridge-event-watcher/src/constants.ts b/packages/bridge-event-watcher/src/constants.ts new file mode 100644 index 0000000..d015adb --- /dev/null +++ b/packages/bridge-event-watcher/src/constants.ts @@ -0,0 +1,14 @@ +export enum MessageStatus { + INFLIGHT = "INFLIGHT", + CONFIRMING = "CONFIRMING", + VERIFIED = "VERIFIED", + DELIVERED = "DELIVERED", + FAILED = "FAILED", + PAYLOAD_STORED = "PAYLOAD_STORED", + BLOCKED = "BLOCKED", +} + +export const LAYER_ZERO_SCAN_API = { + ["l1"]: "https://scan.layerzero-api.com/v1", + ["l2"]: "https://scan-testnet.layerzero-api.com/v1", +} as const; diff --git a/packages/bridge-event-watcher/src/index.ts b/packages/bridge-event-watcher/src/index.ts new file mode 100644 index 0000000..7b9994b --- /dev/null +++ b/packages/bridge-event-watcher/src/index.ts @@ -0,0 +1,27 @@ +import { logger, timeOperation } from "@intmax2-function/shared"; +import { name } from "../package.json"; +import { performJob } from "./service/job.service"; + +async function main() { + try { + logger.info(`Starting ${name} job`); + const { durationInSeconds } = await timeOperation(performJob); + logger.info(`Completed ${name} job executed successfully in ${durationInSeconds}s`); + process.exit(0); + } catch (error) { + logger.error(error); + process.exit(1); + } +} + +process.on("unhandledRejection", (reason, promise) => { + logger.error(`Unhandled Rejection at: ${promise} reason: ${reason}`); + process.exit(1); +}); + +if (require.main === module) { + main().catch((error) => { + logger.error(error); + process.exit(1); + }); +} diff --git a/packages/bridge-event-watcher/src/service/job.service.ts b/packages/bridge-event-watcher/src/service/job.service.ts new file mode 100644 index 0000000..43d1deb --- /dev/null +++ b/packages/bridge-event-watcher/src/service/job.service.ts @@ -0,0 +1,72 @@ +import { + BASE_BRIDGE_O_APP_CONTRACT_ADDRESS, + BASE_BRIDGE_O_APP_CONTRACT_DEPLOYED_BLOCK, + BLOCK_RANGE_TINY, + type BridgeRequestedEvent, + BridgeTransaction, + bridgeRequestedEvent, + createNetworkClient, + Event, + type EventData, + FIRESTORE_DOCUMENT_EVENTS, + fetchEvents, + getStartBlockNumber, + logger, + validateBlockRange, +} from "@intmax2-function/shared"; + +export const performJob = async () => { + const l2Client = createNetworkClient("l2"); + const event = new Event(FIRESTORE_DOCUMENT_EVENTS.BRIDGE_REQUESTED); + + const [currentBlockNumber, lastProcessedEvent] = await Promise.all([ + await l2Client.getBlockNumber(), + await event.getEvent(), + ]); + + await processBridgeMonitor(l2Client, currentBlockNumber, event, lastProcessedEvent); +}; + +const processBridgeMonitor = async ( + l2Client: ReturnType, + currentBlockNumber: bigint, + event: Event, + lastProcessedEvent: EventData | null, +) => { + const startBlockNumber = getStartBlockNumber( + lastProcessedEvent, + BASE_BRIDGE_O_APP_CONTRACT_DEPLOYED_BLOCK, + ); + const isValid = validateBlockRange("BridgeRequested", startBlockNumber, currentBlockNumber); + if (!isValid) { + logger.info("Skipping process BridgeRequested due to invalid block range."); + return; + } + + const bridgeRequestedEvents = await fetchEvents(l2Client, { + startBlockNumber: BigInt(BASE_BRIDGE_O_APP_CONTRACT_DEPLOYED_BLOCK), + endBlockNumber: currentBlockNumber, + blockRange: BLOCK_RANGE_TINY, + contractAddress: BASE_BRIDGE_O_APP_CONTRACT_ADDRESS, + eventInterface: bridgeRequestedEvent, + }); + + const bridgeRequestedInputs = bridgeRequestedEvents.map((event) => ({ + guid: event.args.receipt.guid, + nonce: Number(event.args.receipt.nonce), + recipient: event.args.recipient, + amount: event.args.amount.toString(), + transactionHash: event.transactionHash, + })); + + await BridgeTransaction.getInstance().saveBridgeTransactionsBatch(bridgeRequestedInputs); + + await updateEventState(event, currentBlockNumber); +}; + +const updateEventState = async (event: Event, currentBlockNumber: bigint) => { + const eventData = { + lastBlockNumber: Number(currentBlockNumber), + }; + await event.addOrUpdateEvent(eventData); +}; diff --git a/packages/bridge-event-watcher/tsconfig.json b/packages/bridge-event-watcher/tsconfig.json new file mode 100644 index 0000000..a09ade0 --- /dev/null +++ b/packages/bridge-event-watcher/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "sourceMap": true, + "isolatedModules": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "allowUnreachableCode": false, + "incremental": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/*.spec.ts", "dist"] +} diff --git a/packages/bridge-monitor/README.md b/packages/bridge-monitor/README.md new file mode 100644 index 0000000..e49306d --- /dev/null +++ b/packages/bridge-monitor/README.md @@ -0,0 +1,17 @@ +# bridge-monitor + +The bridge-monitor is a tool designed to monitor and process bridge transactions on blockchain networks. + +## Usage + +To set up the development environment: + +```bash +# install +yarn + +# dev +yarn workspace bridge-monitor dev + +# build +yarn build diff --git a/packages/bridge-monitor/package.json b/packages/bridge-monitor/package.json new file mode 100644 index 0000000..ac86e7a --- /dev/null +++ b/packages/bridge-monitor/package.json @@ -0,0 +1,21 @@ +{ + "name": "bridge-monitor", + "version": "1.0.0", + "dependencies": { + "@intmax2-function/shared": "workspace:*", + "axios": "^1.11.0", + "viem": "^2.34.0" + }, + "scripts": { + "start": "node dist/index.js", + "dev": "tsx watch --env-file=./../../.env src/index.ts", + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest" + }, + "devDependencies": { + "tsx": "^4.20.4", + "typescript": "^5.9.2", + "vitest": "^3.2.4" + } +} diff --git a/packages/bridge-monitor/src/constants.ts b/packages/bridge-monitor/src/constants.ts new file mode 100644 index 0000000..6d050f5 --- /dev/null +++ b/packages/bridge-monitor/src/constants.ts @@ -0,0 +1,4 @@ +export const LAYER_ZERO_SCAN_API = { + ["mainnet"]: "https://scan.layerzero-api.com/v1", + ["testnet"]: "https://scan-testnet.layerzero-api.com/v1", +} as const; diff --git a/packages/bridge-monitor/src/index.ts b/packages/bridge-monitor/src/index.ts new file mode 100644 index 0000000..7b9994b --- /dev/null +++ b/packages/bridge-monitor/src/index.ts @@ -0,0 +1,27 @@ +import { logger, timeOperation } from "@intmax2-function/shared"; +import { name } from "../package.json"; +import { performJob } from "./service/job.service"; + +async function main() { + try { + logger.info(`Starting ${name} job`); + const { durationInSeconds } = await timeOperation(performJob); + logger.info(`Completed ${name} job executed successfully in ${durationInSeconds}s`); + process.exit(0); + } catch (error) { + logger.error(error); + process.exit(1); + } +} + +process.on("unhandledRejection", (reason, promise) => { + logger.error(`Unhandled Rejection at: ${promise} reason: ${reason}`); + process.exit(1); +}); + +if (require.main === module) { + main().catch((error) => { + logger.error(error); + process.exit(1); + }); +} diff --git a/packages/bridge-monitor/src/lib/blockchain.ts b/packages/bridge-monitor/src/lib/blockchain.ts new file mode 100644 index 0000000..bdb64e1 --- /dev/null +++ b/packages/bridge-monitor/src/lib/blockchain.ts @@ -0,0 +1,3 @@ +import { createNetworkClient } from "@intmax2-function/shared"; + +export const l1Client = createNetworkClient("l1"); diff --git a/packages/bridge-monitor/src/service/job.service.ts b/packages/bridge-monitor/src/service/job.service.ts new file mode 100644 index 0000000..7ba7d25 --- /dev/null +++ b/packages/bridge-monitor/src/service/job.service.ts @@ -0,0 +1,59 @@ +import { + BridgeTransaction, + type BridgeTransactionData, + BridgeTransactionStatus, +} from "@intmax2-function/shared"; +import { + fetchBridgeGuidTransaction, + handleFailedStatus, + handleInflightOrConfirming, + handlePayloadStored, + handleVerifiedStatus, +} from "./process.service"; + +export const performJob = async () => { + const bridgeTransactions = await BridgeTransaction.getInstance().fetchBridgeTransactions({ + statuses: [ + BridgeTransactionStatus.QUEUED, + BridgeTransactionStatus.INFLIGHT, + BridgeTransactionStatus.CONFIRMING, + BridgeTransactionStatus.VERIFIED, + BridgeTransactionStatus.BLOCKED, + ], + }); + + const sortedTransactions = bridgeTransactions.sort((a, b) => a.nonce - b.nonce); + + for (const bridgeTransaction of sortedTransactions) { + await processBridgeTransaction(bridgeTransaction); + } +}; + +const processBridgeTransaction = async (bridgeTransaction: BridgeTransactionData) => { + // TODO: sleep and retry + const bridgeGuidTransaction = await fetchBridgeGuidTransaction(bridgeTransaction.guid); + const statusName = bridgeGuidTransaction.status.name; + + switch (statusName) { + case BridgeTransactionStatus.FAILED: + await handleFailedStatus(bridgeGuidTransaction); + break; + + case BridgeTransactionStatus.INFLIGHT: + case BridgeTransactionStatus.CONFIRMING: + await handleInflightOrConfirming(bridgeTransaction); + break; + + case BridgeTransactionStatus.VERIFIED: + await handleVerifiedStatus(bridgeTransaction); + break; + + case BridgeTransactionStatus.PAYLOAD_STORED: + await handlePayloadStored(bridgeGuidTransaction); + break; + } + + await BridgeTransaction.getInstance().updateBridgeTransaction(bridgeTransaction.guid, { + status: statusName as BridgeTransactionStatus, + }); +}; diff --git a/packages/bridge-monitor/src/service/process.service.ts b/packages/bridge-monitor/src/service/process.service.ts new file mode 100644 index 0000000..5e6cbe0 --- /dev/null +++ b/packages/bridge-monitor/src/service/process.service.ts @@ -0,0 +1,117 @@ +import { + API_TIMEOUT, + BridgeTransactionData, + config, + Discord, + logger, + MAINNET_BRIDGE_O_APP_CONTRACT_ADDRESS, + MainnetBridgeOAppAbi, +} from "@intmax2-function/shared"; +import axios, { AxiosError } from "axios"; +import type { Abi } from "viem"; +import { LAYER_ZERO_SCAN_API } from "../constants"; +import { l1Client } from "../lib/blockchain"; +import type { BridgeGuidTransaction, BridgeGuidTransactionResponse } from "../types"; +import { submitTransaction } from "./submit.service"; + +export const fetchBridgeGuidTransaction = async (guid: string) => { + const layerZeroMessagesUrl = `${LAYER_ZERO_SCAN_API[config.LAYER_ZERO_NETWORK]}/messages/guid/${guid}`; + try { + const response = await axios.get(layerZeroMessagesUrl, { + timeout: API_TIMEOUT, + headers: { + Accept: "application/json", + }, + }); + if (response.data?.data === undefined) { + throw new Error("Data is missing in the response"); + } + const transactions = response.data.data as BridgeGuidTransaction[]; + + if (transactions.length === 0) { + throw new Error("No transactions found"); + } + + return transactions[0]; + } catch (error) { + logger.error( + `Failed to fetch bridge transaction status url: ${layerZeroMessagesUrl} ${error instanceof Error ? error.message : error}`, + ); + // 404 + + if (error instanceof AxiosError) { + throw new Error(`Failed to fetch status: ${error.response?.status}`); + } + + throw new Error( + `Unexpected error while fetching bridge transaction status: ${ + error instanceof Error ? error.message : error + }`, + ); + } +}; + +export const handleFailedStatus = async (_: BridgeGuidTransaction) => { + await submitTransaction("clear"); +}; + +export const handleInflightOrConfirming = async (bridgeTransaction: BridgeTransactionData) => { + const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + + // TODO + if (bridgeTransaction.updatedAt.toDate() < twentyFourHoursAgo) { + await Discord.getInstance().sendMessageWitForReady( + "FATAL", + `INFLIGHT/CONFIRMING status persists over 24 hours: ${bridgeTransaction.guid}`, + ); + } +}; + +export const handleVerifiedStatus = async (bridgeTransaction: BridgeTransactionData) => { + const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + const verifiedTimestamp = bridgeTransaction.verifiedAt + ? bridgeTransaction.verifiedAt.toDate() + : bridgeTransaction.createdAt.toDate(); + + // TODO: + if (verifiedTimestamp < twentyFourHoursAgo) { + await submitTransaction("manualRetry"); + } +}; + +export const handlePayloadStored = async (bridgeGuidTransaction: BridgeGuidTransaction) => { + const hasStored = await hasStoredPayload(); + + if (hasStored) { + await submitTransaction("manualRetry"); + } else { + await Discord.getInstance().sendMessageWitForReady( + "FATAL", + `PAYLOAD_STORED but hasStoredPayload is false: ${bridgeGuidTransaction.guid}`, + ); + } +}; + +const hasStoredPayload = async () => { + const currentBlockNumber = await l1Client.getBlockNumber(); + + const args = [ + { + srcEid: "srcEid", + sender: "sender", + nonce: "nonce", + guid: "guid", + message: "message", + }, + ]; + + const isStored = await l1Client.readContract({ + address: MAINNET_BRIDGE_O_APP_CONTRACT_ADDRESS, + abi: MainnetBridgeOAppAbi as Abi, + functionName: "hasStoredPayload", + args, + blockNumber: currentBlockNumber, + }); + + return isStored as boolean; +}; diff --git a/packages/bridge-monitor/src/service/submit.service.ts b/packages/bridge-monitor/src/service/submit.service.ts new file mode 100644 index 0000000..64a3872 --- /dev/null +++ b/packages/bridge-monitor/src/service/submit.service.ts @@ -0,0 +1,3 @@ +export const submitTransaction = async (operation: string) => { + console.log(`Submitting transaction for operation: ${operation}`); +}; diff --git a/packages/bridge-monitor/src/types.ts b/packages/bridge-monitor/src/types.ts new file mode 100644 index 0000000..eccb88b --- /dev/null +++ b/packages/bridge-monitor/src/types.ts @@ -0,0 +1,146 @@ +export interface BridgeGuidTransactionResponse { + data: BridgeGuidTransaction[]; +} + +export interface BridgeGuidTransaction { + pathway: Pathway; + source: Source; + destination: Destination; + verification: Verification; + guid: string; + config: Config; + status: Status; + created: string; + updated: string; +} + +interface Pathway { + srcEid: number; + dstEid: number; + sender: PathwayEndpoint; + receiver: PathwayEndpoint; + id: string; + nonce: number; +} + +interface PathwayEndpoint { + address: string; + id: string; + name: string; + chain: string; +} + +interface Source { + status: string; + tx: SourceTransaction; + failedTx: string[]; +} + +interface SourceTransaction { + txHash: string; + blockHash: string; + blockNumber: string; + blockTimestamp: number; + from: string; + blockConfirmations: number; + payload: string; + value: string; + readinessTimestamp: number; + resolvedPayload: string; + adapterParams: AdapterParams; + options: TransactionOptions; +} + +interface AdapterParams { + version: string; + dstGasLimit: string; + dstNativeGasTransferAmount: string; + dstNativeGasTransferAddress: string; +} + +interface TransactionOptions { + lzReceive: GasValue; + nativeDrop: NativeDrop[]; + compose: Compose[]; + ordered: boolean; +} + +interface GasValue { + gas: string; + value: string; +} + +interface NativeDrop { + amount: string; + receiver: string; +} + +interface Compose { + index: number; + gas: string; + value: string; +} + +interface Destination { + status: string; + tx: DestinationTransaction; + payloadStoredTx: string; + failedTx: string[]; +} + +interface DestinationTransaction { + txHash: string; + blockHash: string; + blockNumber: number; + blockTimestamp: number; +} + +interface Verification { + dvn: DVNVerification; + sealer: SealerVerification; +} + +interface DVNVerification { + dvns: Record; + status: string; +} + +interface SealerVerification { + tx: DestinationTransaction; + failedTx: FailedTransaction[]; + status: string; +} + +interface FailedTransaction { + txHash: string; + txError: string; +} + +interface Config { + error: boolean; + errorMessage: string; + dvnConfigError: boolean; + receiveLibrary: string; + sendLibrary: string; + inboundConfig: ChainConfig; + outboundConfig: ChainConfig; + ulnSendVersion: string; + ulnReceiveVersion: string; +} + +interface ChainConfig { + confirmations: number; + requiredDVNCount: number; + optionalDVNCount: number; + optionalDVNThreshold: number; + requiredDVNs: string[]; + requiredDVNNames: string[]; + optionalDVNs: string[]; + optionalDVNNames: string[]; + executor: string; +} + +export interface Status { + name: string; + message: string; +} diff --git a/packages/bridge-monitor/tsconfig.json b/packages/bridge-monitor/tsconfig.json new file mode 100644 index 0000000..a09ade0 --- /dev/null +++ b/packages/bridge-monitor/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "sourceMap": true, + "isolatedModules": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "allowUnreachableCode": false, + "incremental": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/*.spec.ts", "dist"] +} diff --git a/packages/shared/src/abi/BaseBridgeOApp.json b/packages/shared/src/abi/BaseBridgeOApp.json new file mode 100644 index 0000000..ce0255c --- /dev/null +++ b/packages/shared/src/abi/BaseBridgeOApp.json @@ -0,0 +1,266 @@ +{ + "abi": [ + { + "type": "constructor", + "inputs": [ + { "name": "_endpoint", "type": "address", "internalType": "address" }, + { "name": "_delegate", "type": "address", "internalType": "address" }, + { "name": "_owner", "type": "address", "internalType": "address" }, + { "name": "_token", "type": "address", "internalType": "address" }, + { "name": "_dstEid", "type": "uint32", "internalType": "uint32" } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "bridgeStorage", + "inputs": [], + "outputs": [{ "name": "", "type": "address", "internalType": "contract IBridgeStorage" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "bridgeTo", + "inputs": [{ "name": "recipient", "type": "address", "internalType": "address" }], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "bridgedAmount", + "inputs": [{ "name": "user", "type": "address", "internalType": "address" }], + "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "endpoint", + "inputs": [], + "outputs": [ + { "name": "", "type": "address", "internalType": "contract ILayerZeroEndpointV2" } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "gasLimit", + "inputs": [], + "outputs": [{ "name": "", "type": "uint128", "internalType": "uint128" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "oAppVersion", + "inputs": [], + "outputs": [ + { "name": "senderVersion", "type": "uint64", "internalType": "uint64" }, + { "name": "receiverVersion", "type": "uint64", "internalType": "uint64" } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "owner", + "inputs": [], + "outputs": [{ "name": "", "type": "address", "internalType": "address" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "peers", + "inputs": [{ "name": "eid", "type": "uint32", "internalType": "uint32" }], + "outputs": [{ "name": "peer", "type": "bytes32", "internalType": "bytes32" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "quoteBridge", + "inputs": [], + "outputs": [ + { + "name": "fee", + "type": "tuple", + "internalType": "struct MessagingFee", + "components": [ + { "name": "nativeFee", "type": "uint256", "internalType": "uint256" }, + { "name": "lzTokenFee", "type": "uint256", "internalType": "uint256" } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "renounceOwnership", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setBridgeStorage", + "inputs": [{ "name": "_bridgeStorage", "type": "address", "internalType": "address" }], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setDelegate", + "inputs": [{ "name": "_delegate", "type": "address", "internalType": "address" }], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setGasLimit", + "inputs": [{ "name": "_gasLimit", "type": "uint128", "internalType": "uint128" }], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setPeer", + "inputs": [ + { "name": "_eid", "type": "uint32", "internalType": "uint32" }, + { "name": "_peer", "type": "bytes32", "internalType": "bytes32" } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "transferOwnership", + "inputs": [{ "name": "newOwner", "type": "address", "internalType": "address" }], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "transferStorageOwnership", + "inputs": [{ "name": "newOwner", "type": "address", "internalType": "address" }], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "event", + "name": "BridgeRequested", + "inputs": [ + { "name": "recipient", "type": "address", "indexed": true, "internalType": "address" }, + { "name": "amount", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "user", "type": "address", "indexed": true, "internalType": "address" }, + { + "name": "receipt", + "type": "tuple", + "indexed": false, + "internalType": "struct MessagingReceipt", + "components": [ + { "name": "guid", "type": "bytes32", "internalType": "bytes32" }, + { "name": "nonce", "type": "uint64", "internalType": "uint64" }, + { + "name": "fee", + "type": "tuple", + "internalType": "struct MessagingFee", + "components": [ + { "name": "nativeFee", "type": "uint256", "internalType": "uint256" }, + { "name": "lzTokenFee", "type": "uint256", "internalType": "uint256" } + ] + } + ] + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "BridgeStorageUpdated", + "inputs": [ + { "name": "oldStorage", "type": "address", "indexed": true, "internalType": "address" }, + { "name": "newStorage", "type": "address", "indexed": true, "internalType": "address" } + ], + "anonymous": false + }, + { + "type": "event", + "name": "GasLimitUpdated", + "inputs": [ + { "name": "oldLimit", "type": "uint128", "indexed": false, "internalType": "uint128" }, + { "name": "newLimit", "type": "uint128", "indexed": false, "internalType": "uint128" } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OwnershipTransferred", + "inputs": [ + { "name": "previousOwner", "type": "address", "indexed": true, "internalType": "address" }, + { "name": "newOwner", "type": "address", "indexed": true, "internalType": "address" } + ], + "anonymous": false + }, + { + "type": "event", + "name": "PeerSet", + "inputs": [ + { "name": "eid", "type": "uint32", "indexed": false, "internalType": "uint32" }, + { "name": "peer", "type": "bytes32", "indexed": false, "internalType": "bytes32" } + ], + "anonymous": false + }, + { "type": "error", "name": "BalanceLessThanBridged", "inputs": [] }, + { "type": "error", "name": "InsufficientNativeFee", "inputs": [] }, + { "type": "error", "name": "InvalidBridgeStorage", "inputs": [] }, + { "type": "error", "name": "InvalidDelegate", "inputs": [] }, + { "type": "error", "name": "InvalidEndpointCall", "inputs": [] }, + { "type": "error", "name": "InvalidGasLimit", "inputs": [] }, + { + "type": "error", + "name": "InvalidOptionType", + "inputs": [{ "name": "optionType", "type": "uint16", "internalType": "uint16" }] + }, + { "type": "error", "name": "LzTokenUnavailable", "inputs": [] }, + { "type": "error", "name": "NoDelta", "inputs": [] }, + { + "type": "error", + "name": "NoPeer", + "inputs": [{ "name": "eid", "type": "uint32", "internalType": "uint32" }] + }, + { + "type": "error", + "name": "NotEnoughNative", + "inputs": [{ "name": "msgValue", "type": "uint256", "internalType": "uint256" }] + }, + { + "type": "error", + "name": "OnlyPeer", + "inputs": [ + { "name": "eid", "type": "uint32", "internalType": "uint32" }, + { "name": "sender", "type": "bytes32", "internalType": "bytes32" } + ] + }, + { + "type": "error", + "name": "OwnableInvalidOwner", + "inputs": [{ "name": "owner", "type": "address", "internalType": "address" }] + }, + { + "type": "error", + "name": "OwnableUnauthorizedAccount", + "inputs": [{ "name": "account", "type": "address", "internalType": "address" }] + }, + { "type": "error", "name": "RecipientZero", "inputs": [] }, + { "type": "error", "name": "ReentrancyGuardReentrantCall", "inputs": [] }, + { + "type": "error", + "name": "SafeCastOverflowedUintDowncast", + "inputs": [ + { "name": "bits", "type": "uint8", "internalType": "uint8" }, + { "name": "value", "type": "uint256", "internalType": "uint256" } + ] + }, + { + "type": "error", + "name": "SafeERC20FailedOperation", + "inputs": [{ "name": "token", "type": "address", "internalType": "address" }] + } + ] +} diff --git a/packages/shared/src/abi/MainnetBridgeOApp.json b/packages/shared/src/abi/MainnetBridgeOApp.json new file mode 100644 index 0000000..caf0f76 --- /dev/null +++ b/packages/shared/src/abi/MainnetBridgeOApp.json @@ -0,0 +1,291 @@ +{ + "abi": [ + { + "type": "constructor", + "inputs": [ + { "name": "_endpoint", "type": "address", "internalType": "address" }, + { "name": "_delegate", "type": "address", "internalType": "address" }, + { "name": "_owner", "type": "address", "internalType": "address" }, + { "name": "_token", "type": "address", "internalType": "address" }, + { "name": "_srcEid", "type": "uint32", "internalType": "uint32" }, + { "name": "_srcSender", "type": "bytes32", "internalType": "bytes32" } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "allowInitializePath", + "inputs": [ + { + "name": "origin", + "type": "tuple", + "internalType": "struct Origin", + "components": [ + { "name": "srcEid", "type": "uint32", "internalType": "uint32" }, + { "name": "sender", "type": "bytes32", "internalType": "bytes32" }, + { "name": "nonce", "type": "uint64", "internalType": "uint64" } + ] + } + ], + "outputs": [{ "name": "", "type": "bool", "internalType": "bool" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "clearMessage", + "inputs": [ + { + "name": "_origin", + "type": "tuple", + "internalType": "struct Origin", + "components": [ + { "name": "srcEid", "type": "uint32", "internalType": "uint32" }, + { "name": "sender", "type": "bytes32", "internalType": "bytes32" }, + { "name": "nonce", "type": "uint64", "internalType": "uint64" } + ] + }, + { "name": "_guid", "type": "bytes32", "internalType": "bytes32" }, + { "name": "_message", "type": "bytes", "internalType": "bytes" } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "endpoint", + "inputs": [], + "outputs": [ + { "name": "", "type": "address", "internalType": "contract ILayerZeroEndpointV2" } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "hasStoredPayload", + "inputs": [ + { "name": "srcEid", "type": "uint32", "internalType": "uint32" }, + { "name": "sender", "type": "bytes32", "internalType": "bytes32" }, + { "name": "nonce", "type": "uint64", "internalType": "uint64" }, + { "name": "guid", "type": "bytes32", "internalType": "bytes32" }, + { "name": "message", "type": "bytes", "internalType": "bytes" } + ], + "outputs": [{ "name": "", "type": "bool", "internalType": "bool" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isComposeMsgSender", + "inputs": [ + { + "name": "", + "type": "tuple", + "internalType": "struct Origin", + "components": [ + { "name": "srcEid", "type": "uint32", "internalType": "uint32" }, + { "name": "sender", "type": "bytes32", "internalType": "bytes32" }, + { "name": "nonce", "type": "uint64", "internalType": "uint64" } + ] + }, + { "name": "", "type": "bytes", "internalType": "bytes" }, + { "name": "_sender", "type": "address", "internalType": "address" } + ], + "outputs": [{ "name": "", "type": "bool", "internalType": "bool" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "lzReceive", + "inputs": [ + { + "name": "_origin", + "type": "tuple", + "internalType": "struct Origin", + "components": [ + { "name": "srcEid", "type": "uint32", "internalType": "uint32" }, + { "name": "sender", "type": "bytes32", "internalType": "bytes32" }, + { "name": "nonce", "type": "uint64", "internalType": "uint64" } + ] + }, + { "name": "_guid", "type": "bytes32", "internalType": "bytes32" }, + { "name": "_message", "type": "bytes", "internalType": "bytes" }, + { "name": "_executor", "type": "address", "internalType": "address" }, + { "name": "_extraData", "type": "bytes", "internalType": "bytes" } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "manualRetry", + "inputs": [ + { + "name": "_origin", + "type": "tuple", + "internalType": "struct Origin", + "components": [ + { "name": "srcEid", "type": "uint32", "internalType": "uint32" }, + { "name": "sender", "type": "bytes32", "internalType": "bytes32" }, + { "name": "nonce", "type": "uint64", "internalType": "uint64" } + ] + }, + { "name": "_guid", "type": "bytes32", "internalType": "bytes32" }, + { "name": "_message", "type": "bytes", "internalType": "bytes" }, + { "name": "_extraData", "type": "bytes", "internalType": "bytes" } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "nextNonce", + "inputs": [ + { "name": "", "type": "uint32", "internalType": "uint32" }, + { "name": "", "type": "bytes32", "internalType": "bytes32" } + ], + "outputs": [{ "name": "nonce", "type": "uint64", "internalType": "uint64" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "oAppVersion", + "inputs": [], + "outputs": [ + { "name": "senderVersion", "type": "uint64", "internalType": "uint64" }, + { "name": "receiverVersion", "type": "uint64", "internalType": "uint64" } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "owner", + "inputs": [], + "outputs": [{ "name": "", "type": "address", "internalType": "address" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "peers", + "inputs": [{ "name": "eid", "type": "uint32", "internalType": "uint32" }], + "outputs": [{ "name": "peer", "type": "bytes32", "internalType": "bytes32" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "renounceOwnership", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setDelegate", + "inputs": [{ "name": "_delegate", "type": "address", "internalType": "address" }], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setPeer", + "inputs": [ + { "name": "_eid", "type": "uint32", "internalType": "uint32" }, + { "name": "_peer", "type": "bytes32", "internalType": "bytes32" } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "transferOwnership", + "inputs": [{ "name": "newOwner", "type": "address", "internalType": "address" }], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "withdrawTokens", + "inputs": [ + { "name": "to", "type": "address", "internalType": "address" }, + { "name": "amount", "type": "uint256", "internalType": "uint256" } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "event", + "name": "BridgeFulfilled", + "inputs": [ + { "name": "srcUser", "type": "address", "indexed": true, "internalType": "address" }, + { "name": "recipient", "type": "address", "indexed": true, "internalType": "address" }, + { "name": "amount", "type": "uint256", "indexed": false, "internalType": "uint256" } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OwnershipTransferred", + "inputs": [ + { "name": "previousOwner", "type": "address", "indexed": true, "internalType": "address" }, + { "name": "newOwner", "type": "address", "indexed": true, "internalType": "address" } + ], + "anonymous": false + }, + { + "type": "event", + "name": "PeerSet", + "inputs": [ + { "name": "eid", "type": "uint32", "indexed": false, "internalType": "uint32" }, + { "name": "peer", "type": "bytes32", "indexed": false, "internalType": "bytes32" } + ], + "anonymous": false + }, + { + "type": "event", + "name": "TokensWithdrawn", + "inputs": [ + { "name": "to", "type": "address", "indexed": true, "internalType": "address" }, + { "name": "amount", "type": "uint256", "indexed": false, "internalType": "uint256" } + ], + "anonymous": false + }, + { "type": "error", "name": "BadSender", "inputs": [] }, + { "type": "error", "name": "BadSrcEid", "inputs": [] }, + { "type": "error", "name": "InvalidAddress", "inputs": [] }, + { "type": "error", "name": "InvalidAmount", "inputs": [] }, + { "type": "error", "name": "InvalidDelegate", "inputs": [] }, + { "type": "error", "name": "InvalidEndpointCall", "inputs": [] }, + { + "type": "error", + "name": "NoPeer", + "inputs": [{ "name": "eid", "type": "uint32", "internalType": "uint32" }] + }, + { + "type": "error", + "name": "OnlyEndpoint", + "inputs": [{ "name": "addr", "type": "address", "internalType": "address" }] + }, + { + "type": "error", + "name": "OnlyPeer", + "inputs": [ + { "name": "eid", "type": "uint32", "internalType": "uint32" }, + { "name": "sender", "type": "bytes32", "internalType": "bytes32" } + ] + }, + { + "type": "error", + "name": "OwnableInvalidOwner", + "inputs": [{ "name": "owner", "type": "address", "internalType": "address" }] + }, + { + "type": "error", + "name": "OwnableUnauthorizedAccount", + "inputs": [{ "name": "account", "type": "address", "internalType": "address" }] + }, + { "type": "error", "name": "RecipientZero", "inputs": [] }, + { + "type": "error", + "name": "SafeERC20FailedOperation", + "inputs": [{ "name": "token", "type": "address", "internalType": "address" }] + } + ] +} diff --git a/packages/shared/src/abi/index.ts b/packages/shared/src/abi/index.ts index c222c1d..e84507d 100644 --- a/packages/shared/src/abi/index.ts +++ b/packages/shared/src/abi/index.ts @@ -1,7 +1,9 @@ +export { abi as BaseBridgeOAppAbi } from "./BaseBridgeOApp.json"; export { abi as BlockBuilderRegistryAbi } from "./BlockBuilderRegistry.json"; export { abi as L1ScrollMessengerAbi } from "./L1ScrollMessenger.json"; export { abi as L2ScrollMessengerAbi } from "./L2ScrollMessenger.json"; export { abi as LiquidityAbi } from "./Liquidity.json"; +export { abi as MainnetBridgeOAppAbi } from "./MainnetBridgeOApp.json"; export { abi as MinterAbi } from "./Minter.json"; export { abi as MockL1ScrollMessengerAbi } from "./MockL1ScrollMessenger.json"; export { abi as RollupAbi } from "./Rollup.json"; diff --git a/packages/shared/src/blockchain/events.ts b/packages/shared/src/blockchain/events.ts index 94ac201..96afab4 100644 --- a/packages/shared/src/blockchain/events.ts +++ b/packages/shared/src/blockchain/events.ts @@ -31,6 +31,10 @@ export const transferredToLiquidityEvent = parseAbiItem( "event TransferredToLiquidity(uint256 amount)", ); +export const bridgeRequestedEvent = parseAbiItem( + "event BridgeRequested(address indexed recipient, uint256 amount, address indexed user, (bytes32 guid, uint64 nonce, (uint256 nativeFee, uint256 lzTokenFee) fee) receipt)", +); + export const getEventLogs = async ( client: PublicClient, address: `0x${string}`, diff --git a/packages/shared/src/config/index.ts b/packages/shared/src/config/index.ts index 2f7000c..125614e 100644 --- a/packages/shared/src/config/index.ts +++ b/packages/shared/src/config/index.ts @@ -1,6 +1,6 @@ import { bool, cleanEnv, json, num, str, url } from "envalid"; import { version } from "../../../../package.json"; -import { l1Chain, l2Chain, rpcUrls } from "./validator"; +import { l1Chain, l2Chain, layerZeroNetwork, rpcUrls } from "./validator"; export const config = cleanEnv(process.env, { // app @@ -59,6 +59,7 @@ export const config = cleanEnv(process.env, { default: 0.3, desc: "Rate at which the transaction wait time increases on each retry", }), + LAYER_ZERO_NETWORK: layerZeroNetwork({ default: "testnet" }), // mint MINT_AVAILABLE_FROM: str({ devDefault: "2025-06-23T00:00:00Z", @@ -77,6 +78,10 @@ export const config = cleanEnv(process.env, { ROLLUP_CONTRACT_ADDRESS: str({ devDefault: "0x" }), MINTER_CONTRACT_ADDRESS: str({ devDefault: "0x" }), MINTER_CONTRACT_DEPLOYED_BLOCK: num({ devDefault: 0 }), + MAINNET_BRIDGE_O_APP_CONTRACT_ADDRESS: str({ devDefault: "0x" }), + MAINNET_BRIDGE_O_APP_CONTRACT_DEPLOYED_BLOCK: num({ devDefault: 0 }), + BASE_BRIDGE_O_APP_CONTRACT_ADDRESS: str({ devDefault: "0x" }), + BASE_BRIDGE_O_APP_CONTRACT_DEPLOYED_BLOCK: num({ devDefault: 0 }), // messenger contract L1_SCROLL_MESSENGER_CONTRACT_ADDRESS: str({ devDefault: "0x" }), MOCK_L1_SCROLL_MESSENGER_CONTRACT_ADDRESS: str({ default: "0x" }), diff --git a/packages/shared/src/config/validator.ts b/packages/shared/src/config/validator.ts index 80b1e27..360f316 100644 --- a/packages/shared/src/config/validator.ts +++ b/packages/shared/src/config/validator.ts @@ -49,6 +49,13 @@ export const l1Chain = makeValidator<"mainnet" | "sepolia">((input) => { return input as "mainnet" | "sepolia"; }); +export const layerZeroNetwork = makeValidator<"mainnet" | "testnet">((input) => { + if (input !== "mainnet" && input !== "testnet") { + throw new Error("LAYER_ZERO_NETWORK must be either 'mainnet' or 'testnet'"); + } + return input as "mainnet" | "testnet"; +}); + export const l2Chain = makeValidator<"scroll" | "scrollSepolia">((input) => { if (input !== "scroll" && input !== "scrollSepolia") { throw new Error("L2_CHAIN must be either 'scroll' or 'scrollSepolia'"); diff --git a/packages/shared/src/constants/constants.ts b/packages/shared/src/constants/constants.ts index 2403633..e520336 100644 --- a/packages/shared/src/constants/constants.ts +++ b/packages/shared/src/constants/constants.ts @@ -18,6 +18,7 @@ export const FIRESTORE_COLLECTIONS = { TX_MAPS: "txMaps", TOKEN_MAPS: "tokenMaps", MINTER_EVENTS: "minterEvents", + BRIDGE_TRANSACTIONS: "bridgeTransactions", } as const; export const FIRESTORE_DOCUMENTS = { @@ -34,6 +35,7 @@ export const FIRESTORE_DOCUMENT_EVENTS = { MOCK_L2_SENT_MESSAGE: "mockL2SentMessage", BLOCK_BUILDER_HEART_BEAT: "blockBuilderHeartBeat", MINTER: "minter", + BRIDGE_REQUESTED: "bridgeRequested", } as const; export const FIRESTORE_MAX_BATCH_SIZE = 500; @@ -67,6 +69,14 @@ export const L1_SCROLL_MESSENGER_CONTRACT_ADDRESS = config.L1_SCROLL_MESSENGER_CONTRACT_ADDRESS as `0x${string}`; export const MINTER_CONTRACT_ADDRESS = config.MINTER_CONTRACT_ADDRESS as `0x${string}`; export const MINTER_CONTRACT_DEPLOYED_BLOCK = config.MINTER_CONTRACT_DEPLOYED_BLOCK; +export const BASE_BRIDGE_O_APP_CONTRACT_ADDRESS = + config.BASE_BRIDGE_O_APP_CONTRACT_ADDRESS as `0x${string}`; +export const BASE_BRIDGE_O_APP_CONTRACT_DEPLOYED_BLOCK = + config.BASE_BRIDGE_O_APP_CONTRACT_DEPLOYED_BLOCK; +export const MAINNET_BRIDGE_O_APP_CONTRACT_ADDRESS = + config.MAINNET_BRIDGE_O_APP_CONTRACT_ADDRESS as `0x${string}`; +export const MAINNET_BRIDGE_O_APP_CONTRACT_DEPLOYED_BLOCK = + config.MAINNET_BRIDGE_O_APP_CONTRACT_DEPLOYED_BLOCK; // mock export const MOCK_L1_SCROLL_MESSENGER_CONTRACT_ADDRESS = @@ -90,6 +100,7 @@ export const TRANSACTION_WAIT_TRANSACTION_TIMEOUT = config.TRANSACTION_WAIT_TRAN export const TRANSACTION_INCREMENT_RATE = config.TRANSACTION_INCREMENT_RATE; // block event +export const BLOCK_RANGE_TINY = 499n; export const BLOCK_RANGE_MINIMUM = 10000n; export const BLOCK_RANGE_NORMAL = 30000n; export const BLOCK_RANGE_MAX = 100000n; diff --git a/packages/shared/src/db/bridgeTransaction.ts b/packages/shared/src/db/bridgeTransaction.ts new file mode 100644 index 0000000..61bff76 --- /dev/null +++ b/packages/shared/src/db/bridgeTransaction.ts @@ -0,0 +1,155 @@ +import type { CollectionReference, Query } from "@google-cloud/firestore"; +import { FIRESTORE_COLLECTIONS, FIRESTORE_MAX_BATCH_SIZE } from "../constants"; +import { AppError, ErrorCode, logger } from "../lib"; +import { + type BridgeTransactionData, + type BridgeTransactionFilter, + type BridgeTransactionInput, + BridgeTransactionStatus, + type BridgeTransactionUpdateInput, +} from "../types"; +import { db } from "./firestore"; + +export class BridgeTransaction { + private static instance: BridgeTransaction | null = null; + private readonly db = db; + private readonly collection: CollectionReference; + protected readonly defaultOrderField = "__name__"; + protected readonly defaultOrderDirection = "asc"; + + constructor() { + this.collection = db.collection(FIRESTORE_COLLECTIONS.BRIDGE_TRANSACTIONS); + } + + public static getInstance() { + if (!BridgeTransaction.instance) { + BridgeTransaction.instance = new BridgeTransaction(); + } + return BridgeTransaction.instance; + } + + async saveBridgeTransactionsBatch(inputs: BridgeTransactionInput[]) { + const batches = []; + const now = new Date(); + + try { + for (let i = 0; i < inputs.length; i += FIRESTORE_MAX_BATCH_SIZE) { + const batch = this.db.batch(); + const batchInputs = inputs.slice(i, i + FIRESTORE_MAX_BATCH_SIZE); + + for (const { guid, ...rest } of batchInputs) { + const ref = this.collection.doc(guid); + + batch.set( + ref, + { + ...rest, + status: BridgeTransactionStatus.QUEUED, + updatedAt: now, + createdAt: now, + }, + { merge: false }, + ); + } + + batches.push(batch.commit()); + } + + await Promise.all(batches); + + return { + count: inputs.length, + }; + } catch (error) { + logger.error(error); + throw new AppError( + 500, + ErrorCode.INTERNAL_SERVER_ERROR, + `Failed to save tokenMaps: ${(error as Error).message}`, + ); + } + } + + async updateBridgeTransaction(guid: string, updateData: Partial) { + const now = new Date(); + try { + const { guid: _, ...dataToUpdate } = updateData as any; + const ref = this.collection.doc(guid); + + await ref.update({ + ...dataToUpdate, + updatedAt: now, + }); + + return { + guid, + success: true, + }; + } catch (error) { + logger.error(error); + throw new AppError( + 500, + ErrorCode.INTERNAL_SERVER_ERROR, + `Failed to update bridge transaction ${guid}: ${(error as Error).message}`, + ); + } + } + + private async list(buildQuery?: (query: Query) => Query) { + try { + let query = this.collection.orderBy( + this.defaultOrderField as string, + this.defaultOrderDirection, + ); + + if (buildQuery) { + query = buildQuery(query); + } + + const allItems = []; + let lastDoc: FirebaseFirestore.DocumentSnapshot | null = null; + + do { + let batchQuery = query.limit(FIRESTORE_MAX_BATCH_SIZE); + if (lastDoc) { + batchQuery = batchQuery.startAfter(lastDoc); + } + + const snapshot = await batchQuery.get(); + const batchItems = snapshot.docs.map((doc) => { + return { ...doc.data(), guid: doc.id } as BridgeTransactionData; + }); + + allItems.push(...batchItems); + if (snapshot.size < FIRESTORE_MAX_BATCH_SIZE) { + lastDoc = null; + } else { + lastDoc = snapshot.docs[snapshot.docs.length - 1]; + } + } while (lastDoc); + + return allItems; + } catch (error) { + logger.error(error); + throw new AppError( + 500, + ErrorCode.INTERNAL_SERVER_ERROR, + `Failed to list ${(error as Error).message}`, + ); + } + } + + async fetchBridgeTransactions(filter?: BridgeTransactionFilter) { + return this.list((query) => { + let modified = query; + if (filter?.statuses && filter.statuses.length > 0) { + modified = modified.where("status", "in", filter.statuses); + } + return modified; + }); + } + + async fetchAllBridgeTransactions() { + return this.list(); + } +} diff --git a/packages/shared/src/db/index.ts b/packages/shared/src/db/index.ts index 87aec9b..905aed2 100644 --- a/packages/shared/src/db/index.ts +++ b/packages/shared/src/db/index.ts @@ -1,4 +1,5 @@ export * from "./baseIndexer"; +export * from "./bridgeTransaction"; export * from "./event"; export * from "./indexer"; export * from "./mintEvent"; diff --git a/packages/shared/src/types/blockchain.ts b/packages/shared/src/types/blockchain.ts index 31a98f3..3cb31fc 100644 --- a/packages/shared/src/types/blockchain.ts +++ b/packages/shared/src/types/blockchain.ts @@ -87,4 +87,22 @@ export interface TransferredToLiquidityEventLog { amount: bigint; } +export interface BridgeRequestedEvent extends BaseEvent { + args: BridgeRequestedEventLog; +} + +export interface BridgeRequestedEventLog { + recipient: string; + amount: bigint; + user: string; + receipt: { + guid: string; + nonce: bigint; + fee: { + nativeFee: bigint; + lzTokenFee: bigint; + }; + }; +} + export type NetworkLayer = "l1" | "l2"; diff --git a/packages/shared/src/types/bridgeTransaction.ts b/packages/shared/src/types/bridgeTransaction.ts new file mode 100644 index 0000000..89d7d96 --- /dev/null +++ b/packages/shared/src/types/bridgeTransaction.ts @@ -0,0 +1,34 @@ +export interface BridgeTransactionData { + guid: string; + status: BridgeTransactionStatus; + nonce: number; + verifiedAt?: FirebaseFirestore.Timestamp; + updatedAt: FirebaseFirestore.Timestamp; + createdAt: FirebaseFirestore.Timestamp; +} + +export interface BridgeTransactionInput { + guid: string; + amount: string; + recipient: string; + transactionHash: string; +} + +export interface BridgeTransactionUpdateInput { + status: BridgeTransactionStatus; +} + +export interface BridgeTransactionFilter { + statuses: BridgeTransactionStatus[]; +} + +export enum BridgeTransactionStatus { + QUEUED = "QUEUED", + INFLIGHT = "INFLIGHT", + CONFIRMING = "CONFIRMING", + VERIFIED = "VERIFIED", + DELIVERED = "DELIVERED", + FAILED = "FAILED", + PAYLOAD_STORED = "PAYLOAD_STORED", + BLOCKED = "BLOCKED", +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index b43cfe1..1edcb61 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -1,4 +1,5 @@ export * from "./blockchain"; +export * from "./bridgeTransaction"; export * from "./event"; export * from "./indexer"; export * from "./mintEvent"; diff --git a/yarn.lock b/yarn.lock index 6d73355..f677d3b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1042,6 +1042,15 @@ __metadata: languageName: node linkType: hard +"@noble/curves@npm:1.9.6": + version: 1.9.6 + resolution: "@noble/curves@npm:1.9.6" + dependencies: + "@noble/hashes": "npm:1.8.0" + checksum: 10c0/e462875ad752d2cdffc3c7b27b6de3adcff5fae0731e94138bd9e452c5f9b7aaf4c01ea6c62d3c0544b4e7419662535bb2ef1103311de48d51885c053206e118 + languageName: node + linkType: hard + "@noble/curves@npm:^1.4.2, @noble/curves@npm:~1.9.0": version: 1.9.1 resolution: "@noble/curves@npm:1.9.1" @@ -1981,6 +1990,30 @@ __metadata: languageName: node linkType: hard +"bridge-event-watcher@workspace:packages/bridge-event-watcher": + version: 0.0.0-use.local + resolution: "bridge-event-watcher@workspace:packages/bridge-event-watcher" + dependencies: + "@intmax2-function/shared": "workspace:*" + tsx: "npm:^4.20.4" + typescript: "npm:^5.9.2" + vitest: "npm:^3.2.4" + languageName: unknown + linkType: soft + +"bridge-monitor@workspace:packages/bridge-monitor": + version: 0.0.0-use.local + resolution: "bridge-monitor@workspace:packages/bridge-monitor" + dependencies: + "@intmax2-function/shared": "workspace:*" + axios: "npm:^1.11.0" + tsx: "npm:^4.20.4" + typescript: "npm:^5.9.2" + viem: "npm:^2.34.0" + vitest: "npm:^3.2.4" + languageName: unknown + linkType: soft + "brorand@npm:^1.1.0": version: 1.1.0 resolution: "brorand@npm:1.1.0" @@ -3947,6 +3980,27 @@ __metadata: languageName: node linkType: hard +"ox@npm:0.8.7": + version: 0.8.7 + resolution: "ox@npm:0.8.7" + dependencies: + "@adraffy/ens-normalize": "npm:^1.11.0" + "@noble/ciphers": "npm:^1.3.0" + "@noble/curves": "npm:^1.9.1" + "@noble/hashes": "npm:^1.8.0" + "@scure/bip32": "npm:^1.7.0" + "@scure/bip39": "npm:^1.6.0" + abitype: "npm:^1.0.8" + eventemitter3: "npm:5.0.1" + peerDependencies: + typescript: ">=5.4.0" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/a0919651e35b60d741f745ef6a0cfa1f63daf17d43e95cae7957c291eba90248be0968ee0a6d2f268e5e79f6dbbc3ab3b4617df0bcadcae722843168639795e1 + languageName: node + linkType: hard + "p-limit@npm:^3.0.1": version: 3.1.0 resolution: "p-limit@npm:3.1.0" @@ -4993,6 +5047,27 @@ __metadata: languageName: node linkType: hard +"viem@npm:^2.34.0": + version: 2.34.0 + resolution: "viem@npm:2.34.0" + dependencies: + "@noble/curves": "npm:1.9.6" + "@noble/hashes": "npm:1.8.0" + "@scure/bip32": "npm:1.7.0" + "@scure/bip39": "npm:1.6.0" + abitype: "npm:1.0.8" + isows: "npm:1.0.7" + ox: "npm:0.8.7" + ws: "npm:8.18.3" + peerDependencies: + typescript: ">=5.0.4" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/e2cd2a672d1912db63e8e0a7e95d0ce551e30ba4c4fa1fde042b23cde590eed7144fcdfce88777bfcbb8503c5f45a7b8d439e82853bc2ef746e408bf1394c4a3 + languageName: node + linkType: hard + "vite-node@npm:3.2.4": version: 3.2.4 resolution: "vite-node@npm:3.2.4" @@ -5269,6 +5344,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:8.18.3": + version: 8.18.3 + resolution: "ws@npm:8.18.3" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/eac918213de265ef7cb3d4ca348b891a51a520d839aa51cdb8ca93d4fa7ff9f6ccb339ccee89e4075324097f0a55157c89fa3f7147bde9d8d7e90335dc087b53 + languageName: node + linkType: hard + "ws@npm:^7.5.10": version: 7.5.10 resolution: "ws@npm:7.5.10"