diff --git a/.changeset/stale-yaks-bathe.md b/.changeset/stale-yaks-bathe.md new file mode 100644 index 00000000000..dca5a5b67d6 --- /dev/null +++ b/.changeset/stale-yaks-bathe.md @@ -0,0 +1,154 @@ +--- +"thirdweb": minor +--- + +Adds Bridge.Transfer module for direct token transfers: + +```typescript +import { Bridge, NATIVE_TOKEN_ADDRESS } from "thirdweb"; + +const quote = await Bridge.Transfer.prepare({ + chainId: 1, + tokenAddress: NATIVE_TOKEN_ADDRESS, + amount: toWei("0.01"), + sender: "0x...", + receiver: "0x...", + client: thirdwebClient, +}); +``` + +This will return a quote that might look like: +```typescript +{ + originAmount: 10000026098875381n, + destinationAmount: 10000000000000000n, + blockNumber: 22026509n, + timestamp: 1741730936680, + estimatedExecutionTimeMs: 1000 + steps: [ + { + originToken: { + chainId: 1, + address: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + symbol: "ETH", + name: "Ethereum", + decimals: 18, + priceUsd: 2000, + iconUri: "https://..." + }, + destinationToken: { + chainId: 1, + address: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + symbol: "ETH", + name: "Ethereum", + decimals: 18, + priceUsd: 2000, + iconUri: "https://..." + }, + originAmount: 10000026098875381n, + destinationAmount: 10000000000000000n, + estimatedExecutionTimeMs: 1000 + transactions: [ + { + action: "approval", + id: "0x", + to: "0x...", + data: "0x...", + chainId: 1, + type: "eip1559" + }, + { + action: "transfer", + to: "0x...", + value: 10000026098875381n, + data: "0x...", + chainId: 1, + type: "eip1559" + } + ] + } + ], + expiration: 1741730936680, + intent: { + chainId: 1, + tokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + amount: 10000000000000000n, + sender: "0x...", + receiver: "0x..." + } +} +``` + +## 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 will have the `approval` action specified. You can perform approvals with `sendAndConfirmTransaction`, then proceed to the next transaction. + - 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 non-approval transaction, use `Bridge.status` rather than checking for transaction inclusion. This function will ensure full completion of the transfer. + +You can include arbitrary data to be included on any webhooks and status responses with the `purchaseData` option: + +```ts +const quote = await Bridge.Transfer.prepare({ + chainId: 1, + tokenAddress: NATIVE_TOKEN_ADDRESS, + amount: toWei("0.01"), + sender: "0x...", + receiver: "0x...", + purchaseData: { + reference: "payment-123", + metadata: { + note: "Transfer to Alice" + } + }, + client: thirdwebClient, +}); +``` + +## Fees +There may be fees associated with the transfer. These fees are paid by the `feePayer` address, which defaults to the `sender` address. You can specify a different address with the `feePayer` option. If you do not specify an option or explicitly specify `sender`, the fees will be added to the input amount. If you specify the `receiver` as the fee payer the fees will be subtracted from the destination amount. + +For example, if you were to request a transfer with `feePayer` set to `receiver`: +```typescript +const quote = await Bridge.Transfer.prepare({ + chainId: 1, + tokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC + amount: 100_000_000n, // 100 USDC + sender: "0x...", + receiver: "0x...", + feePayer: "receiver", + client: thirdwebClient, +}); +``` + +The returned quote might look like: +```typescript +{ + originAmount: 100_000_000n, // 100 USDC + destinationAmount: 99_970_000n, // 99.97 USDC + ... +} +``` + +If you were to request a transfer with `feePayer` set to `sender`: +```typescript +const quote = await Bridge.Transfer.prepare({ + chainId: 1, + tokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC + amount: 100_000_000n, // 100 USDC + sender: "0x...", + receiver: "0x...", + feePayer: "sender", + client: thirdwebClient, +}); +``` + +The returned quote might look like: +```typescript +{ + originAmount: 100_030_000n, // 100.03 USDC + destinationAmount: 100_000_000n, // 100 USDC + ... +} +``` diff --git a/packages/thirdweb/src/bridge/Buy.ts b/packages/thirdweb/src/bridge/Buy.ts index 73afcb8718d..828b1e7d3f9 100644 --- a/packages/thirdweb/src/bridge/Buy.ts +++ b/packages/thirdweb/src/bridge/Buy.ts @@ -119,6 +119,7 @@ export async function quote(options: quote.Options): Promise { url.searchParams.set("destinationChainId", destinationChainId.toString()); url.searchParams.set("destinationTokenAddress", destinationTokenAddress); url.searchParams.set("buyAmountWei", amount.toString()); + url.searchParams.set("amount", amount.toString()); if (maxSteps) { url.searchParams.set("maxSteps", maxSteps.toString()); } @@ -199,7 +200,7 @@ export declare namespace quote { * This will return a quote that might look like: * ```typescript * { - * originAmount: 10000026098875381n, + * originAmount: 2000030000n, * destinationAmount: 1000000000000000000n, * blockNumber: 22026509n, * timestamp: 1741730936680, @@ -208,11 +209,11 @@ export declare namespace quote { * { * originToken: { * chainId: 1, - * address: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", - * symbol: "ETH", - * name: "Ethereum", - * decimals: 18, - * priceUsd: 2000, + * address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + * symbol: "USDC", + * name: "USDC", + * decimals: 6, + * priceUsd: 1, * iconUri: "https://..." * }, * destinationToken: { @@ -224,7 +225,7 @@ export declare namespace quote { * priceUsd: 2000, * iconUri: "https://..." * }, - * originAmount: 10000026098875381n, + * originAmount: 2000030000n, * destinationAmount: 1000000000000000000n, * estimatedExecutionTimeMs: 1000 * transactions: [ @@ -250,7 +251,7 @@ export declare namespace quote { * expiration: 1741730936680, * intent: { * originChainId: 1, - * originTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + * originTokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", * destinationChainId: 10, * destinationTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", * amount: 1000000000000000000n @@ -342,7 +343,8 @@ export async function prepare( "Content-Type": "application/json", }, body: stringify({ - buyAmountWei: amount.toString(), + buyAmountWei: amount.toString(), // legacy + amount: amount.toString(), originChainId: originChainId.toString(), originTokenAddress, destinationChainId: destinationChainId.toString(), @@ -382,6 +384,8 @@ export async function prepare( destinationChainId, destinationTokenAddress, amount, + sender, + receiver, }, }; } @@ -407,6 +411,8 @@ export declare namespace prepare { destinationChainId: number; destinationTokenAddress: ox__Address.Address; amount: bigint; + sender: ox__Address.Address; + receiver: ox__Address.Address; purchaseData?: unknown; }; }; diff --git a/packages/thirdweb/src/bridge/Sell.ts b/packages/thirdweb/src/bridge/Sell.ts index 6c59db3b703..f4759941bc6 100644 --- a/packages/thirdweb/src/bridge/Sell.ts +++ b/packages/thirdweb/src/bridge/Sell.ts @@ -118,6 +118,7 @@ export async function quote(options: quote.Options): Promise { url.searchParams.set("destinationChainId", destinationChainId.toString()); url.searchParams.set("destinationTokenAddress", destinationTokenAddress); url.searchParams.set("sellAmountWei", amount.toString()); + url.searchParams.set("amount", amount.toString()); if (typeof maxSteps !== "undefined") { url.searchParams.set("maxSteps", maxSteps.toString()); } @@ -190,7 +191,7 @@ export declare namespace quote { * This will return a quote that might look like: * ```typescript * { - * originAmount: 1000000000000000000n, + * originAmount: 2000000000n, * destinationAmount: 9980000000000000000n, * blockNumber: 22026509n, * timestamp: 1741730936680, @@ -199,11 +200,11 @@ export declare namespace quote { * { * originToken: { * chainId: 1, - * address: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", - * symbol: "ETH", - * name: "Ethereum", - * decimals: 18, - * priceUsd: 2000, + * address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + * symbol: "USDC", + * name: "USDC", + * decimals: 6, + * priceUsd: 1, * iconUri: "https://..." * }, * destinationToken: { @@ -215,7 +216,7 @@ export declare namespace quote { * priceUsd: 2000, * iconUri: "https://..." * }, - * originAmount: 1000000000000000000n, + * originAmount: 2000000000n, * destinationAmount: 9980000000000000000n, * estimatedExecutionTimeMs: 1000 * } @@ -241,10 +242,10 @@ export declare namespace quote { * expiration: 1741730936680, * intent: { * originChainId: 1, - * originTokenAddress: NATIVE_TOKEN_ADDRESS, + * originTokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", * destinationChainId: 10, - * destinationTokenAddress: NATIVE_TOKEN_ADDRESS, - * amount: 1000000000000000000n + * destinationTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + * amount: 2000000000n * } * } * ``` @@ -334,6 +335,7 @@ export async function prepare( }, body: stringify({ sellAmountWei: amount.toString(), + amount: amount.toString(), originChainId: originChainId.toString(), originTokenAddress, destinationChainId: destinationChainId.toString(), @@ -374,6 +376,8 @@ export async function prepare( destinationChainId, destinationTokenAddress, amount, + sender, + receiver, purchaseData, }, }; @@ -400,6 +404,8 @@ export declare namespace prepare { destinationChainId: number; destinationTokenAddress: ox__Address.Address; amount: bigint; + sender: ox__Address.Address; + receiver: ox__Address.Address; purchaseData?: unknown; }; }; diff --git a/packages/thirdweb/src/bridge/Transfer.test.ts b/packages/thirdweb/src/bridge/Transfer.test.ts new file mode 100644 index 00000000000..a505d71714b --- /dev/null +++ b/packages/thirdweb/src/bridge/Transfer.test.ts @@ -0,0 +1,76 @@ +import { toWei } from "src/utils/units.js"; +import { describe, expect, it } from "vitest"; +import { TEST_CLIENT } from "~test/test-clients.js"; +import * as Transfer from "./Transfer.js"; + +describe.runIf(process.env.TW_SECRET_KEY)("Bridge.Transfer.prepare", () => { + it("should get a valid prepared quote", async () => { + const quote = await Transfer.prepare({ + chainId: 1, + tokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + amount: toWei("0.01"), + sender: "0x2a4f24F935Eb178e3e7BA9B53A5Ee6d8407C0709", + receiver: "0x2a4f24F935Eb178e3e7BA9B53A5Ee6d8407C0709", + client: TEST_CLIENT, + purchaseData: { + reference: "test-transfer", + }, + }); + + expect(quote).toBeDefined(); + expect(quote.intent.amount).toEqual(toWei("0.01")); + for (const step of quote.steps) { + expect(step.transactions.length).toBeGreaterThan(0); + } + expect(quote.intent).toBeDefined(); + }); + + it("should surface any errors", async () => { + await expect( + Transfer.prepare({ + chainId: 444, // Invalid chain ID + tokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + amount: toWei("1000000000"), + sender: "0x2a4f24F935Eb178e3e7BA9B53A5Ee6d8407C0709", + receiver: "0x2a4f24F935Eb178e3e7BA9B53A5Ee6d8407C0709", + client: TEST_CLIENT, + }), + ).rejects.toThrowError(); + }); + + it("should support the feePayer option", async () => { + const senderQuote = await Transfer.prepare({ + chainId: 1, + tokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + amount: toWei("0.01"), + sender: "0x2a4f24F935Eb178e3e7BA9B53A5Ee6d8407C0709", + receiver: "0x2a4f24F935Eb178e3e7BA9B53A5Ee6d8407C0709", + feePayer: "sender", + client: TEST_CLIENT, + }); + + expect(senderQuote).toBeDefined(); + expect(senderQuote.intent.feePayer).toBe("sender"); + + const receiverQuote = await Transfer.prepare({ + chainId: 1, + tokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + amount: toWei("0.01"), + sender: "0x2a4f24F935Eb178e3e7BA9B53A5Ee6d8407C0709", + receiver: "0x2a4f24F935Eb178e3e7BA9B53A5Ee6d8407C0709", + feePayer: "receiver", + client: TEST_CLIENT, + }); + + expect(receiverQuote).toBeDefined(); + expect(receiverQuote.intent.feePayer).toBe("receiver"); + + // When receiver pays fees, the destination amount should be less than the requested amount + expect(receiverQuote.destinationAmount).toBeLessThan(toWei("0.01")); + + // When sender pays fees, the origin amount should be more than the requested amount + // and the destination amount should equal the requested amount + expect(senderQuote.originAmount).toBeGreaterThan(toWei("0.01")); + expect(senderQuote.destinationAmount).toEqual(toWei("0.01")); + }); +}); diff --git a/packages/thirdweb/src/bridge/Transfer.ts b/packages/thirdweb/src/bridge/Transfer.ts new file mode 100644 index 00000000000..0f361f20b39 --- /dev/null +++ b/packages/thirdweb/src/bridge/Transfer.ts @@ -0,0 +1,270 @@ +import type { Address as ox__Address } from "ox"; +import { defineChain } from "../chains/utils.js"; +import type { ThirdwebClient } from "../client/client.js"; +import { getClientFetch } from "../utils/fetch.js"; +import { stringify } from "../utils/json.js"; +import { UNIVERSAL_BRIDGE_URL } from "./constants.js"; +import type { PreparedQuote } from "./types/Quote.js"; + +/** + * Prepares a **finalized** Universal Bridge quote for the provided transfer request with transaction data. + * + * @example + * ```typescript + * import { Bridge, NATIVE_TOKEN_ADDRESS } from "thirdweb"; + * + * const quote = await Bridge.Transfer.prepare({ + * chainId: 1, + * tokenAddress: NATIVE_TOKEN_ADDRESS, + * amount: toWei("0.01"), + * sender: "0x...", + * receiver: "0x...", + * client: thirdwebClient, + * }); + * ``` + * + * This will return a quote that might look like: + * ```typescript + * { + * originAmount: 10000026098875381n, + * destinationAmount: 10000000000000000n, + * blockNumber: 22026509n, + * timestamp: 1741730936680, + * estimatedExecutionTimeMs: 1000 + * steps: [ + * { + * originToken: { + * chainId: 1, + * address: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + * symbol: "ETH", + * name: "Ethereum", + * decimals: 18, + * priceUsd: 2000, + * iconUri: "https://..." + * }, + * destinationToken: { + * chainId: 1, + * address: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + * symbol: "ETH", + * name: "Ethereum", + * decimals: 18, + * priceUsd: 2000, + * iconUri: "https://..." + * }, + * originAmount: 10000026098875381n, + * destinationAmount: 10000000000000000n, + * estimatedExecutionTimeMs: 1000 + * transactions: [ + * { + * action: "approval", + * id: "0x", + * to: "0x...", + * data: "0x...", + * chainId: 1, + * type: "eip1559" + * }, + * { + * action: "transfer", + * to: "0x...", + * value: 10000026098875381n, + * data: "0x...", + * chainId: 1, + * type: "eip1559" + * } + * ] + * } + * ], + * expiration: 1741730936680, + * intent: { + * chainId: 1, + * tokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + * amount: 10000000000000000n, + * sender: "0x...", + * receiver: "0x..." + * } + * } + * ``` + * + * ## 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 will have the `approval` action specified. You can perform approvals with `sendAndConfirmTransaction`, then proceed to the next transaction. + * - 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 non-approval transaction, use `Bridge.status` rather than checking for transaction inclusion. This function will ensure full completion of the transfer. + * + * You can access this functions input and output types with `Transfer.prepare.Options` and `Transfer.prepare.Result`, respectively. + * + * You can include arbitrary data to be included on any webhooks and status responses with the `purchaseData` option. + * + * ```ts + * const quote = await Bridge.Transfer.prepare({ + * chainId: 1, + * tokenAddress: NATIVE_TOKEN_ADDRESS, + * amount: toWei("0.01"), + * sender: "0x...", + * receiver: "0x...", + * purchaseData: { + * reference: "payment-123", + * metadata: { + * note: "Transfer to Alice" + * } + * }, + * client: thirdwebClient, + * }); + * ``` + * + * ## Fees + * There may be fees associated with the transfer. These fees are paid by the `feePayer` address, which defaults to the `sender` address. You can specify a different address with the `feePayer` option. If you do not specify an option or explicitly specify `sender`, the fees will be added to the input amount. If you specify the `receiver` as the fee payer the fees will be subtracted from the destination amount. + * + * For example, if you were to request a transfer with `feePayer` set to `receiver`: + * ```typescript + * const quote = await Bridge.Transfer.prepare({ + * chainId: 1, + * tokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC + * amount: 100_000_000n, // 100 USDC + * sender: "0x...", + * receiver: "0x...", + * feePayer: "receiver", + * client: thirdwebClient, + * }); + * ``` + * + * The returned quote might look like: + * ```typescript + * { + * originAmount: 100_000_000n, // 100 USDC + * destinationAmount: 99_970_000n, // 99.97 USDC + * ... + * } + * ``` + * + * If you were to request a transfer with `feePayer` set to `sender`: + * ```typescript + * const quote = await Bridge.Transfer.prepare({ + * chainId: 1, + * tokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC + * amount: 100_000_000n, // 100 USDC + * sender: "0x...", + * receiver: "0x...", + * feePayer: "sender", + * client: thirdwebClient, + * }); + * ``` + * + * The returned quote might look like: + * ```typescript + * { + * originAmount: 100_030_000n, // 100.03 USDC + * destinationAmount: 100_000_000n, // 100 USDC + * ... + * } + * ``` + * + * @param options - The options for the quote. + * @param options.chainId - The chain ID of the token. + * @param options.tokenAddress - The address of the token. + * @param options.amount - The amount of the token to transfer. + * @param options.sender - The address of the sender. + * @param options.receiver - The address of the recipient. + * @param options.purchaseData - Arbitrary data to be passed to the transfer function and included with any webhooks or status calls. + * @param options.client - Your thirdweb client. + * @param [options.feePayer] - The address that will pay the fees for the transfer. If not specified, the sender will be used. Values can be "sender" or "receiver". + * + * @returns A promise that resolves to a finalized quote and transactions for the requested transfer. + * + * @throws Will throw an error if there is an issue fetching the quote. + * @bridge Transfer + * @beta + */ +export async function prepare( + options: prepare.Options, +): Promise { + const { + chainId, + tokenAddress, + sender, + receiver, + client, + amount, + purchaseData, + feePayer, + } = options; + + const clientFetch = getClientFetch(client); + const url = new URL(`${UNIVERSAL_BRIDGE_URL}/transfer/prepare`); + + const response = await clientFetch(url.toString(), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: stringify({ + transferAmountWei: amount.toString(), // legacy + amount: amount.toString(), + chainId: chainId.toString(), + tokenAddress, + sender, + receiver, + purchaseData, + feePayer, + }), + }); + if (!response.ok) { + const errorJson = await response.json(); + throw new Error( + `${errorJson.code} | ${errorJson.message} - ${errorJson.correlationId}`, + ); + } + + 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, + steps: data.steps.map((step) => ({ + ...step, + transactions: step.transactions.map((transaction) => ({ + ...transaction, + value: transaction.value ? BigInt(transaction.value) : undefined, + client, + chain: defineChain(transaction.chainId), + })), + })), + intent: { + chainId, + tokenAddress, + amount, + sender, + receiver, + feePayer, + }, + }; +} + +export declare namespace prepare { + type Options = { + chainId: number; + tokenAddress: ox__Address.Address; + sender: ox__Address.Address; + receiver: ox__Address.Address; + amount: bigint; + client: ThirdwebClient; + purchaseData?: unknown; + feePayer?: "sender" | "receiver"; + }; + + type Result = PreparedQuote & { + intent: { + chainId: number; + tokenAddress: ox__Address.Address; + amount: bigint; + sender: ox__Address.Address; + receiver: ox__Address.Address; + purchaseData?: unknown; + feePayer?: "sender" | "receiver"; + }; + }; +} diff --git a/packages/thirdweb/src/bridge/index.ts b/packages/thirdweb/src/bridge/index.ts index d5bd00cfc23..ac1a2e48741 100644 --- a/packages/thirdweb/src/bridge/index.ts +++ b/packages/thirdweb/src/bridge/index.ts @@ -1,5 +1,6 @@ export * as Buy from "./Buy.js"; export * as Sell from "./Sell.js"; +export * as Transfer from "./Transfer.js"; export { status } from "./Status.js"; export { routes } from "./Routes.js"; export { chains } from "./Chains.js"; diff --git a/packages/thirdweb/src/pay/buyWithCrypto/commonTypes.ts b/packages/thirdweb/src/pay/buyWithCrypto/commonTypes.ts index 80c7895e411..14432a2ffd9 100644 --- a/packages/thirdweb/src/pay/buyWithCrypto/commonTypes.ts +++ b/packages/thirdweb/src/pay/buyWithCrypto/commonTypes.ts @@ -10,16 +10,6 @@ export type QuoteTokenInfo = { symbol?: string; }; -export type QuoteTransactionRequest = { - data: string; - to: string; - value: string; - from: string; - chainId: number; - gasPrice: string; - gasLimit: string; -}; - export type QuoteApprovalInfo = { chainId: number; tokenAddress: string; diff --git a/packages/thirdweb/src/pay/buyWithCrypto/getQuote.ts b/packages/thirdweb/src/pay/buyWithCrypto/getQuote.ts index 42c9760dadc..67d4906233a 100644 --- a/packages/thirdweb/src/pay/buyWithCrypto/getQuote.ts +++ b/packages/thirdweb/src/pay/buyWithCrypto/getQuote.ts @@ -1,15 +1,15 @@ -import type { Hash } from "viem"; +import { Value } from "ox"; +import * as Bridge from "../../bridge/index.js"; import { getCachedChain } from "../../chains/utils.js"; import type { ThirdwebClient } from "../../client/client.js"; +import { NATIVE_TOKEN_ADDRESS } from "../../constants/addresses.js"; +import { getContract } from "../../contract/contract.js"; +import { decimals } from "../../extensions/erc20/read/decimals.js"; import type { PrepareTransactionOptions } from "../../transaction/prepare-transaction.js"; -import { getClientFetch } from "../../utils/fetch.js"; -import { stringify } from "../../utils/json.js"; -import { getPayBuyWithCryptoQuoteEndpoint } from "../utils/definitions.js"; import type { QuoteApprovalInfo, QuotePaymentToken, QuoteTokenInfo, - QuoteTransactionRequest, } from "./commonTypes.js"; /** @@ -105,44 +105,6 @@ export type GetBuyWithCryptoQuoteParams = { } ); -/** - * @buyCrypto - */ -type BuyWithCryptoQuoteRouteResponse = { - transactionRequest: QuoteTransactionRequest; - approval?: QuoteApprovalInfo; - - fromAddress: string; - toAddress: string; - - fromToken: QuoteTokenInfo; - toToken: QuoteTokenInfo; - - fromAmountWei: string; - fromAmount: string; - - toAmountMinWei: string; - toAmountMin: string; - toAmountWei: string; - toAmount: string; - - paymentTokens: QuotePaymentToken[]; - processingFees: QuotePaymentToken[]; - - estimated: { - fromAmountUSDCents: number; - toAmountMinUSDCents: number; - toAmountUSDCents: number; - slippageBPS: number; - feesUSDCents: number; - gasCostUSDCents?: number; - durationSeconds?: number; - }; - - maxSlippageBPS: number; - bridge?: string; -}; - /** * @buyCrypto */ @@ -215,76 +177,200 @@ export async function getBuyWithCryptoQuote( params: GetBuyWithCryptoQuoteParams, ): Promise { try { - const clientFetch = getClientFetch(params.client); - - const response = await clientFetch(getPayBuyWithCryptoQuoteEndpoint(), { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - body: stringify({ - fromAddress: params.fromAddress, - toAddress: params.toAddress, - fromChainId: params.fromChainId.toString(), - fromTokenAddress: params.fromTokenAddress, - toChainId: params.toChainId.toString(), - toTokenAddress: params.toTokenAddress, - fromAmount: params.fromAmount, - toAmount: params.toAmount, - maxSlippageBPS: params.maxSlippageBPS, - intentId: params.intentId, - purchaseData: params.purchaseData, - }), - }); - - // Assuming the response directly matches the SwapResponse interface - if (!response.ok) { - const errorObj = await response.json(); - if (errorObj && "error" in errorObj) { - throw errorObj; + const quote = await (async () => { + if (params.toAmount) { + const destinationTokenContract = getContract({ + address: params.toTokenAddress, + chain: getCachedChain(params.toChainId), + client: params.client, + }); + const tokenDecimals = + destinationTokenContract.address.toLowerCase() === + NATIVE_TOKEN_ADDRESS + ? 18 + : await decimals({ + contract: destinationTokenContract, + }); + const amount = Value.from(params.toAmount, tokenDecimals); + return Bridge.Buy.prepare({ + sender: params.fromAddress, + receiver: params.toAddress, + originChainId: params.fromChainId, + originTokenAddress: params.fromTokenAddress, + destinationChainId: params.toChainId, + destinationTokenAddress: params.toTokenAddress, + amount: amount, + purchaseData: params.purchaseData, + client: params.client, + }); + } else if (params.fromAmount) { + const originTokenContract = getContract({ + address: params.fromTokenAddress, + chain: getCachedChain(params.fromChainId), + client: params.client, + }); + const tokenDecimals = await decimals({ + contract: originTokenContract, + }); + const amount = Value.from(params.fromAmount, tokenDecimals); + return Bridge.Sell.prepare({ + sender: params.fromAddress, + receiver: params.toAddress, + originChainId: params.fromChainId, + originTokenAddress: params.fromTokenAddress, + destinationChainId: params.toChainId, + destinationTokenAddress: params.toTokenAddress, + amount: amount, + purchaseData: params.purchaseData, + client: params.client, + }); } - throw new Error(`HTTP error! status: ${response.status}`); + throw new Error( + "Invalid quote request, must provide either `fromAmount` or `toAmount`", + ); + })(); + + // check if the fromAddress already has approval for the given amount + const firstStep = quote.steps[0]; + if (!firstStep) { + throw new Error( + "This quote is incompatible with getBuyWithCryptoQuote. Please use Bridge.Buy.prepare instead.", + ); } + const approvalTxs = firstStep.transactions.filter( + (tx) => tx.action === "approval", + ); + if (approvalTxs.length > 1) { + throw new Error( + "This quote is incompatible with getBuyWithCryptoQuote. Please use Bridge.Buy.prepare instead.", + ); + } + const approvalTx = approvalTxs[0]; - const data: BuyWithCryptoQuoteRouteResponse = (await response.json()) - .result; + const txs = firstStep.transactions.filter((tx) => tx.action !== "approval"); + if (txs.length > 1) { + throw new Error( + "This quote is incompatible with getBuyWithCryptoQuote. Please use Bridge.Buy.prepare instead.", + ); + } + const tx = txs[0]; + if (!tx) { + throw new Error( + "This quote is incompatible with getBuyWithCryptoQuote. Please use Bridge.Buy.prepare instead.", + ); + } - // check if the fromAddress already has approval for the given amount - const approvalData = data.approval; + const approvalData: QuoteApprovalInfo | undefined = approvalTx + ? { + chainId: firstStep.originToken.chainId, + tokenAddress: firstStep.originToken.address, + spenderAddress: approvalTx.to, + amountWei: quote.originAmount.toString(), + } + : undefined; const swapRoute: BuyWithCryptoQuote = { transactionRequest: { - chain: getCachedChain(data.transactionRequest.chainId), - client: params.client, - data: data.transactionRequest.data as Hash, - to: data.transactionRequest.to, - value: BigInt(data.transactionRequest.value), + ...tx, extraGas: 50000n, // extra gas buffer }, approvalData, swapDetails: { - fromAddress: data.fromAddress, - toAddress: data.toAddress, - - fromToken: data.fromToken, - toToken: data.toToken, - - fromAmount: data.fromAmount, - fromAmountWei: data.fromAmountWei, - - toAmountMinWei: data.toAmountMinWei, - toAmountMin: data.toAmountMin, - - toAmountWei: data.toAmountWei, - toAmount: data.toAmount, - estimated: data.estimated, - - maxSlippageBPS: data.maxSlippageBPS, + fromAddress: quote.intent.sender, + toAddress: quote.intent.receiver, + + fromToken: { + tokenAddress: firstStep.originToken.address, + chainId: firstStep.originToken.chainId, + decimals: firstStep.originToken.decimals, + symbol: firstStep.originToken.symbol, + name: firstStep.originToken.name, + priceUSDCents: firstStep.originToken.priceUsd * 100, + }, + toToken: { + tokenAddress: firstStep.destinationToken.address, + chainId: firstStep.destinationToken.chainId, + decimals: firstStep.destinationToken.decimals, + symbol: firstStep.destinationToken.symbol, + name: firstStep.destinationToken.name, + priceUSDCents: firstStep.destinationToken.priceUsd * 100, + }, + + fromAmount: Value.format( + quote.originAmount, + firstStep.originToken.decimals, + ).toString(), + fromAmountWei: quote.originAmount.toString(), + + toAmountMinWei: quote.destinationAmount.toString(), + toAmountMin: Value.format( + quote.destinationAmount, + firstStep.destinationToken.decimals, + ).toString(), + + toAmountWei: quote.destinationAmount.toString(), + toAmount: Value.format( + quote.destinationAmount, + firstStep.destinationToken.decimals, + ).toString(), + estimated: { + fromAmountUSDCents: + Number( + Value.format(quote.originAmount, firstStep.originToken.decimals), + ) * + firstStep.originToken.priceUsd * + 100, + toAmountMinUSDCents: + Number( + Value.format( + quote.destinationAmount, + firstStep.destinationToken.decimals, + ), + ) * + firstStep.destinationToken.priceUsd * + 100, + toAmountUSDCents: + Number( + Value.format( + quote.destinationAmount, + firstStep.destinationToken.decimals, + ), + ) * + firstStep.destinationToken.priceUsd * + 100, + slippageBPS: 0, + feesUSDCents: 0, + gasCostUSDCents: 0, + durationSeconds: 0, + }, + + maxSlippageBPS: 0, }, - paymentTokens: data.paymentTokens, - processingFees: data.processingFees, + paymentTokens: [ + { + token: { + tokenAddress: firstStep.originToken.address, + chainId: firstStep.originToken.chainId, + decimals: firstStep.originToken.decimals, + symbol: firstStep.originToken.symbol, + name: firstStep.originToken.name, + priceUSDCents: firstStep.originToken.priceUsd * 100, + }, + amountWei: quote.originAmount.toString(), + amount: Value.format( + quote.originAmount, + firstStep.originToken.decimals, + ).toString(), + amountUSDCents: + Number( + Value.format(quote.originAmount, firstStep.originToken.decimals), + ) * + firstStep.originToken.priceUsd * + 100, + }, + ], + processingFees: [], client: params.client, }; diff --git a/packages/thirdweb/src/pay/buyWithCrypto/getTransfer.ts b/packages/thirdweb/src/pay/buyWithCrypto/getTransfer.ts index 74e480db7b0..08d081312bf 100644 --- a/packages/thirdweb/src/pay/buyWithCrypto/getTransfer.ts +++ b/packages/thirdweb/src/pay/buyWithCrypto/getTransfer.ts @@ -1,17 +1,12 @@ -import type { Hash } from "viem"; +import { Value } from "ox"; +import * as Bridge from "../../bridge/index.js"; import { getCachedChain } from "../../chains/utils.js"; import type { ThirdwebClient } from "../../client/client.js"; +import { NATIVE_TOKEN_ADDRESS } from "../../constants/addresses.js"; +import { getContract } from "../../contract/contract.js"; +import { decimals } from "../../extensions/erc20/read/decimals.js"; import type { PrepareTransactionOptions } from "../../transaction/prepare-transaction.js"; -import type { Address } from "../../utils/address.js"; -import { getClientFetch } from "../../utils/fetch.js"; -import { stringify } from "../../utils/json.js"; -import { getPayBuyWithCryptoTransferEndpoint } from "../utils/definitions.js"; -import type { - QuoteApprovalInfo, - QuotePaymentToken, - QuoteTokenInfo, - QuoteTransactionRequest, -} from "./commonTypes.js"; +import type { QuoteApprovalInfo, QuotePaymentToken } from "./commonTypes.js"; /** * The parameters for [`getBuyWithCryptoTransfer`](https://portal.thirdweb.com/references/typescript/v5/getBuyWithCryptoTransfer) function @@ -66,21 +61,6 @@ export type GetBuyWithCryptoTransferParams = { feePayer?: "sender" | "receiver"; }; -/** - * @buyCrypto - */ -type BuyWithCryptoTransferResponse = { - quoteId: string; - transactionRequest: QuoteTransactionRequest; - approval?: QuoteApprovalInfo; - fromAddress: string; - toAddress: string; - token: QuoteTokenInfo; - paymentToken: QuotePaymentToken; - processingFee: QuotePaymentToken; - estimatedGasCostUSDCents: number; -}; - /** * @buyCrypto */ @@ -126,50 +106,129 @@ export async function getBuyWithCryptoTransfer( params: GetBuyWithCryptoTransferParams, ): Promise { try { - const clientFetch = getClientFetch(params.client); - - const response = await clientFetch(getPayBuyWithCryptoTransferEndpoint(), { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - body: stringify({ - fromAddress: params.fromAddress, - toAddress: params.toAddress, - chainId: params.chainId, - tokenAddress: params.tokenAddress, - amount: params.amount, - purchaseData: params.purchaseData, - feePayer: params.feePayer, - }), + const tokenContract = getContract({ + address: params.tokenAddress, + chain: getCachedChain(params.chainId), + client: params.client, + }); + const tokenDecimals = + tokenContract.address.toLowerCase() === NATIVE_TOKEN_ADDRESS + ? 18 + : await decimals({ + contract: tokenContract, + }); + const amount = Value.from(params.amount, tokenDecimals); + const quote = await Bridge.Transfer.prepare({ + chainId: params.chainId, + tokenAddress: params.tokenAddress, + amount, + sender: params.fromAddress, + receiver: params.toAddress, + client: params.client, + feePayer: params.feePayer, }); - if (!response.ok) { - const errorObj = await response.json(); - if (errorObj && "error" in errorObj) { - throw errorObj; - } - throw new Error(`HTTP error! status: ${response.status}`); + const firstStep = quote.steps[0]; + if (!firstStep) { + throw new Error( + "This quote is incompatible with getBuyWithCryptoTransfer. Please use Bridge.Transfer.prepare instead.", + ); } - const data: BuyWithCryptoTransferResponse = (await response.json()).result; + const approvalTxs = firstStep.transactions.filter( + (tx) => tx.action === "approval", + ); + if (approvalTxs.length > 1) { + throw new Error( + "This quote is incompatible with getBuyWithCryptoTransfer. Please use Bridge.Transfer.prepare instead.", + ); + } + const approvalTx = approvalTxs[0]; + + const approvalData: QuoteApprovalInfo | undefined = approvalTx + ? { + chainId: firstStep.originToken.chainId, + tokenAddress: firstStep.originToken.address, + spenderAddress: approvalTx.to, + amountWei: quote.originAmount.toString(), + } + : undefined; + + const txs = firstStep.transactions.filter((tx) => tx.action !== "approval"); + if (txs.length > 1) { + throw new Error( + "This quote is incompatible with getBuyWithCryptoTransfer. Please use Bridge.Transfer.prepare instead.", + ); + } + const tx = txs[0]; + if (!tx) { + throw new Error( + "This quote is incompatible with getBuyWithCryptoTransfer. Please use Bridge.Transfer.prepare instead.", + ); + } const transfer: BuyWithCryptoTransfer = { transactionRequest: { - chain: getCachedChain(data.transactionRequest.chainId), - client: params.client, - data: data.transactionRequest.data as Hash, - to: data.transactionRequest.to as Address, - value: BigInt(data.transactionRequest.value), + ...tx, extraGas: 50000n, // extra gas buffer }, - approvalData: data.approval, - fromAddress: data.fromAddress, - toAddress: data.toAddress, - paymentToken: data.paymentToken, - processingFee: data.processingFee, - estimatedGasCostUSDCents: data.estimatedGasCostUSDCents, + approvalData, + fromAddress: params.fromAddress, + toAddress: params.toAddress, + paymentToken: { + token: { + tokenAddress: firstStep.originToken.address, + chainId: firstStep.originToken.chainId, + decimals: firstStep.originToken.decimals, + symbol: firstStep.originToken.symbol, + name: firstStep.originToken.name, + priceUSDCents: firstStep.originToken.priceUsd * 100, + }, + amountWei: quote.originAmount.toString(), + amount: Value.format( + quote.originAmount, + firstStep.originToken.decimals, + ).toString(), + amountUSDCents: + Number( + Value.format(quote.originAmount, firstStep.originToken.decimals), + ) * + firstStep.originToken.priceUsd * + 100, + }, + processingFee: { + token: { + tokenAddress: firstStep.originToken.address, + chainId: firstStep.originToken.chainId, + decimals: firstStep.originToken.decimals, + symbol: firstStep.originToken.symbol, + name: firstStep.originToken.name, + priceUSDCents: firstStep.originToken.priceUsd * 100, + }, + amountWei: + params.feePayer === "sender" + ? (quote.originAmount - quote.destinationAmount).toString() + : "0", + amount: + params.feePayer === "sender" + ? Value.format( + quote.originAmount - quote.destinationAmount, + firstStep.originToken.decimals, + ).toString() + : "0", + amountUSDCents: + params.feePayer === "sender" + ? Number( + Value.format( + quote.originAmount - quote.destinationAmount, + firstStep.originToken.decimals, + ), + ) * + firstStep.originToken.priceUsd * + 100 + : 0, + }, + estimatedGasCostUSDCents: 0, client: params.client, }; diff --git a/packages/thirdweb/src/pay/utils/definitions.ts b/packages/thirdweb/src/pay/utils/definitions.ts index f1763343238..0e15b074715 100644 --- a/packages/thirdweb/src/pay/utils/definitions.ts +++ b/packages/thirdweb/src/pay/utils/definitions.ts @@ -13,19 +13,6 @@ const getPayBaseUrl = () => { */ export const getPayBuyWithCryptoStatusUrl = () => `${getPayBaseUrl()}/buy-with-crypto/status/v1`; -/** - * Endpoint to get "Buy with Crypto" quote. - * @internal - */ -export const getPayBuyWithCryptoQuoteEndpoint = () => - `${getPayBaseUrl()}/buy-with-crypto/quote/v1`; - -/** - * Endpoint to get "Buy with Crypto" transfer. - * @internal - */ -export const getPayBuyWithCryptoTransferEndpoint = () => - `${getPayBaseUrl()}/buy-with-crypto/transfer/v1`; /** * Endpoint to get a "Buy with Fiat" quote. diff --git a/packages/thirdweb/src/utils/any-evm/zksync/constants.ts b/packages/thirdweb/src/utils/any-evm/zksync/constants.ts index 734f9d3699b..51e9e5fd275 100644 --- a/packages/thirdweb/src/utils/any-evm/zksync/constants.ts +++ b/packages/thirdweb/src/utils/any-evm/zksync/constants.ts @@ -3,6 +3,7 @@ export const ZKSYNC_SINGLETON_FACTORY = export const CONTRACT_DEPLOYER_ADDRESS = "0x0000000000000000000000000000000000008006" as const; export const KNOWN_CODES_STORAGE = "0x0000000000000000000000000000000000008004"; +// biome-ignore lint/nursery/noProcessEnv: Used for testing export const PUBLISHED_PRIVATE_KEY = process.env.ZKSYNC_PUBLISHED_PRIVATE_KEY; export const singletonFactoryAbi = [