Skip to content

Commit 57fa96b

Browse files
committed
[SDK] Add 2 new Pay functions: convertFiatToCrypto and convertCryptoToFiat (#5457)
CNCT-2369 ## Problem solved Short description of the bug fixed or feature added <!-- start pr-codex --> --- ## PR-Codex overview This PR introduces two new functions for converting between fiat and crypto currencies in the `thirdweb` package, along with corresponding tests and endpoints. ### Detailed summary - Added `convertFiatToCrypto` and `convertCryptoToFiat` functions. - Created endpoints for fiat to crypto and crypto to fiat conversions. - Implemented tests for both conversion functions, covering various scenarios and error handling. - Updated documentation with examples for the new functions. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex -->
1 parent 1e9a6c7 commit 57fa96b

File tree

7 files changed

+469
-0
lines changed

7 files changed

+469
-0
lines changed

.changeset/stupid-buses-wink.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"thirdweb": minor
3+
---
4+
5+
Add 2 new Pay functions: convertFiatToCrypto and convertCryptoToFiat
6+
7+
Examples:
8+
9+
### Convert fiat (USD) to crypto
10+
```ts
11+
import { convertFiatToCrypto } from "thirdweb/pay";
12+
import { ethereum } from "thirdweb/chains";
13+
14+
// Convert 2 cents to ETH
15+
const result = await convertFiatToCrypto({
16+
from: "USD",
17+
// the token address. For native token, use NATIVE_TOKEN_ADDRESS
18+
to: "0x...",
19+
// the chain (of the chain where the token belong to)
20+
chain: ethereum,
21+
// 2 cents
22+
fromAmount: 0.02,
23+
});
24+
// Result: 0.0000057 (a number)
25+
```
26+
27+
### Convert crypto to fiat (USD)
28+
29+
```ts
30+
import { convertCryptoToFiat } from "thirdweb/pay";
31+
32+
// Get Ethereum price
33+
const result = convertCryptoToFiat({
34+
fromTokenAddress: NATIVE_TOKEN_ADDRESS,
35+
to: "USD",
36+
chain: ethereum,
37+
fromAmount: 1,
38+
});
39+
40+
// Result: 3404.11 (number)
41+
```

packages/thirdweb/src/exports/pay.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,13 @@ export type {
6666
PayTokenInfo,
6767
PayOnChainTransactionDetails,
6868
} from "../pay/utils/commonTypes.js";
69+
70+
export {
71+
convertFiatToCrypto,
72+
type ConvertFiatToCryptoParams,
73+
} from "../pay/convert/fiatToCrypto.js";
74+
75+
export {
76+
convertCryptoToFiat,
77+
type ConvertCryptoToFiatParams,
78+
} from "../pay/convert/cryptoToFiat.js";
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, expect, it } from "vitest";
2+
import { TEST_CLIENT } from "~test/test-clients.js";
3+
import { TEST_ACCOUNT_A } from "~test/test-wallets.js";
4+
import { base } from "../../chains/chain-definitions/base.js";
5+
import { ethereum } from "../../chains/chain-definitions/ethereum.js";
6+
import { sepolia } from "../../chains/chain-definitions/sepolia.js";
7+
import { NATIVE_TOKEN_ADDRESS } from "../../constants/addresses.js";
8+
import { convertCryptoToFiat } from "./cryptoToFiat.js";
9+
10+
describe.runIf(process.env.TW_SECRET_KEY)("Pay: crypto-to-fiat", () => {
11+
it("should convert ETH price to USD on Ethereum mainnet", async () => {
12+
const data = await convertCryptoToFiat({
13+
chain: ethereum,
14+
fromTokenAddress: NATIVE_TOKEN_ADDRESS,
15+
fromAmount: 1,
16+
to: "USD",
17+
client: TEST_CLIENT,
18+
});
19+
expect(data.result).toBeDefined();
20+
// Should be a number
21+
expect(!Number.isNaN(data.result)).toBe(true);
22+
// Since eth is around US$3000, we can add a test to check if the price is greater than $1500 (as a safe margin)
23+
// let's hope that scenario does not happen :(
24+
expect(Number(data.result) > 1500).toBe(true);
25+
});
26+
27+
it("should convert ETH price to USD on Base mainnet", async () => {
28+
const data = await convertCryptoToFiat({
29+
chain: base,
30+
fromTokenAddress: NATIVE_TOKEN_ADDRESS,
31+
fromAmount: 1,
32+
to: "USD",
33+
client: TEST_CLIENT,
34+
});
35+
expect(data.result).toBeDefined();
36+
// Should be a number
37+
expect(!Number.isNaN(data.result)).toBe(true);
38+
// Since eth is around US$3000, we can add a test to check if the price is greater than $1500 (as a safe margin)
39+
// let's hope that scenario does not happen :(
40+
expect(data.result > 1500).toBe(true);
41+
});
42+
43+
it("should return zero if fromAmount is zero", async () => {
44+
const data = await convertCryptoToFiat({
45+
chain: base,
46+
fromTokenAddress: NATIVE_TOKEN_ADDRESS,
47+
fromAmount: 0,
48+
to: "USD",
49+
client: TEST_CLIENT,
50+
});
51+
expect(data.result).toBe(0);
52+
});
53+
54+
it("should throw error for testnet chain (because testnets are not supported", async () => {
55+
await expect(() =>
56+
convertCryptoToFiat({
57+
chain: sepolia,
58+
fromTokenAddress: NATIVE_TOKEN_ADDRESS,
59+
fromAmount: 1,
60+
to: "USD",
61+
client: TEST_CLIENT,
62+
}),
63+
).rejects.toThrowError(
64+
`Cannot fetch price for a testnet (chainId: ${sepolia.id})`,
65+
);
66+
});
67+
68+
it("should throw error if fromTokenAddress is set to an invalid EVM address", async () => {
69+
await expect(() =>
70+
convertCryptoToFiat({
71+
chain: ethereum,
72+
fromTokenAddress: "haha",
73+
fromAmount: 1,
74+
to: "USD",
75+
client: TEST_CLIENT,
76+
}),
77+
).rejects.toThrowError(
78+
"Invalid fromTokenAddress. Expected a valid EVM contract address",
79+
);
80+
});
81+
82+
it("should throw error if fromTokenAddress is set to a wallet address", async () => {
83+
await expect(() =>
84+
convertCryptoToFiat({
85+
chain: base,
86+
fromTokenAddress: TEST_ACCOUNT_A.address,
87+
fromAmount: 1,
88+
to: "USD",
89+
client: TEST_CLIENT,
90+
}),
91+
).rejects.toThrowError(
92+
`Error: ${TEST_ACCOUNT_A.address} on chainId: ${base.id} is not a valid contract address.`,
93+
);
94+
});
95+
});
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import type { Address } from "abitype";
2+
import type { Chain } from "../../chains/types.js";
3+
import type { ThirdwebClient } from "../../client/client.js";
4+
import { NATIVE_TOKEN_ADDRESS } from "../../constants/addresses.js";
5+
import { getBytecode } from "../../contract/actions/get-bytecode.js";
6+
import { getContract } from "../../contract/contract.js";
7+
import { isAddress } from "../../utils/address.js";
8+
import { getClientFetch } from "../../utils/fetch.js";
9+
import { getPayConvertCryptoToFiatEndpoint } from "../utils/definitions.js";
10+
11+
/**
12+
* Props for the `convertCryptoToFiat` function
13+
* @buyCrypto
14+
*/
15+
export type ConvertCryptoToFiatParams = {
16+
client: ThirdwebClient;
17+
/**
18+
* The contract address of the token
19+
* For native token, use NATIVE_TOKEN_ADDRESS
20+
*/
21+
fromTokenAddress: Address;
22+
/**
23+
* The amount of token to convert to fiat value
24+
*/
25+
fromAmount: number;
26+
/**
27+
* The chain that the token is deployed to
28+
*/
29+
chain: Chain;
30+
/**
31+
* The fiat symbol. e.g "USD"
32+
* Only USD is supported at the moment.
33+
*/
34+
to: "USD";
35+
};
36+
37+
/**
38+
* Get a price of a token (using tokenAddress + chainId) in fiat.
39+
* Only USD is supported at the moment.
40+
* @example
41+
* ### Basic usage
42+
* For native token (non-ERC20), you should use NATIVE_TOKEN_ADDRESS as the value for `tokenAddress`
43+
* ```ts
44+
* import { convertCryptoToFiat } from "thirdweb/pay";
45+
*
46+
* // Get Ethereum price
47+
* const result = convertCryptoToFiat({
48+
* fromTokenAddress: NATIVE_TOKEN_ADDRESS,
49+
* to: "USD",
50+
* chain: ethereum,
51+
* fromAmount: 1,
52+
* });
53+
*
54+
* // Result: 3404.11
55+
* ```
56+
* @buyCrypto
57+
* @returns a number representing the price (in selected fiat) of "x" token, with "x" being the `fromAmount`.
58+
*/
59+
export async function convertCryptoToFiat(
60+
options: ConvertCryptoToFiatParams,
61+
): Promise<{ result: number }> {
62+
const { client, fromTokenAddress, to, chain, fromAmount } = options;
63+
if (Number(fromAmount) === 0) {
64+
return { result: 0 };
65+
}
66+
// Testnets just don't work with our current provider(s)
67+
if (chain.testnet === true) {
68+
throw new Error(`Cannot fetch price for a testnet (chainId: ${chain.id})`);
69+
}
70+
// Some provider that we are using will return `0` for unsupported token
71+
// so we should do some basic input validations before sending the request
72+
73+
// Make sure it's a valid EVM address
74+
if (!isAddress(fromTokenAddress)) {
75+
throw new Error(
76+
"Invalid fromTokenAddress. Expected a valid EVM contract address",
77+
);
78+
}
79+
// Make sure it's either a valid contract or a native token address
80+
if (fromTokenAddress.toLowerCase() !== NATIVE_TOKEN_ADDRESS.toLowerCase()) {
81+
const bytecode = await getBytecode(
82+
getContract({
83+
address: fromTokenAddress,
84+
chain,
85+
client,
86+
}),
87+
).catch(() => undefined);
88+
if (!bytecode || bytecode === "0x") {
89+
throw new Error(
90+
`Error: ${fromTokenAddress} on chainId: ${chain.id} is not a valid contract address.`,
91+
);
92+
}
93+
}
94+
const params = {
95+
fromTokenAddress,
96+
to,
97+
chainId: String(chain.id),
98+
fromAmount: String(fromAmount),
99+
};
100+
const queryString = new URLSearchParams(params).toString();
101+
const url = `${getPayConvertCryptoToFiatEndpoint()}?${queryString}`;
102+
const response = await getClientFetch(client)(url);
103+
if (!response.ok) {
104+
throw new Error(
105+
`Failed to fetch ${to} value for token (${fromTokenAddress}) on chainId: ${chain.id}`,
106+
);
107+
}
108+
109+
const data: { result: number } = await response.json();
110+
return data;
111+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { describe, expect, it } from "vitest";
2+
import { TEST_CLIENT } from "~test/test-clients.js";
3+
import { TEST_ACCOUNT_A } from "~test/test-wallets.js";
4+
import { base } from "../../chains/chain-definitions/base.js";
5+
import { ethereum } from "../../chains/chain-definitions/ethereum.js";
6+
import { sepolia } from "../../chains/chain-definitions/sepolia.js";
7+
import { NATIVE_TOKEN_ADDRESS } from "../../constants/addresses.js";
8+
import { convertFiatToCrypto } from "./fiatToCrypto.js";
9+
10+
describe.runIf(process.env.TW_SECRET_KEY)("Pay: fiatToCrypto", () => {
11+
it("should convert fiat price to token on Ethereum mainnet", async () => {
12+
const data = await convertFiatToCrypto({
13+
chain: ethereum,
14+
from: "USD",
15+
fromAmount: 1,
16+
to: NATIVE_TOKEN_ADDRESS,
17+
client: TEST_CLIENT,
18+
});
19+
expect(data.result).toBeDefined();
20+
// Should be a number
21+
expect(!Number.isNaN(data.result)).toBe(true);
22+
// Since eth is around US$3000, 1 USD should be around 0.0003
23+
// we give it some safe margin so the test won't be flaky
24+
expect(data.result < 0.001).toBe(true);
25+
});
26+
27+
it("should convert fiat price to token on Base mainnet", async () => {
28+
const data = await convertFiatToCrypto({
29+
chain: base,
30+
from: "USD",
31+
fromAmount: 1,
32+
to: NATIVE_TOKEN_ADDRESS,
33+
client: TEST_CLIENT,
34+
});
35+
36+
expect(data.result).toBeDefined();
37+
// Should be a number
38+
expect(!Number.isNaN(data.result)).toBe(true);
39+
// Since eth is around US$3000, 1 USD should be around 0.0003
40+
// we give it some safe margin so the test won't be flaky
41+
expect(data.result < 0.001).toBe(true);
42+
});
43+
44+
it("should return zero if the fromAmount is zero", async () => {
45+
const data = await convertFiatToCrypto({
46+
chain: base,
47+
from: "USD",
48+
fromAmount: 0,
49+
to: NATIVE_TOKEN_ADDRESS,
50+
client: TEST_CLIENT,
51+
});
52+
expect(data.result).toBe(0);
53+
});
54+
55+
it("should throw error for testnet chain (because testnets are not supported", async () => {
56+
await expect(() =>
57+
convertFiatToCrypto({
58+
chain: sepolia,
59+
to: NATIVE_TOKEN_ADDRESS,
60+
fromAmount: 1,
61+
from: "USD",
62+
client: TEST_CLIENT,
63+
}),
64+
).rejects.toThrowError(
65+
`Cannot fetch price for a testnet (chainId: ${sepolia.id})`,
66+
);
67+
});
68+
69+
it("should throw error if `to` is set to an invalid EVM address", async () => {
70+
await expect(() =>
71+
convertFiatToCrypto({
72+
chain: ethereum,
73+
to: "haha",
74+
fromAmount: 1,
75+
from: "USD",
76+
client: TEST_CLIENT,
77+
}),
78+
).rejects.toThrowError(
79+
"Invalid `to`. Expected a valid EVM contract address",
80+
);
81+
});
82+
83+
it("should throw error if `to` is set to a wallet address", async () => {
84+
await expect(() =>
85+
convertFiatToCrypto({
86+
chain: base,
87+
to: TEST_ACCOUNT_A.address,
88+
fromAmount: 1,
89+
from: "USD",
90+
client: TEST_CLIENT,
91+
}),
92+
).rejects.toThrowError(
93+
`Error: ${TEST_ACCOUNT_A.address} on chainId: ${base.id} is not a valid contract address.`,
94+
);
95+
});
96+
});

0 commit comments

Comments
 (0)