From 654f8795b45f6a14f5e4808e71f2d6143ef46171 Mon Sep 17 00:00:00 2001 From: gregfromstl Date: Thu, 20 Mar 2025 21:27:26 +0000 Subject: [PATCH] [SDK] Feature: Adds Universal Bridge (#6464) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ## PR-Codex overview This PR introduces a new `Bridge` module to the `thirdweb` SDK, enabling users to perform token buy and sell operations across chains, along with route discovery and transaction status checks. ### Detailed summary - Added `Bridge` module for Universal Bridge operations. - Implemented `Bridge.Buy` and `Bridge.Sell` for token transactions. - Created `quote` and `prepare` functions for both buy and sell operations. - Introduced `routes` function to discover available bridge routes. - Added `status` function to check transaction statuses. - Defined TypeScript types: `Route`, `Status`, `Quote`, `PreparedQuote`. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` --- .changeset/clear-olives-know.md | 177 ++++++++++++ packages/thirdweb/package.json | 82 ++---- packages/thirdweb/src/bridge/Buy.test.ts | 74 +++++ packages/thirdweb/src/bridge/Buy.ts | 267 +++++++++++++++++++ packages/thirdweb/src/bridge/Routes.test.ts | 161 +++++++++++ packages/thirdweb/src/bridge/Routes.ts | 160 +++++++++++ packages/thirdweb/src/bridge/Sell.test.ts | 74 +++++ packages/thirdweb/src/bridge/Sell.ts | 267 +++++++++++++++++++ packages/thirdweb/src/bridge/Status.test.ts | 38 +++ packages/thirdweb/src/bridge/Status.ts | 163 +++++++++++ packages/thirdweb/src/bridge/constants.ts | 1 + packages/thirdweb/src/bridge/index.ts | 8 + packages/thirdweb/src/bridge/types/Quote.ts | 42 +++ packages/thirdweb/src/bridge/types/Route.ts | 20 ++ packages/thirdweb/src/bridge/types/Status.ts | 39 +++ packages/thirdweb/src/exports/bridge.ts | 1 + packages/thirdweb/src/exports/thirdweb.ts | 5 + packages/thirdweb/tsdoc.json | 5 + 18 files changed, 1527 insertions(+), 57 deletions(-) create mode 100644 .changeset/clear-olives-know.md create mode 100644 packages/thirdweb/src/bridge/Buy.test.ts create mode 100644 packages/thirdweb/src/bridge/Buy.ts create mode 100644 packages/thirdweb/src/bridge/Routes.test.ts create mode 100644 packages/thirdweb/src/bridge/Routes.ts create mode 100644 packages/thirdweb/src/bridge/Sell.test.ts create mode 100644 packages/thirdweb/src/bridge/Sell.ts create mode 100644 packages/thirdweb/src/bridge/Status.test.ts create mode 100644 packages/thirdweb/src/bridge/Status.ts create mode 100644 packages/thirdweb/src/bridge/constants.ts create mode 100644 packages/thirdweb/src/bridge/index.ts create mode 100644 packages/thirdweb/src/bridge/types/Quote.ts create mode 100644 packages/thirdweb/src/bridge/types/Route.ts create mode 100644 packages/thirdweb/src/bridge/types/Status.ts create mode 100644 packages/thirdweb/src/exports/bridge.ts diff --git a/.changeset/clear-olives-know.md b/.changeset/clear-olives-know.md new file mode 100644 index 00000000000..5deb371c425 --- /dev/null +++ b/.changeset/clear-olives-know.md @@ -0,0 +1,177 @@ +--- +"thirdweb": minor +--- + +Adds a new `Bridge` module to the thirdweb SDK to access the Universal Bridge. + +## Features + +### Buy & Sell Operations + +The Bridge module makes it easy to buy and sell tokens across chains: + +- `Bridge.Buy` - For specifying the destination amount you want to receive +- `Bridge.Sell` - For specifying the origin amount you want to send + +Each operation provides two functions: +1. `quote` - Get an estimate without connecting a wallet +2. `prepare` - Get a finalized quote with transaction data + +#### Buy Example + +```typescript +import { Bridge, toWei, NATIVE_TOKEN_ADDRESS } from "thirdweb"; + +// First, get a quote to see approximately how much you'll pay +const buyQuote = await Bridge.Buy.quote({ + originChainId: 1, // Ethereum + originTokenAddress: NATIVE_TOKEN_ADDRESS, + destinationChainId: 10, // Optimism + destinationTokenAddress: NATIVE_TOKEN_ADDRESS, + buyAmountWei: toWei("0.01"), // I want to receive 0.01 ETH on Optimism + client: thirdwebClient, +}); + +console.log(`To get ${buyQuote.destinationAmount} wei on destination chain, you need to pay ${buyQuote.originAmount} wei`); + +// When ready to execute, prepare the transaction +const preparedBuy = await Bridge.Buy.prepare({ + originChainId: 1, + originTokenAddress: NATIVE_TOKEN_ADDRESS, + destinationChainId: 10, + destinationTokenAddress: NATIVE_TOKEN_ADDRESS, + buyAmountWei: toWei("0.01"), + sender: "0x...", // Your wallet address + receiver: "0x...", // Recipient address (can be the same as sender) + client: thirdwebClient, +}); + +// The prepared quote contains the transactions you need to execute +console.log(`Transactions to execute: ${preparedBuy.transactions.length}`); +``` + +#### Sell Example + +```typescript +import { Bridge, toWei } from "thirdweb"; + +// First, get a quote to see approximately how much you'll receive +const sellQuote = await Bridge.Sell.quote({ + originChainId: 1, // Ethereum + originTokenAddress: NATIVE_TOKEN_ADDRESS, + destinationChainId: 10, // Optimism + destinationTokenAddress: NATIVE_TOKEN_ADDRESS, + sellAmountWei: toWei("0.01"), // I want to sell 0.01 ETH from Ethereum + client: thirdwebClient, +}); + +console.log(`If you send ${sellQuote.originAmount} wei, you'll receive approximately ${sellQuote.destinationAmount} wei`); + +// When ready to execute, prepare the transaction +const preparedSell = await Bridge.Sell.prepare({ + originChainId: 1, + originTokenAddress: NATIVE_TOKEN_ADDRESS, + destinationChainId: 10, + destinationTokenAddress: NATIVE_TOKEN_ADDRESS, + sellAmountWei: toWei("0.01"), + sender: "0x...", // Your wallet address + receiver: "0x...", // Recipient address (can be the same as sender) + client: thirdwebClient, +}); + +// Execute the transactions in sequence +for (const tx of preparedSell.transactions) { + // Send the transaction using your wallet + // Wait for it to be mined +} +``` + +### Bridge Routes + +You can discover available bridge routes using the `routes` function: + +```typescript +import { Bridge, NATIVE_TOKEN_ADDRESS } from "thirdweb"; + +// Get all available routes +const allRoutes = await Bridge.routes({ + client: thirdwebClient, +}); + +// Filter routes for a specific token or chain +const filteredRoutes = await Bridge.routes({ + originChainId: 1, // From Ethereum + originTokenAddress: NATIVE_TOKEN_ADDRESS, + destinationChainId: 10, // To Optimism + client: thirdwebClient, +}); + +// Paginate through routes +const paginatedRoutes = await Bridge.routes({ + limit: 10, + offset: 0, + client: thirdwebClient, +}); +``` + +### Bridge Transaction Status + +After executing bridge transactions, you can check their status: + +```typescript +import { Bridge } from "thirdweb"; + +// Check the status of a bridge transaction +const bridgeStatus = await Bridge.status({ + transactionHash: "0xe199ef82a0b6215221536e18ec512813c1aa10b4f5ed0d4dfdfcd703578da56d", + chainId: 8453, // The chain ID where the transaction was initiated + client: thirdwebClient, +}); + +// The status will be one of: "COMPLETED", "PENDING", "FAILED", or "NOT_FOUND" +if (bridgeStatus.status === "completed") { + console.log(` + Bridge completed! + Sent: ${bridgeStatus.originAmount} wei on chain ${bridgeStatus.originChainId} + Received: ${bridgeStatus.destinationAmount} wei on chain ${bridgeStatus.destinationChainId} + `); +} else if (bridgeStatus.status === "pending") { + console.log("Bridge transaction is still pending..."); +} else { + console.log("Bridge transaction failed"); +} +``` + +## Error Handling + +The Bridge module provides consistent error handling with descriptive error messages: + +```typescript +try { + await Bridge.Buy.quote({ + // ...params + }); +} catch (error) { + // Errors will have the format: "ErrorCode | Error message details" + console.error(error.message); // e.g. "AmountTooHigh | The provided amount is too high for the requested route." +} +``` + +## Types + +The Bridge module exports the following TypeScript types: + +- `Route` - Describes a bridge route between chains and tokens +- `Status` - Represents the status of a bridge transaction +- `Quote` - Contains quote information for a bridge transaction +- `PreparedQuote` - Extends Quote with transaction data + +## Integration + +The Bridge module is accessible as a top-level export: + +```typescript +import { Bridge } from "thirdweb"; +``` + +Use `Bridge.Buy`, `Bridge.Sell`, `Bridge.routes`, and `Bridge.status` to access the corresponding functionality. diff --git a/packages/thirdweb/package.json b/packages/thirdweb/package.json index 0d85d757f66..006cbf99632 100644 --- a/packages/thirdweb/package.json +++ b/packages/thirdweb/package.json @@ -128,67 +128,35 @@ "import": "./dist/esm/exports/ai.js", "default": "./dist/cjs/exports/ai.js" }, + "./bridge": { + "types": "./dist/types/exports/bridge.d.ts", + "import": "./dist/esm/exports/bridge.js", + "default": "./dist/cjs/exports/bridge.js" + }, "./package.json": "./package.json" }, "typesVersions": { "*": { - "adapters/*": [ - "./dist/types/exports/adapters/*.d.ts" - ], - "auth": [ - "./dist/types/exports/auth.d.ts" - ], - "chains": [ - "./dist/types/exports/chains.d.ts" - ], - "contract": [ - "./dist/types/exports/contract.d.ts" - ], - "deploys": [ - "./dist/types/exports/deploys.d.ts" - ], - "event": [ - "./dist/types/exports/event.d.ts" - ], - "extensions/*": [ - "./dist/types/exports/extensions/*.d.ts" - ], - "pay": [ - "./dist/types/exports/pay.d.ts" - ], - "react": [ - "./dist/types/exports/react.d.ts" - ], - "react-native": [ - "./dist/types/exports/react-native.d.ts" - ], - "rpc": [ - "./dist/types/exports/rpc.d.ts" - ], - "storage": [ - "./dist/types/exports/storage.d.ts" - ], - "transaction": [ - "./dist/types/exports/transaction.d.ts" - ], - "utils": [ - "./dist/types/exports/utils.d.ts" - ], - "wallets": [ - "./dist/types/exports/wallets.d.ts" - ], - "wallets/*": [ - "./dist/types/exports/wallets/*.d.ts" - ], - "modules": [ - "./dist/types/exports/modules.d.ts" - ], - "social": [ - "./dist/types/exports/social.d.ts" - ], - "ai": [ - "./dist/types/exports/ai.d.ts" - ] + "adapters/*": ["./dist/types/exports/adapters/*.d.ts"], + "auth": ["./dist/types/exports/auth.d.ts"], + "chains": ["./dist/types/exports/chains.d.ts"], + "contract": ["./dist/types/exports/contract.d.ts"], + "deploys": ["./dist/types/exports/deploys.d.ts"], + "event": ["./dist/types/exports/event.d.ts"], + "extensions/*": ["./dist/types/exports/extensions/*.d.ts"], + "pay": ["./dist/types/exports/pay.d.ts"], + "react": ["./dist/types/exports/react.d.ts"], + "react-native": ["./dist/types/exports/react-native.d.ts"], + "rpc": ["./dist/types/exports/rpc.d.ts"], + "storage": ["./dist/types/exports/storage.d.ts"], + "transaction": ["./dist/types/exports/transaction.d.ts"], + "utils": ["./dist/types/exports/utils.d.ts"], + "wallets": ["./dist/types/exports/wallets.d.ts"], + "wallets/*": ["./dist/types/exports/wallets/*.d.ts"], + "modules": ["./dist/types/exports/modules.d.ts"], + "social": ["./dist/types/exports/social.d.ts"], + "ai": ["./dist/types/exports/ai.d.ts"], + "bridge": ["./dist/types/exports/bridge.d.ts"] } }, "browser": { diff --git a/packages/thirdweb/src/bridge/Buy.test.ts b/packages/thirdweb/src/bridge/Buy.test.ts new file mode 100644 index 00000000000..1c337c621b7 --- /dev/null +++ b/packages/thirdweb/src/bridge/Buy.test.ts @@ -0,0 +1,74 @@ +import { toWei } from "src/utils/units.js"; +import { describe, expect, it } from "vitest"; +import { TEST_CLIENT } from "~test/test-clients.js"; +import * as Buy from "./Buy.js"; + +describe("Bridge.Buy.quote", () => { + it("should get a valid quote", async () => { + const quote = await Buy.quote({ + originChainId: 1, + originTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + destinationChainId: 10, + destinationTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + buyAmountWei: toWei("0.01"), + client: TEST_CLIENT, + }); + + expect(quote).toBeDefined(); + expect(quote.destinationAmount).toEqual(toWei("0.01")); + expect(quote.intent).toBeDefined(); + }); + + it("should surface any errors", async () => { + await expect( + Buy.quote({ + originChainId: 1, + originTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + destinationChainId: 10, + destinationTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + buyAmountWei: toWei("1000000000"), + client: TEST_CLIENT, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: AMOUNT_TOO_HIGH | The provided amount is too high for the requested route.]`, + ); + }); +}); + +describe("Bridge.Buy.prepare", () => { + it("should get a valid prepared quote", async () => { + const quote = await Buy.prepare({ + originChainId: 1, + originTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + destinationChainId: 10, + destinationTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + buyAmountWei: toWei("0.01"), + sender: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + receiver: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + client: TEST_CLIENT, + }); + + expect(quote).toBeDefined(); + expect(quote.destinationAmount).toEqual(toWei("0.01")); + expect(quote.transactions).toBeDefined(); + expect(quote.transactions.length).toBeGreaterThan(0); + expect(quote.intent).toBeDefined(); + }); + + it("should surface any errors", async () => { + await expect( + Buy.prepare({ + originChainId: 1, + originTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + destinationChainId: 10, + destinationTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + buyAmountWei: toWei("1000000000"), + sender: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + receiver: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + client: TEST_CLIENT, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: AMOUNT_TOO_HIGH | The provided amount is too high for the requested route.]`, + ); + }); +}); diff --git a/packages/thirdweb/src/bridge/Buy.ts b/packages/thirdweb/src/bridge/Buy.ts new file mode 100644 index 00000000000..c95b6b3f7e3 --- /dev/null +++ b/packages/thirdweb/src/bridge/Buy.ts @@ -0,0 +1,267 @@ +import type { Address as ox__Address } from "ox"; +import type { ThirdwebClient } from "../client/client.js"; +import { getClientFetch } from "../utils/fetch.js"; +import { UNIVERSAL_BRIDGE_URL } from "./constants.js"; +import type { PreparedQuote, Quote } from "./types/Quote.js"; + +/** + * Retrieves a Universal Bridge quote for the provided buy intent. The quote will specify the necessary `originAmount` to receive the desired `destinationAmount`, which is specified with the `buyAmountWei` option. + * + * @example + * ```typescript + * import { Bridge, NATIVE_TOKEN_ADDRESS } from "thirdweb"; + * + * const quote = await Bridge.Buy.quote({ + * originChainId: 1, + * originTokenAddress: NATIVE_TOKEN_ADDRESS, + * destinationChainId: 10, + * destinationTokenAddress: NATIVE_TOKEN_ADDRESS, + * buyAmountWei: toWei("0.01"), + * client: thirdwebClient, + * }); + * ``` + * + * This will return a quote that might look like: + * ```typescript + * { + * originAmount: 10000026098875381n, + * destinationAmount: 1000000000000000000n, + * blockNumber: 22026509n, + * timestamp: 1741730936680, + * estimatedExecutionTimeMs: 1000 + * intent: { + * originChainId: 1, + * originTokenAddress: NATIVE_TOKEN_ADDRESS, + * destinationChainId: 10, + * destinationTokenAddress: NATIVE_TOKEN_ADDRESS, + * buyAmountWei: 1000000000000000000n + * } + * } + * ``` + * + * The quote is an **estimate** for how much you would expect to pay for a specific buy. This quote is not guaranteed and you should use `Buy.prepare` to get a finalized quote with transaction data ready for execution. + * So why use `quote`? The quote function is sometimes slightly faster than `prepare`, and can be used before the user connects their wallet. + * + * You can access this functions input and output types with `Buy.quote.Options` and `Buy.quote.Result`, respectively. + * + * @param options - The options for the quote. + * @param options.originChainId - The chain ID of the origin token. + * @param options.originTokenAddress - The address of the origin token. + * @param options.destinationChainId - The chain ID of the destination token. + * @param options.destinationTokenAddress - The address of the destination token. + * @param options.buyAmountWei - The amount of the origin token to buy. + * @param options.client - Your thirdweb client. + * + * @returns A promise that resolves to a non-finalized quote for the requested buy. + * + * @throws Will throw an error if there is an issue fetching the quote. + * @bridge + * @beta + */ +export async function quote(options: quote.Options): Promise { + const { + originChainId, + originTokenAddress, + destinationChainId, + destinationTokenAddress, + buyAmountWei, + client, + } = options; + + const clientFetch = getClientFetch(client); + const url = new URL(`${UNIVERSAL_BRIDGE_URL}/buy/quote`); + url.searchParams.set("originChainId", originChainId.toString()); + url.searchParams.set("originTokenAddress", originTokenAddress); + url.searchParams.set("destinationChainId", destinationChainId.toString()); + url.searchParams.set("destinationTokenAddress", destinationTokenAddress); + url.searchParams.set("buyAmountWei", buyAmountWei.toString()); + + const response = await clientFetch(url.toString()); + if (!response.ok) { + const errorJson = await response.json(); + throw new Error(`${errorJson.code} | ${errorJson.message}`); + } + + const { data }: { data: Quote } = await response.json(); + return { + originAmount: BigInt(data.originAmount), + destinationAmount: BigInt(data.destinationAmount), + blockNumber: data.blockNumber ? BigInt(data.blockNumber) : undefined, + timestamp: data.timestamp, + estimatedExecutionTimeMs: data.estimatedExecutionTimeMs, + intent: { + originChainId, + originTokenAddress, + destinationChainId, + destinationTokenAddress, + buyAmountWei, + }, + }; +} + +export declare namespace quote { + type Options = { + originChainId: number; + originTokenAddress: ox__Address.Address; + destinationChainId: number; + destinationTokenAddress: ox__Address.Address; + buyAmountWei: bigint; + client: ThirdwebClient; + }; + + type Result = Quote & { + intent: { + originChainId: number; + originTokenAddress: ox__Address.Address; + destinationChainId: number; + destinationTokenAddress: ox__Address.Address; + buyAmountWei: bigint; + }; + }; +} + +/** + * Prepares a **finalized** Universal Bridge quote for the provided buy request with transaction data. This function will return everything `quote` does, with the addition of a series of prepared transactions and the associated expiration timestamp. + * + * @example + * ```typescript + * import { Bridge, NATIVE_TOKEN_ADDRESS } from "thirdweb"; + * + * const quote = await Bridge.Buy.prepare({ + * originChainId: 1, + * originTokenAddress: NATIVE_TOKEN_ADDRESS, + * destinationChainId: 10, + * destinationTokenAddress: NATIVE_TOKEN_ADDRESS, + * buyAmountWei: toWei("0.01"), + * client: thirdwebClient, + * }); + * ``` + * + * This will return a quote that might look like: + * ```typescript + * { + * originAmount: 10000026098875381n, + * destinationAmount: 1000000000000000000n, + * blockNumber: 22026509n, + * timestamp: 1741730936680, + * estimatedExecutionTimeMs: 1000 + * transactions: [ + * { + * to: NATIVE_TOKEN_ADDRESS, + * value: 10000026098875381n, + * data: "0x", + * chainId: 10, + * type: "eip1559" + * } + * ], + * expiration: 1741730936680, + * intent: { + * originChainId: 1, + * originTokenAddress: NATIVE_TOKEN_ADDRESS, + * destinationChainId: 10, + * destinationTokenAddress: NATIVE_TOKEN_ADDRESS, + * buyAmountWei: 1000000000000000000n + * } + * } + * ``` + * + * ## Sending the transactions + * The `transactions` array is a series of [ox](https://oxlib.sh) EIP-1559 transactions that must be executed one after the other in order to fulfill the complete route. There are a few things to keep in mind when executing these transactions: + * - Approvals and other preparation transactions are not included in the transactions array. + * - All transactions are assumed to be executed by the `sender` address, regardless of which chain they are on. The final transaction will use the `receiver` as the recipient address. + * - If an `expiration` timestamp is provided, all transactions must be executed before that time to guarantee successful execution at the specified price. + * + * NOTE: To get the status of each transaction, use `Bridge.status` rather than checking for transaction inclusion. This function will ensure full bridge completion on the destination chain. + * + * You can access this functions input and output types with `Buy.prepare.Options` and `Buy.prepare.Result`, respectively. + * + * @param options - The options for the quote. + * @param options.originChainId - The chain ID of the origin token. + * @param options.originTokenAddress - The address of the origin token. + * @param options.destinationChainId - The chain ID of the destination token. + * @param options.destinationTokenAddress - The address of the destination token. + * @param options.buyAmountWei - The amount of the origin token to buy. + * @param options.sender - The address of the sender. + * @param options.receiver - The address of the recipient. + * @param options.client - Your thirdweb client. + * + * @returns A promise that resolves to a non-finalized quote for the requested buy. + * + * @throws Will throw an error if there is an issue fetching the quote. + * @bridge + * @beta + */ +export async function prepare( + options: prepare.Options, +): Promise { + const { + originChainId, + originTokenAddress, + destinationChainId, + destinationTokenAddress, + buyAmountWei, + sender, + receiver, + client, + } = options; + + const clientFetch = getClientFetch(client); + const url = new URL(`${UNIVERSAL_BRIDGE_URL}/buy/prepare`); + url.searchParams.set("originChainId", originChainId.toString()); + url.searchParams.set("originTokenAddress", originTokenAddress); + url.searchParams.set("destinationChainId", destinationChainId.toString()); + url.searchParams.set("destinationTokenAddress", destinationTokenAddress); + url.searchParams.set("buyAmountWei", buyAmountWei.toString()); + url.searchParams.set("sender", sender); + url.searchParams.set("receiver", receiver); + + const response = await clientFetch(url.toString()); + if (!response.ok) { + const errorJson = await response.json(); + throw new Error(`${errorJson.code} | ${errorJson.message}`); + } + + const { data }: { data: PreparedQuote } = await response.json(); + return { + originAmount: BigInt(data.originAmount), + destinationAmount: BigInt(data.destinationAmount), + blockNumber: data.blockNumber ? BigInt(data.blockNumber) : undefined, + timestamp: data.timestamp, + estimatedExecutionTimeMs: data.estimatedExecutionTimeMs, + transactions: data.transactions.map((transaction) => ({ + ...transaction, + value: transaction.value ? BigInt(transaction.value) : undefined, + })), + expiration: data.expiration, + intent: { + originChainId, + originTokenAddress, + destinationChainId, + destinationTokenAddress, + buyAmountWei, + }, + }; +} + +export declare namespace prepare { + type Options = { + originChainId: number; + originTokenAddress: ox__Address.Address; + destinationChainId: number; + destinationTokenAddress: ox__Address.Address; + buyAmountWei: bigint; + sender: ox__Address.Address; + receiver: ox__Address.Address; + client: ThirdwebClient; + }; + + type Result = PreparedQuote & { + intent: { + originChainId: number; + originTokenAddress: ox__Address.Address; + destinationChainId: number; + destinationTokenAddress: ox__Address.Address; + buyAmountWei: bigint; + }; + }; +} diff --git a/packages/thirdweb/src/bridge/Routes.test.ts b/packages/thirdweb/src/bridge/Routes.test.ts new file mode 100644 index 00000000000..bd43c3b60c5 --- /dev/null +++ b/packages/thirdweb/src/bridge/Routes.test.ts @@ -0,0 +1,161 @@ +import { http, passthrough } from "msw"; +import { setupServer } from "msw/node"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { TEST_CLIENT } from "~test/test-clients.js"; +import { routes } from "./Routes.js"; + +const server = setupServer( + http.get("https://bridge.thirdweb.com/v1/routes", () => { + passthrough(); + }), +); + +describe("Bridge.routes", () => { + beforeAll(() => server.listen()); + afterEach(() => server.resetHandlers()); + afterAll(() => server.close()); + + it("should get a valid list of routes", async () => { + const allRoutes = await routes({ + client: TEST_CLIENT, + }); + + expect(allRoutes).toBeDefined(); + expect(Array.isArray(allRoutes)).toBe(true); + }); + + it("should filter routes by origin chain", async () => { + const filteredRoutes = await routes({ + client: TEST_CLIENT, + originChainId: 1, + }); + + expect(filteredRoutes).toBeDefined(); + expect(Array.isArray(filteredRoutes)).toBe(true); + expect( + filteredRoutes.every((route) => route.originToken.chainId === 1), + ).toBe(true); + }); + + it("should filter routes by destination chain", async () => { + const filteredRoutes = await routes({ + client: TEST_CLIENT, + destinationChainId: 1, + }); + + expect(filteredRoutes).toBeDefined(); + expect(Array.isArray(filteredRoutes)).toBe(true); + expect( + filteredRoutes.every((route) => route.destinationToken.chainId === 1), + ).toBe(true); + }); + + it("should filter routes by origin token", async () => { + const filteredRoutes = await routes({ + client: TEST_CLIENT, + originTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + }); + + expect(filteredRoutes).toBeDefined(); + expect(Array.isArray(filteredRoutes)).toBe(true); + expect( + filteredRoutes.every( + (route) => + route.originToken.address === + "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + ), + ).toBe(true); + }); + + it("should filter routes by destination token", async () => { + const filteredRoutes = await routes({ + client: TEST_CLIENT, + destinationTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + }); + + expect(filteredRoutes).toBeDefined(); + expect(Array.isArray(filteredRoutes)).toBe(true); + expect( + filteredRoutes.every( + (route) => + route.destinationToken.address === + "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + ), + ).toBe(true); + }); + + it("should combine filters", async () => { + const filteredRoutes = await routes({ + client: TEST_CLIENT, + originChainId: 1, + destinationChainId: 10, + originTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + destinationTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + }); + + expect(filteredRoutes).toBeDefined(); + expect(Array.isArray(filteredRoutes)).toBe(true); + expect(filteredRoutes.length).toBeGreaterThan(0); + expect( + filteredRoutes.every( + (route) => + route.originToken.chainId === 1 && + route.destinationToken.chainId === 10 && + route.originToken.address === + "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" && + route.destinationToken.address === + "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + ), + ).toBe(true); + }); + + it("should respect limit and offset", async () => { + const page1Routes = await routes({ + client: TEST_CLIENT, + limit: 1, + offset: 1, + }); + + expect(page1Routes).toBeDefined(); + expect(Array.isArray(page1Routes)).toBe(true); + expect(page1Routes.length).toBe(1); + + const page2Routes = await routes({ + client: TEST_CLIENT, + limit: 1, + offset: 2, + }); + + expect(page2Routes).toBeDefined(); + expect(Array.isArray(page2Routes)).toBe(true); + expect(page2Routes.length).toBe(1); + + expect(JSON.stringify(page1Routes)).not.toEqual( + JSON.stringify(page2Routes), + ); + }); + + it("should surface any errors", async () => { + server.use( + http.get("https://bridge.thirdweb.com/v1/routes", () => { + return Response.json( + { + code: "InvalidRoutesRequest", + message: "The provided request is invalid.", + }, + { status: 400 }, + ); + }), + ); + + await expect( + routes({ + client: TEST_CLIENT, + limit: 1000, + offset: 1000, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: InvalidRoutesRequest | The provided request is invalid.]`, + ); + }); +}); diff --git a/packages/thirdweb/src/bridge/Routes.ts b/packages/thirdweb/src/bridge/Routes.ts new file mode 100644 index 00000000000..046d323ef4d --- /dev/null +++ b/packages/thirdweb/src/bridge/Routes.ts @@ -0,0 +1,160 @@ +import type { Address as ox__Address, Hex as ox__Hex } from "ox"; +import type { ThirdwebClient } from "../client/client.js"; +import { getClientFetch } from "../utils/fetch.js"; +import { UNIVERSAL_BRIDGE_URL } from "./constants.js"; +import type { Route } from "./types/Route.js"; + +/** + * Retrieves supported Universal Bridge routes based on the provided filters. + * + * When multiple filters are specified, a route must satisfy all filters to be included (it acts as an AND operator). + * + * @example + * ```typescript + * import { Bridge } from "thirdweb"; + * + * const routes = await Bridge.routes({ + * client: thirdwebClient, + * }); + * ``` + * + * Returned routes might look something like: + * ```typescript + * [ + * { + * destinationToken: { + * address: "0x12c88a3C30A7AaBC1dd7f2c08a97145F5DCcD830", + * chainId: 1, + * decimals: 18, + * iconUri: "https://assets.coingecko.com/coins/images/37207/standard/G.jpg", + * name: "G7", + * symbol: "G7", + * }, + * originToken: { + * address: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + * chainId: 480, + * decimals: 18, + * iconUri: "https://assets.relay.link/icons/1/light.png", + * name: "Ether", + * symbol: "ETH", + * } + * }, + * { + * destinationToken: { + * address: "0x4d224452801ACEd8B2F0aebE155379bb5D594381", + * chainId: 1, + * decimals: 18, + * iconUri: "https://coin-images.coingecko.com/coins/images/24383/large/apecoin.jpg?1696523566", + * name: "ApeCoin", + * symbol: "APE", + * }, + * originToken: { + * address: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + * chainId: 480, + * decimals: 18, + * iconUri: "https://assets.relay.link/icons/1/light.png", + * name: "Ether", + * symbol: "ETH", + * } + * } + * ] + * ``` + * + * You can filter for specific chains or tokens: + * ```typescript + * import { Bridge } from "thirdweb"; + * + * // Get all routes starting from mainnet ETH + * const routes = await Bridge.routes({ + * originChainId: 1, + * originTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + * client: thirdwebClient, + * }); + * ``` + * + * The returned routes will be limited based on the API. You can paginate through the results using the `limit` and `offset` parameters: + * ```typescript + * import { Bridge } from "thirdweb"; + * + * // Get the first 10 routes starting from mainnet ETH + * const routes = await Bridge.routes({ + * originChainId: 1, + * originTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + * limit: 10, + * offset: 0, + * client: thirdwebClient, + * }); + * ``` + * + * @param options - The options for the quote. + * @param options.client - Your thirdweb client. + * @param options.originChainId - Filter by a specific origin chain ID. + * @param options.originTokenAddress - Filter by a specific origin token address. + * @param options.destinationChainId - Filter by a specific destination chain ID. + * @param options.destinationTokenAddress - Filter by a specific destination token address. + * @param options.transactionHash - Filter by a specific transaction hash. + * @param options.limit - Limit the number of routes returned. + * @param options.offset - Offset the number of routes returned. + * + * @returns A promise that resolves to an array of routes. + * + * @throws Will throw an error if there is an issue fetching the routes. + * @bridge + * @beta + */ +export async function routes(options: routes.Options): Promise { + const { + client, + originChainId, + originTokenAddress, + destinationChainId, + destinationTokenAddress, + limit, + offset, + } = options; + + const clientFetch = getClientFetch(client); + const url = new URL(`${UNIVERSAL_BRIDGE_URL}/routes`); + if (originChainId) { + url.searchParams.set("originChainId", originChainId.toString()); + } + if (originTokenAddress) { + url.searchParams.set("originTokenAddress", originTokenAddress); + } + if (destinationChainId) { + url.searchParams.set("destinationChainId", destinationChainId.toString()); + } + if (destinationTokenAddress) { + url.searchParams.set("destinationTokenAddress", destinationTokenAddress); + } + if (limit) { + url.searchParams.set("limit", limit.toString()); + } + if (offset) { + url.searchParams.set("offset", offset.toString()); + } + + const response = await clientFetch(url.toString()); + if (!response.ok) { + const errorJson = await response.json(); + throw new Error(`${errorJson.code} | ${errorJson.message}`); + } + + const { data }: { data: Route[] } = await response.json(); + return data; +} + +export declare namespace routes { + type Options = { + client: ThirdwebClient; + originChainId?: number; + originTokenAddress?: ox__Address.Address; + destinationChainId?: number; + destinationTokenAddress?: ox__Address.Address; + transactionHash?: ox__Hex.Hex; + limit?: number; + offset?: number; + }; + + type Result = Route[]; +} diff --git a/packages/thirdweb/src/bridge/Sell.test.ts b/packages/thirdweb/src/bridge/Sell.test.ts new file mode 100644 index 00000000000..b0993d7d0b1 --- /dev/null +++ b/packages/thirdweb/src/bridge/Sell.test.ts @@ -0,0 +1,74 @@ +import { toWei } from "src/utils/units.js"; +import { describe, expect, it } from "vitest"; +import { TEST_CLIENT } from "~test/test-clients.js"; +import * as Sell from "./Sell.js"; + +describe("Bridge.Sell.quote", () => { + it("should get a valid quote", async () => { + const quote = await Sell.quote({ + originChainId: 1, + originTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + destinationChainId: 10, + destinationTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + sellAmountWei: toWei("0.01"), + client: TEST_CLIENT, + }); + + expect(quote).toBeDefined(); + expect(quote.originAmount).toEqual(toWei("0.01")); + expect(quote.intent).toBeDefined(); + }); + + it("should surface any errors", async () => { + await expect( + Sell.quote({ + originChainId: 1, + originTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + destinationChainId: 10, + destinationTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + sellAmountWei: toWei("1000000000"), + client: TEST_CLIENT, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: AMOUNT_TOO_HIGH | The provided amount is too high for the requested route.]`, + ); + }); +}); + +describe("Bridge.Sell.prepare", () => { + it("should get a valid prepared quote", async () => { + const quote = await Sell.prepare({ + originChainId: 1, + originTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + destinationChainId: 10, + destinationTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + sellAmountWei: toWei("0.01"), + sender: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + receiver: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + client: TEST_CLIENT, + }); + + expect(quote).toBeDefined(); + expect(quote.originAmount).toEqual(toWei("0.01")); + expect(quote.transactions).toBeDefined(); + expect(quote.transactions.length).toBeGreaterThan(0); + expect(quote.intent).toBeDefined(); + }); + + it("should surface any errors", async () => { + await expect( + Sell.prepare({ + originChainId: 1, + originTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + destinationChainId: 10, + destinationTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + sellAmountWei: toWei("1000000000"), + sender: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + receiver: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + client: TEST_CLIENT, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: AMOUNT_TOO_HIGH | The provided amount is too high for the requested route.]`, + ); + }); +}); diff --git a/packages/thirdweb/src/bridge/Sell.ts b/packages/thirdweb/src/bridge/Sell.ts new file mode 100644 index 00000000000..dd0da7066ee --- /dev/null +++ b/packages/thirdweb/src/bridge/Sell.ts @@ -0,0 +1,267 @@ +import type { Address as ox__Address } from "ox"; +import type { ThirdwebClient } from "../client/client.js"; +import { getClientFetch } from "../utils/fetch.js"; +import { UNIVERSAL_BRIDGE_URL } from "./constants.js"; +import type { PreparedQuote, Quote } from "./types/Quote.js"; + +/** + * Retrieves a Universal Bridge quote for the provided sell intent. The quote will specify the expected `destinationAmount` that will be received in exchange for the specified `originAmount`, which is specified with the `sellAmountWei` option. + * + * @example + * ```typescript + * import { Bridge, NATIVE_TOKEN_ADDRESS } from "thirdweb"; + * + * const quote = await Bridge.Sell.quote({ + * originChainId: 1, + * originTokenAddress: NATIVE_TOKEN_ADDRESS, + * destinationChainId: 10, + * destinationTokenAddress: NATIVE_TOKEN_ADDRESS, + * sellAmountWei: toWei("0.01"), + * client: thirdwebClient, + * }); + * ``` + * + * This will return a quote that might look like: + * ```typescript + * { + * originAmount: 1000000000000000000n, + * destinationAmount: 9999979011973735n, + * blockNumber: 22026509n, + * timestamp: 1741730936680, + * estimatedExecutionTimeMs: 1000 + * intent: { + * originChainId: 1, + * originTokenAddress: NATIVE_TOKEN_ADDRESS, + * destinationChainId: 10, + * destinationTokenAddress: NATIVE_TOKEN_ADDRESS, + * sellAmountWei: 1000000000000000000n + * } + * } + * ``` + * + * The quote is an **estimate** for how much you would expect to receive for a specific sell. This quote is not guaranteed and you should use `Sell.prepare` to get a finalized quote with transaction data ready for execution. + * So why use `quote`? The quote function is sometimes slightly faster than `prepare`, and can be used before the user connects their wallet. + * + * You can access this functions input and output types with `Sell.quote.Options` and `Sell.quote.Result`, respectively. + * + * @param options - The options for the quote. + * @param options.originChainId - The chain ID of the origin token. + * @param options.originTokenAddress - The address of the origin token. + * @param options.destinationChainId - The chain ID of the destination token. + * @param options.destinationTokenAddress - The address of the destination token. + * @param options.sellAmountWei - The amount of the origin token to sell. + * @param options.client - Your thirdweb client. + * + * @returns A promise that resolves to a non-finalized quote for the requested sell. + * + * @throws Will throw an error if there is an issue fetching the quote. + * @bridge + * @beta + */ +export async function quote(options: quote.Options): Promise { + const { + originChainId, + originTokenAddress, + destinationChainId, + destinationTokenAddress, + sellAmountWei, + client, + } = options; + + const clientFetch = getClientFetch(client); + const url = new URL(`${UNIVERSAL_BRIDGE_URL}/sell/quote`); + url.searchParams.set("originChainId", originChainId.toString()); + url.searchParams.set("originTokenAddress", originTokenAddress); + url.searchParams.set("destinationChainId", destinationChainId.toString()); + url.searchParams.set("destinationTokenAddress", destinationTokenAddress); + url.searchParams.set("sellAmountWei", sellAmountWei.toString()); + + const response = await clientFetch(url.toString()); + if (!response.ok) { + const errorJson = await response.json(); + throw new Error(`${errorJson.code} | ${errorJson.message}`); + } + + const { data }: { data: Quote } = await response.json(); + return { + originAmount: BigInt(data.originAmount), + destinationAmount: BigInt(data.destinationAmount), + blockNumber: data.blockNumber ? BigInt(data.blockNumber) : undefined, + timestamp: data.timestamp, + estimatedExecutionTimeMs: data.estimatedExecutionTimeMs, + intent: { + originChainId, + originTokenAddress, + destinationChainId, + destinationTokenAddress, + sellAmountWei, + }, + }; +} + +export declare namespace quote { + type Options = { + originChainId: number; + originTokenAddress: ox__Address.Address; + destinationChainId: number; + destinationTokenAddress: ox__Address.Address; + sellAmountWei: bigint; + client: ThirdwebClient; + }; + + type Result = Quote & { + intent: { + originChainId: number; + originTokenAddress: ox__Address.Address; + destinationChainId: number; + destinationTokenAddress: ox__Address.Address; + sellAmountWei: bigint; + }; + }; +} + +/** + * Prepares a **finalized** Universal Bridge quote for the provided sell request with transaction data. This function will return everything `quote` does, with the addition of a series of prepared transactions and the associated expiration timestamp. + * + * @example + * ```typescript + * import { Bridge, NATIVE_TOKEN_ADDRESS } from "thirdweb"; + * + * const quote = await Bridge.Sell.prepare({ + * originChainId: 1, + * originTokenAddress: NATIVE_TOKEN_ADDRESS, + * destinationChainId: 10, + * destinationTokenAddress: NATIVE_TOKEN_ADDRESS, + * sellAmountWei: toWei("0.01"), + * client: thirdwebClient, + * }); + * ``` + * + * This will return a quote that might look like: + * ```typescript + * { + * originAmount: 1000000000000000000n, + * destinationAmount: 9980000000000000000n, + * blockNumber: 22026509n, + * timestamp: 1741730936680, + * estimatedExecutionTimeMs: 1000 + * transactions: [ + * { + * to: NATIVE_TOKEN_ADDRESS, + * value: 9980000000000000000n, + * data: "0x", + * chainId: 10, + * type: "eip1559" + * } + * ], + * expiration: 1741730936680, + * intent: { + * originChainId: 1, + * originTokenAddress: NATIVE_TOKEN_ADDRESS, + * destinationChainId: 10, + * destinationTokenAddress: NATIVE_TOKEN_ADDRESS, + * sellAmountWei: 1000000000000000000n + * } + * } + * ``` + * + * ## Sending the transactions + * The `transactions` array is a series of [ox](https://oxlib.sh) EIP-1559 transactions that must be executed one after the other in order to fulfill the complete route. There are a few things to keep in mind when executing these transactions: + * - Approvals and other preparation transactions are not included in the transactions array. + * - All transactions are assumed to be executed by the `sender` address, regardless of which chain they are on. The final transaction will use the `receiver` as the recipient address. + * - If an `expiration` timestamp is provided, all transactions must be executed before that time to guarantee successful execution at the specified price. + * + * NOTE: To get the status of each transaction, use `Bridge.status` rather than checking for transaction inclusion. This function will ensure full bridge completion on the destination chain. + * + * You can access this functions input and output types with `Sell.prepare.Options` and `Sell.prepare.Result`, respectively. + * + * @param options - The options for the quote. + * @param options.originChainId - The chain ID of the origin token. + * @param options.originTokenAddress - The address of the origin token. + * @param options.destinationChainId - The chain ID of the destination token. + * @param options.destinationTokenAddress - The address of the destination token. + * @param options.sellAmountWei - The amount of the origin token to sell. + * @param options.sender - The address of the sender. + * @param options.receiver - The address of the recipient. + * @param options.client - Your thirdweb client. + * + * @returns A promise that resolves to a non-finalized quote for the requested buy. + * + * @throws Will throw an error if there is an issue fetching the quote. + * @bridge + * @beta + */ +export async function prepare( + options: prepare.Options, +): Promise { + const { + originChainId, + originTokenAddress, + destinationChainId, + destinationTokenAddress, + sellAmountWei, + sender, + receiver, + client, + } = options; + + const clientFetch = getClientFetch(client); + const url = new URL(`${UNIVERSAL_BRIDGE_URL}/sell/prepare`); + url.searchParams.set("originChainId", originChainId.toString()); + url.searchParams.set("originTokenAddress", originTokenAddress); + url.searchParams.set("destinationChainId", destinationChainId.toString()); + url.searchParams.set("destinationTokenAddress", destinationTokenAddress); + url.searchParams.set("sellAmountWei", sellAmountWei.toString()); + url.searchParams.set("sender", sender); + url.searchParams.set("receiver", receiver); + + const response = await clientFetch(url.toString()); + if (!response.ok) { + const errorJson = await response.json(); + throw new Error(`${errorJson.code} | ${errorJson.message}`); + } + + const { data }: { data: PreparedQuote } = await response.json(); + return { + originAmount: BigInt(data.originAmount), + destinationAmount: BigInt(data.destinationAmount), + blockNumber: data.blockNumber ? BigInt(data.blockNumber) : undefined, + timestamp: data.timestamp, + estimatedExecutionTimeMs: data.estimatedExecutionTimeMs, + transactions: data.transactions.map((transaction) => ({ + ...transaction, + value: transaction.value ? BigInt(transaction.value) : undefined, + })), + expiration: data.expiration, + intent: { + originChainId, + originTokenAddress, + destinationChainId, + destinationTokenAddress, + sellAmountWei, + }, + }; +} + +export declare namespace prepare { + type Options = { + originChainId: number; + originTokenAddress: ox__Address.Address; + destinationChainId: number; + destinationTokenAddress: ox__Address.Address; + sellAmountWei: bigint; + sender: ox__Address.Address; + receiver: ox__Address.Address; + client: ThirdwebClient; + }; + + type Result = PreparedQuote & { + intent: { + originChainId: number; + originTokenAddress: ox__Address.Address; + destinationChainId: number; + destinationTokenAddress: ox__Address.Address; + sellAmountWei: bigint; + }; + }; +} diff --git a/packages/thirdweb/src/bridge/Status.test.ts b/packages/thirdweb/src/bridge/Status.test.ts new file mode 100644 index 00000000000..d0ca82ba6e7 --- /dev/null +++ b/packages/thirdweb/src/bridge/Status.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { TEST_CLIENT } from "~test/test-clients.js"; +import { status } from "./Status.js"; + +describe("Bridge.status", () => { + it("should handle successful status", async () => { + const result = await status({ + transactionHash: + "0xe199ef82a0b6215221536e18ec512813c1aa10b4f5ed0d4dfdfcd703578da56d", + chainId: 8453, + client: TEST_CLIENT, + }); + + expect(result).toBeDefined(); + expect(result.status).toBe("COMPLETED"); + expect(result).toMatchInlineSnapshot(` + { + "destinationAmount": 188625148000000n, + "destinationChainId": 2741, + "destinationTokenAddress": "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + "originAmount": 200000000000000n, + "originChainId": 8453, + "originTokenAddress": "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + "status": "COMPLETED", + "transactions": [ + { + "chainId": 8453, + "transactionHash": "0xe199ef82a0b6215221536e18ec512813c1aa10b4f5ed0d4dfdfcd703578da56d", + }, + { + "chainId": 2741, + "transactionHash": "0xa70a82f42330f54be95a542e1fcfe6ed2dd9f07fb8c82ae67afb4342319f7433", + }, + ], + } + `); + }); +}); diff --git a/packages/thirdweb/src/bridge/Status.ts b/packages/thirdweb/src/bridge/Status.ts new file mode 100644 index 00000000000..aa2352cc7b6 --- /dev/null +++ b/packages/thirdweb/src/bridge/Status.ts @@ -0,0 +1,163 @@ +import type { Hex as ox__Hex } from "ox"; +import type { ThirdwebClient } from "../client/client.js"; +import { getClientFetch } from "../utils/fetch.js"; +import { UNIVERSAL_BRIDGE_URL } from "./constants.js"; +import type { Status } from "./types/Status.js"; + +/** + * Retrieves a Universal Bridge quote for the provided sell intent. The quote will specify the expected `destinationAmount` that will be received in exchange for the specified `originAmount`, which is specified with the `sellAmountWei` option. + * ++ * The returned status will include both the origin and destination transactions and any finalized amounts for the route. + * + * @example + * ```typescript + * import { Bridge } from "thirdweb"; + * + * const status = await Bridge.status({ + * transactionHash: "0xe199ef82a0b6215221536e18ec512813c1aa10b4f5ed0d4dfdfcd703578da56d", + * chainId: 8453, + * client: thirdwebClient, + * }); + * ``` + * + * If the transaction is complete, a response might look like: + * ```typescript + * { + * status: 'COMPLETED', + * originAmount: 200000000000000n, + * destinationAmount: 188625148000000n, + * originChainId: 8453, + * destinationChainId: 2741, + * originTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + * destinationTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + * transactions: [ + * { + * chainId: 8453, + * transactionHash: '0xe199ef82a0b6215221536e18ec512813c1aa10b4f5ed0d4dfdfcd703578da56d' + * }, + * { + * chainId: 2741, + * transactionHash: '0xa70a82f42330f54be95a542e1fcfe6ed2dd9f07fb8c82ae67afb4342319f7433' + * } + * ] + * } + * ``` + * + * If the origin transaction hasn't been mined yet, a response might look like: + * ```typescript + * { + * status: "NOT_FOUND", + * } + * ``` + * This is to allow you to poll for the status without catching an error. Be sure your transaction hash and chain are correct though, as this could also represent a legitimate 404 if the transaction doesn't exist. + * + * If the transaction is still pending, a response might look like: + * ```typescript + * { + * status: "PENDING", + * originAmount: 1000000000000000000n, + * originChainId: 466, + * destinationChainId: 1, + * originTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + * destinationTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + * transactions: [ + * { + * transactionHash: "0xe199ef82a0b6215221536e18ec512813c1aa10b4f5ed0d4dfdfcd703578da56d", + * chainId: 466, + * } + * ] + * } + * ``` + * + * If the transaction failed, a response might look like: + * ```typescript + * { + * status: "FAILED", + * transactions: [ + * { + * transactionHash: "0xe199ef82a0b6215221536e18ec512813c1aa10b4f5ed0d4dfdfcd703578da56d", + * chainId: 466, + * } + * ] + * } + * ``` + * + * This status is for a **single origin transaction only**. If your route involves multiple transactions, you'll need to get the status for each of them individually. + * + * If sending multiple dependent sequential transactions, wait until `status` returns `COMPLETED` before sending the next transaction. + * + * You can access this function's input and output types with `status.Options` and `status.Result`, respectively. + * + * @param options - The options for the quote. + * @param options.transactionHash - The hash of the origin transaction to get the bridge status for. + * @param options.chainId - The chain ID of the origin token. + * @param options.client - Your thirdweb client. + * + * @returns A promise that resolves to a status object for the transaction. + * + * @throws Will throw an error if there is an issue fetching the status. + * @bridge + * @beta + */ +export async function status(options: status.Options): Promise { + const { transactionHash, chainId, client } = options; + + const clientFetch = getClientFetch(client); + const url = new URL(`${UNIVERSAL_BRIDGE_URL}/status`); + url.searchParams.set("transactionHash", transactionHash); + url.searchParams.set("chainId", chainId.toString()); + + const response = await clientFetch(url.toString()); + if (!response.ok) { + const errorJson = await response.json(); + throw new Error(`${errorJson.code}: ${errorJson.message}`); + } + + const { data }: { data: Status } = await response.json(); + if (data.status === "FAILED") { + return { + status: "FAILED", + transactions: data.transactions, + }; + } + + if (data.status === "PENDING") { + return { + status: "PENDING", + originAmount: BigInt(data.originAmount), + originChainId: data.originChainId, + destinationChainId: data.destinationChainId, + originTokenAddress: data.originTokenAddress, + destinationTokenAddress: data.destinationTokenAddress, + transactions: data.transactions, + }; + } + + if (data.status === "NOT_FOUND") { + return { + status: "NOT_FOUND", + transactions: [], + }; + } + + return { + status: "COMPLETED", + originAmount: BigInt(data.originAmount), + destinationAmount: BigInt(data.destinationAmount), + originChainId: data.originChainId, + destinationChainId: data.destinationChainId, + originTokenAddress: data.originTokenAddress, + destinationTokenAddress: data.destinationTokenAddress, + transactions: data.transactions, + }; +} + +export declare namespace status { + type Options = { + transactionHash: ox__Hex.Hex; + chainId: number; + client: ThirdwebClient; + }; + + type Result = Status; +} diff --git a/packages/thirdweb/src/bridge/constants.ts b/packages/thirdweb/src/bridge/constants.ts new file mode 100644 index 00000000000..0ac0e351f4c --- /dev/null +++ b/packages/thirdweb/src/bridge/constants.ts @@ -0,0 +1 @@ +export const UNIVERSAL_BRIDGE_URL = "https://bridge.thirdweb.com/v1"; diff --git a/packages/thirdweb/src/bridge/index.ts b/packages/thirdweb/src/bridge/index.ts new file mode 100644 index 00000000000..cc978a43263 --- /dev/null +++ b/packages/thirdweb/src/bridge/index.ts @@ -0,0 +1,8 @@ +export * as Buy from "./Buy.js"; +export * as Sell from "./Sell.js"; +export { status } from "./Status.js"; +export { routes } from "./Routes.js"; + +export type { Status } from "./types/Status.js"; +export type { Route } from "./types/Route.js"; +export type { Quote, PreparedQuote } from "./types/Quote.js"; diff --git a/packages/thirdweb/src/bridge/types/Quote.ts b/packages/thirdweb/src/bridge/types/Quote.ts new file mode 100644 index 00000000000..c47a0932c6e --- /dev/null +++ b/packages/thirdweb/src/bridge/types/Quote.ts @@ -0,0 +1,42 @@ +import type { TransactionEnvelopeEip1559 as ox__TransactionEnvelopeEip1559 } from "ox"; + +export type Quote = { + /** + * The input amount (in wei) including fees to be paid. + */ + originAmount: bigint; + /** + * The output amount (in wei) to be received. + */ + destinationAmount: bigint; + /** + * The blocknumber this quote was generated at. + */ + blockNumber?: bigint; + /** + * The timestamp this quote was generated at. + */ + timestamp: number; + /** + * The estimated execution time in milliseconds. + */ + estimatedExecutionTimeMs?: number | undefined; +}; + +export type PreparedQuote = Quote & { + /** + * The expiration timestamp for the quote. All transactions must be executed before this timestamp to guarantee successful execution at the specified price. + */ + expiration?: number | undefined; + /** + * A series of [ox](https://oxlib.sh) EIP-1559 transactions that must be executed in sequential order to fulfill the complete route. + */ + transactions: Array< + ox__TransactionEnvelopeEip1559.TransactionEnvelopeEip1559< + false, + bigint, + number, + "eip1559" + > + >; +}; diff --git a/packages/thirdweb/src/bridge/types/Route.ts b/packages/thirdweb/src/bridge/types/Route.ts new file mode 100644 index 00000000000..e78751864a5 --- /dev/null +++ b/packages/thirdweb/src/bridge/types/Route.ts @@ -0,0 +1,20 @@ +import type { Address as ox__Address } from "ox"; + +export type Route = { + originToken: { + chainId: number; + address: ox__Address.Address; + decimals: number; + symbol: string; + name: string; + iconUri?: string; + }; + destinationToken: { + chainId: number; + address: string; + decimals: number; + symbol: string; + name: string; + iconUri?: string; + }; +}; diff --git a/packages/thirdweb/src/bridge/types/Status.ts b/packages/thirdweb/src/bridge/types/Status.ts new file mode 100644 index 00000000000..d58be311844 --- /dev/null +++ b/packages/thirdweb/src/bridge/types/Status.ts @@ -0,0 +1,39 @@ +import type { Address as ox__Address, Hex as ox__Hex } from "ox"; + +export type Status = + | { + status: "COMPLETED"; + originAmount: bigint; + destinationAmount: bigint; + originChainId: number; + destinationChainId: number; + originTokenAddress: ox__Address.Address; + destinationTokenAddress: ox__Address.Address; + transactions: Array<{ + chainId: number; + transactionHash: ox__Hex.Hex; + }>; + } + | { + status: "PENDING"; + originAmount: bigint; + originChainId: number; + destinationChainId: number; + originTokenAddress: ox__Address.Address; + destinationTokenAddress: ox__Address.Address; + transactions: Array<{ + chainId: number; + transactionHash: ox__Hex.Hex; + }>; + } + | { + status: "FAILED"; + transactions: Array<{ + chainId: number; + transactionHash: ox__Hex.Hex; + }>; + } + | { + status: "NOT_FOUND"; + transactions: []; + }; diff --git a/packages/thirdweb/src/exports/bridge.ts b/packages/thirdweb/src/exports/bridge.ts new file mode 100644 index 00000000000..de0a61e9f21 --- /dev/null +++ b/packages/thirdweb/src/exports/bridge.ts @@ -0,0 +1 @@ +export * from "../bridge/index.js"; diff --git a/packages/thirdweb/src/exports/thirdweb.ts b/packages/thirdweb/src/exports/thirdweb.ts index 41fb0017c0f..d614b50df13 100644 --- a/packages/thirdweb/src/exports/thirdweb.ts +++ b/packages/thirdweb/src/exports/thirdweb.ts @@ -71,6 +71,11 @@ export { type ThirdwebContract, } from "../contract/contract.js"; +/** + * UNIVERSAL BRIDGE + */ +export * as Bridge from "../bridge/index.js"; + /** * WALLETS */ diff --git a/packages/thirdweb/tsdoc.json b/packages/thirdweb/tsdoc.json index d1eb8da758e..cf9bf335f7f 100644 --- a/packages/thirdweb/tsdoc.json +++ b/packages/thirdweb/tsdoc.json @@ -57,6 +57,10 @@ "tagName": "@buyCrypto", "syntaxKind": "block" }, + { + "tagName": "@bridge", + "syntaxKind": "block" + }, { "tagName": "@storage", "syntaxKind": "block" @@ -112,6 +116,7 @@ "@walletConnection": true, "@walletUtils": true, "@buyCrypto": true, + "@bridge": true, "@storage": true, "@auth": true, "@utils": true,