Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SDK] Add 2 new Pay functions: convertFiatToCrypto and convertCryptoToFiat #5457

Merged
merged 1 commit into from
Dec 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/stupid-buses-wink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
"thirdweb": minor
---

Add 2 new Pay functions: convertFiatToCrypto and convertCryptoToFiat
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you add a quick example in the changeset?


Examples:

### Convert fiat (USD) to crypto
```ts
import { convertFiatToCrypto } from "thirdweb/pay";
import { ethereum } from "thirdweb/chains";

// 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)
```

### Convert crypto to fiat (USD)

```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 (number)
```
10 changes: 10 additions & 0 deletions packages/thirdweb/src/exports/pay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
95 changes: 95 additions & 0 deletions packages/thirdweb/src/pay/convert/cryptoToFiat.test.ts
Original file line number Diff line number Diff line change
@@ -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.`,
);
});
});
111 changes: 111 additions & 0 deletions packages/thirdweb/src/pay/convert/cryptoToFiat.ts
Original file line number Diff line number Diff line change
@@ -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}`,
);

Check warning on line 106 in packages/thirdweb/src/pay/convert/cryptoToFiat.ts

View check run for this annotation

Codecov / codecov/patch

packages/thirdweb/src/pay/convert/cryptoToFiat.ts#L104-L106

Added lines #L104 - L106 were not covered by tests
}

const data: { result: number } = await response.json();
return data;
}
96 changes: 96 additions & 0 deletions packages/thirdweb/src/pay/convert/fiatToCrypto.test.ts
Original file line number Diff line number Diff line change
@@ -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.`,
);
});
});
Loading
Loading