From f1e9de255757a04db771823eb0037ca9a2af01c4 Mon Sep 17 00:00:00 2001 From: Kien Ngo Date: Tue, 19 Nov 2024 14:52:26 +0700 Subject: [PATCH] update --- .changeset/stupid-buses-wink.md | 5 + packages/thirdweb/src/exports/pay.ts | 10 ++ .../src/pay/convert/cryptoToFiat.test.ts | 95 +++++++++++++++ .../thirdweb/src/pay/convert/cryptoToFiat.ts | 111 ++++++++++++++++++ .../src/pay/convert/fiatToCrypto.test.ts | 96 +++++++++++++++ .../thirdweb/src/pay/convert/fiatToCrypto.ts | 110 +++++++++++++++++ .../thirdweb/src/pay/utils/definitions.ts | 6 + 7 files changed, 433 insertions(+) create mode 100644 .changeset/stupid-buses-wink.md create mode 100644 packages/thirdweb/src/pay/convert/cryptoToFiat.test.ts create mode 100644 packages/thirdweb/src/pay/convert/cryptoToFiat.ts create mode 100644 packages/thirdweb/src/pay/convert/fiatToCrypto.test.ts create mode 100644 packages/thirdweb/src/pay/convert/fiatToCrypto.ts diff --git a/.changeset/stupid-buses-wink.md b/.changeset/stupid-buses-wink.md new file mode 100644 index 00000000000..e400e12f2be --- /dev/null +++ b/.changeset/stupid-buses-wink.md @@ -0,0 +1,5 @@ +--- +"thirdweb": minor +--- + +Add 2 new Pay functions: convertFiatToCrypto and convertCryptoToFiat diff --git a/packages/thirdweb/src/exports/pay.ts b/packages/thirdweb/src/exports/pay.ts index 96c7b94fce2..f901ec55cd7 100644 --- a/packages/thirdweb/src/exports/pay.ts +++ b/packages/thirdweb/src/exports/pay.ts @@ -66,3 +66,13 @@ export type { PayTokenInfo, PayOnChainTransactionDetails, } from "../pay/utils/commonTypes.js"; + +export { + convertFiatToCrypto, + type ConvertFiatToCryptoParams, +} from "../pay/convert/fiatToCrypto.js"; + +export { + convertCryptoToFiat, + type ConvertCryptoToFiatParams, +} from "../pay/convert/cryptoToFiat.js"; diff --git a/packages/thirdweb/src/pay/convert/cryptoToFiat.test.ts b/packages/thirdweb/src/pay/convert/cryptoToFiat.test.ts new file mode 100644 index 00000000000..fed1b8e0aca --- /dev/null +++ b/packages/thirdweb/src/pay/convert/cryptoToFiat.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { TEST_CLIENT } from "~test/test-clients.js"; +import { TEST_ACCOUNT_A } from "~test/test-wallets.js"; +import { base } from "../../chains/chain-definitions/base.js"; +import { ethereum } from "../../chains/chain-definitions/ethereum.js"; +import { sepolia } from "../../chains/chain-definitions/sepolia.js"; +import { NATIVE_TOKEN_ADDRESS } from "../../constants/addresses.js"; +import { convertCryptoToFiat } from "./cryptoToFiat.js"; + +describe.runIf(process.env.TW_SECRET_KEY)("Pay: crypto-to-fiat", () => { + it("should convert ETH price to USD on Ethereum mainnet", async () => { + const data = await convertCryptoToFiat({ + chain: ethereum, + fromTokenAddress: NATIVE_TOKEN_ADDRESS, + fromAmount: 1, + to: "USD", + client: TEST_CLIENT, + }); + expect(data.result).toBeDefined(); + // Should be a number + expect(!Number.isNaN(data.result)).toBe(true); + // Since eth is around US$3000, we can add a test to check if the price is greater than $1500 (as a safe margin) + // let's hope that scenario does not happen :( + expect(Number(data.result) > 1500).toBe(true); + }); + + it("should convert ETH price to USD on Base mainnet", async () => { + const data = await convertCryptoToFiat({ + chain: base, + fromTokenAddress: NATIVE_TOKEN_ADDRESS, + fromAmount: 1, + to: "USD", + client: TEST_CLIENT, + }); + expect(data.result).toBeDefined(); + // Should be a number + expect(!Number.isNaN(data.result)).toBe(true); + // Since eth is around US$3000, we can add a test to check if the price is greater than $1500 (as a safe margin) + // let's hope that scenario does not happen :( + expect(data.result > 1500).toBe(true); + }); + + it("should return zero if fromAmount is zero", async () => { + const data = await convertCryptoToFiat({ + chain: base, + fromTokenAddress: NATIVE_TOKEN_ADDRESS, + fromAmount: 0, + to: "USD", + client: TEST_CLIENT, + }); + expect(data.result).toBe(0); + }); + + it("should throw error for testnet chain (because testnets are not supported", async () => { + await expect(() => + convertCryptoToFiat({ + chain: sepolia, + fromTokenAddress: NATIVE_TOKEN_ADDRESS, + fromAmount: 1, + to: "USD", + client: TEST_CLIENT, + }), + ).rejects.toThrowError( + `Cannot fetch price for a testnet (chainId: ${sepolia.id})`, + ); + }); + + it("should throw error if fromTokenAddress is set to an invalid EVM address", async () => { + await expect(() => + convertCryptoToFiat({ + chain: ethereum, + fromTokenAddress: "haha", + fromAmount: 1, + to: "USD", + client: TEST_CLIENT, + }), + ).rejects.toThrowError( + "Invalid fromTokenAddress. Expected a valid EVM contract address", + ); + }); + + it("should throw error if fromTokenAddress is set to a wallet address", async () => { + await expect(() => + convertCryptoToFiat({ + chain: base, + fromTokenAddress: TEST_ACCOUNT_A.address, + fromAmount: 1, + to: "USD", + client: TEST_CLIENT, + }), + ).rejects.toThrowError( + `Error: ${TEST_ACCOUNT_A.address} on chainId: ${base.id} is not a valid contract address.`, + ); + }); +}); diff --git a/packages/thirdweb/src/pay/convert/cryptoToFiat.ts b/packages/thirdweb/src/pay/convert/cryptoToFiat.ts new file mode 100644 index 00000000000..cf98821b254 --- /dev/null +++ b/packages/thirdweb/src/pay/convert/cryptoToFiat.ts @@ -0,0 +1,111 @@ +import type { Address } from "abitype"; +import type { Chain } from "../../chains/types.js"; +import type { ThirdwebClient } from "../../client/client.js"; +import { NATIVE_TOKEN_ADDRESS } from "../../constants/addresses.js"; +import { getBytecode } from "../../contract/actions/get-bytecode.js"; +import { getContract } from "../../contract/contract.js"; +import { isAddress } from "../../utils/address.js"; +import { getClientFetch } from "../../utils/fetch.js"; +import { getPayConvertCryptoToFiatEndpoint } from "../utils/definitions.js"; + +/** + * Props for the `convertCryptoToFiat` function + * @buyCrypto + */ +export type ConvertCryptoToFiatParams = { + client: ThirdwebClient; + /** + * The contract address of the token + * For native token, use NATIVE_TOKEN_ADDRESS + */ + fromTokenAddress: Address; + /** + * The amount of token to convert to fiat value + */ + fromAmount: number; + /** + * The chain that the token is deployed to + */ + chain: Chain; + /** + * The fiat symbol. e.g "USD" + * Only USD is supported at the moment. + */ + to: "USD"; +}; + +/** + * Get a price of a token (using tokenAddress + chainId) in fiat. + * Only USD is supported at the moment. + * @example + * ### Basic usage + * For native token (non-ERC20), you should use NATIVE_TOKEN_ADDRESS as the value for `tokenAddress` + * ```ts + * import { convertCryptoToFiat } from "thirdweb/pay"; + * + * // Get Ethereum price + * const result = convertCryptoToFiat({ + * fromTokenAddress: NATIVE_TOKEN_ADDRESS, + * to: "USD", + * chain: ethereum, + * fromAmount: 1, + * }); + * + * // Result: 3404.11 + * ``` + * @buyCrypto + * @returns a number representing the price (in selected fiat) of "x" token, with "x" being the `fromAmount`. + */ +export async function convertCryptoToFiat( + options: ConvertCryptoToFiatParams, +): Promise<{ result: number }> { + const { client, fromTokenAddress, to, chain, fromAmount } = options; + if (Number(fromAmount) === 0) { + return { result: 0 }; + } + // Testnets just don't work with our current provider(s) + if (chain.testnet === true) { + throw new Error(`Cannot fetch price for a testnet (chainId: ${chain.id})`); + } + // Some provider that we are using will return `0` for unsupported token + // so we should do some basic input validations before sending the request + + // Make sure it's a valid EVM address + if (!isAddress(fromTokenAddress)) { + throw new Error( + "Invalid fromTokenAddress. Expected a valid EVM contract address", + ); + } + // Make sure it's either a valid contract or a native token address + if (fromTokenAddress.toLowerCase() !== NATIVE_TOKEN_ADDRESS.toLowerCase()) { + const bytecode = await getBytecode( + getContract({ + address: fromTokenAddress, + chain, + client, + }), + ).catch(() => undefined); + if (!bytecode || bytecode === "0x") { + throw new Error( + `Error: ${fromTokenAddress} on chainId: ${chain.id} is not a valid contract address.`, + ); + } + } + const params = { + fromTokenAddress, + to, + chainId: String(chain.id), + fromAmount: String(fromAmount), + }; + const queryString = new URLSearchParams(params).toString(); + const url = `${getPayConvertCryptoToFiatEndpoint()}?${queryString}`; + const response = await getClientFetch(client)(url); + if (!response.ok) { + throw new Error( + `Failed to fetch ${to} value for token (${fromTokenAddress}) on chainId: ${chain.id}`, + ); + } + + const data: { result: number } = await response.json(); + return data; +} diff --git a/packages/thirdweb/src/pay/convert/fiatToCrypto.test.ts b/packages/thirdweb/src/pay/convert/fiatToCrypto.test.ts new file mode 100644 index 00000000000..3a74b4ef0d5 --- /dev/null +++ b/packages/thirdweb/src/pay/convert/fiatToCrypto.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { TEST_CLIENT } from "~test/test-clients.js"; +import { TEST_ACCOUNT_A } from "~test/test-wallets.js"; +import { base } from "../../chains/chain-definitions/base.js"; +import { ethereum } from "../../chains/chain-definitions/ethereum.js"; +import { sepolia } from "../../chains/chain-definitions/sepolia.js"; +import { NATIVE_TOKEN_ADDRESS } from "../../constants/addresses.js"; +import { convertFiatToCrypto } from "./fiatToCrypto.js"; + +describe.runIf(process.env.TW_SECRET_KEY)("Pay: fiatToCrypto", () => { + it("should convert fiat price to token on Ethereum mainnet", async () => { + const data = await convertFiatToCrypto({ + chain: ethereum, + from: "USD", + fromAmount: 1, + to: NATIVE_TOKEN_ADDRESS, + client: TEST_CLIENT, + }); + expect(data.result).toBeDefined(); + // Should be a number + expect(!Number.isNaN(data.result)).toBe(true); + // Since eth is around US$3000, 1 USD should be around 0.0003 + // we give it some safe margin so the test won't be flaky + expect(data.result < 0.001).toBe(true); + }); + + it("should convert fiat price to token on Base mainnet", async () => { + const data = await convertFiatToCrypto({ + chain: base, + from: "USD", + fromAmount: 1, + to: NATIVE_TOKEN_ADDRESS, + client: TEST_CLIENT, + }); + + expect(data.result).toBeDefined(); + // Should be a number + expect(!Number.isNaN(data.result)).toBe(true); + // Since eth is around US$3000, 1 USD should be around 0.0003 + // we give it some safe margin so the test won't be flaky + expect(data.result < 0.001).toBe(true); + }); + + it("should return zero if the fromAmount is zero", async () => { + const data = await convertFiatToCrypto({ + chain: base, + from: "USD", + fromAmount: 0, + to: NATIVE_TOKEN_ADDRESS, + client: TEST_CLIENT, + }); + expect(data.result).toBe(0); + }); + + it("should throw error for testnet chain (because testnets are not supported", async () => { + await expect(() => + convertFiatToCrypto({ + chain: sepolia, + to: NATIVE_TOKEN_ADDRESS, + fromAmount: 1, + from: "USD", + client: TEST_CLIENT, + }), + ).rejects.toThrowError( + `Cannot fetch price for a testnet (chainId: ${sepolia.id})`, + ); + }); + + it("should throw error if `to` is set to an invalid EVM address", async () => { + await expect(() => + convertFiatToCrypto({ + chain: ethereum, + to: "haha", + fromAmount: 1, + from: "USD", + client: TEST_CLIENT, + }), + ).rejects.toThrowError( + "Invalid `to`. Expected a valid EVM contract address", + ); + }); + + it("should throw error if `to` is set to a wallet address", async () => { + await expect(() => + convertFiatToCrypto({ + chain: base, + to: TEST_ACCOUNT_A.address, + fromAmount: 1, + from: "USD", + client: TEST_CLIENT, + }), + ).rejects.toThrowError( + `Error: ${TEST_ACCOUNT_A.address} on chainId: ${base.id} is not a valid contract address.`, + ); + }); +}); diff --git a/packages/thirdweb/src/pay/convert/fiatToCrypto.ts b/packages/thirdweb/src/pay/convert/fiatToCrypto.ts new file mode 100644 index 00000000000..46f6af2cea1 --- /dev/null +++ b/packages/thirdweb/src/pay/convert/fiatToCrypto.ts @@ -0,0 +1,110 @@ +import type { Address } from "abitype"; +import type { Chain } from "../../chains/types.js"; +import type { ThirdwebClient } from "../../client/client.js"; +import { NATIVE_TOKEN_ADDRESS } from "../../constants/addresses.js"; +import { getBytecode } from "../../contract/actions/get-bytecode.js"; +import { getContract } from "../../contract/contract.js"; +import { isAddress } from "../../utils/address.js"; +import { getClientFetch } from "../../utils/fetch.js"; +import { getPayConvertFiatToCryptoEndpoint } from "../utils/definitions.js"; + +/** + * Props for the `convertFiatToCrypto` function + * @buyCrypto + */ +export type ConvertFiatToCryptoParams = { + client: ThirdwebClient; + /** + * The fiat symbol. e.g: "USD" + * Currently only USD is supported. + */ + from: "USD"; + /** + * The total amount of fiat to convert + * e.g: If you want to convert 2 cents to USD, enter `0.02` + */ + fromAmount: number; + /** + * The token address + * For native token, use NATIVE_TOKEN_ADDRESS + */ + to: Address; + /** + * The chain that the token is deployed to + */ + chain: Chain; +}; + +/** + * Convert a fiat value to a token. + * Currently only USD is supported. + * @example + * ### Basic usage + * ```ts + * import { convertFiatToCrypto } from "thirdweb/pay"; + * + * // Convert 2 cents to ETH + * const result = await convertFiatToCrypto({ + * from: "USD", + * // the token address. For native token, use NATIVE_TOKEN_ADDRESS + * to: "0x...", + * // the chain (of the chain where the token belong to) + * chain: ethereum, + * // 2 cents + * fromAmount: 0.02, + * }); + * ``` + * Result: `0.0000057` (a number) + * @buyCrypto + */ +export async function convertFiatToCrypto( + options: ConvertFiatToCryptoParams, +): Promise<{ result: number }> { + const { client, from, to, chain, fromAmount } = options; + if (Number(fromAmount) === 0) { + return { result: 0 }; + } + // Testnets just don't work with our current provider(s) + if (chain.testnet === true) { + throw new Error(`Cannot fetch price for a testnet (chainId: ${chain.id})`); + } + // Some provider that we are using will return `0` for unsupported token + // so we should do some basic input validations before sending the request + + // Make sure it's a valid EVM address + if (!isAddress(to)) { + throw new Error("Invalid `to`. Expected a valid EVM contract address"); + } + // Make sure it's either a valid contract or a native token + if (to.toLowerCase() !== NATIVE_TOKEN_ADDRESS.toLowerCase()) { + const bytecode = await getBytecode( + getContract({ + address: to, + chain, + client, + }), + ).catch(() => undefined); + if (!bytecode || bytecode === "0x") { + throw new Error( + `Error: ${to} on chainId: ${chain.id} is not a valid contract address.`, + ); + } + } + const params = { + from, + to, + chainId: String(chain.id), + fromAmount: String(fromAmount), + }; + const queryString = new URLSearchParams(params).toString(); + const url = `${getPayConvertFiatToCryptoEndpoint()}?${queryString}`; + const response = await getClientFetch(client)(url); + if (!response.ok) { + throw new Error( + `Failed to convert ${to} value to token (${to}) on chainId: ${chain.id}`, + ); + } + + const data: { result: number } = await response.json(); + return data; +} diff --git a/packages/thirdweb/src/pay/utils/definitions.ts b/packages/thirdweb/src/pay/utils/definitions.ts index 313cf16094c..f1763343238 100644 --- a/packages/thirdweb/src/pay/utils/definitions.ts +++ b/packages/thirdweb/src/pay/utils/definitions.ts @@ -76,3 +76,9 @@ export const getPaySupportedSources = () => */ export const getPayBuyHistoryEndpoint = () => `${getPayBaseUrl()}/wallet/history/v1`; + +export const getPayConvertFiatToCryptoEndpoint = () => + `${getPayBaseUrl()}/convert/fiat-to-crypto/v1`; + +export const getPayConvertCryptoToFiatEndpoint = () => + `${getPayBaseUrl()}/convert/crypto-to-fiat/v1`;