diff --git a/packages/sdk/README.md b/packages/sdk/README.md new file mode 100644 index 0000000..4cdfa71 --- /dev/null +++ b/packages/sdk/README.md @@ -0,0 +1,62 @@ +# Gopher SDK + +Gopher SDK is a powerful library for interacting with blockchain networks, allowing you to retrieve information about fungible tokens, supported chains, and user balances, as well as finding solutions for asset transfers. + +## Installation + +Install the library using npm or yarn: + +```bash +npm install gopher-sdk +# or +yarn add gopher-sdk +``` + +## Usage + +You have two approaches: using the `Gopher` class or calling API endpoints directly through the SDK. + +### Using the Gopher Class + +The `Gopher` class provides a convenient interface for interacting with the blockchain using an EIP1193 provider (e.g., MetaMask). + +#### Example + +```typescript +import { Gopher } from 'gopher-sdk'; + +const gopher = new Gopher(window.ethereum); + +gopher.getUserBalances().then(console.log); +``` + +### Calling API Endpoints Directly + +Alternatively, you can call the API endpoints directly using the provided SDK functions. + +#### Example + +```typescript +import { api } from 'gopher-sdk'; + +const ownerAddress = "0x3E101Ec02e7A48D16DADE204C96bFF842E7E2519"; +const tokenSymbol = "USDC"; + +api.getUserFungibleTokens(ownerAddress, tokenSymbol).then(console.log); +``` + +### Environment Variables + +The SDK uses environment variables to configure the base URL `GOPHER_URL`. You can set this variable in your environment configuration or directly in your code. + +#### Setting Environment Variables in Code + +```typescript +import { setBaseUrl } from 'gopher-sdk'; + +setBaseUrl("http://localhost:8080"); +``` + +## Contributing + +Contributions are welcome! Please submit a pull request or open an issue to discuss any changes. diff --git a/packages/sdk/package.json b/packages/sdk/package.json index ac06d0f..fee41b5 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,7 +1,7 @@ { "name": "@chainsafe/gopher-sdk", "version": "0.0.1", - "main": "index.js", + "main": "dist/index.js", "license": "MIT", "scripts": { "build": "tsc", @@ -13,6 +13,7 @@ "devDependencies": { "@types/eslint": "^8.37.0", "@types/node": "18.15.11", + "eip1193-types": "^0.2.1", "eslint": "^8.37.0", "typescript": "^5.0.3" } diff --git a/packages/sdk/src/api.ts b/packages/sdk/src/api.ts new file mode 100644 index 0000000..cf7cab0 --- /dev/null +++ b/packages/sdk/src/api.ts @@ -0,0 +1,105 @@ +import { + Address, + Chain, + ChainID, + FailedSolution, + FungibleToken, + FungibleTokenBalance, + Solution, + SolutionOptions, + SolutionResponse, + TokenSymbol, +} from "./types"; +import { getEnv } from "./utils"; + +export let BASE_URL = getEnv( + "GOPHER_URL", + "https://gopher.test.buildwithsygma.com/" +); +export function setBaseUrl(url: string): void { + BASE_URL = url; +} + +export async function getSupportedChains(): Promise { + const url = new URL("/networks", BASE_URL); + const response = await fetch(url).then( + (response) => response.json() as unknown as { data: Chain[] } + ); + + return response.data; +} + +export async function getChainTokens( + chainID: ChainID +): Promise { + const url = new URL(`/networks/${chainID}/assets/fungible`, BASE_URL); + const response = await fetch(url).then( + (response) => response.json() as unknown as { data: FungibleToken[] } + ); + + return response.data; +} + +export async function getFungibleTokens(): Promise { + const url = new URL("/assets/fungible", BASE_URL); + const response = await fetch(url).then( + (response) => response.json() as unknown as { data: FungibleToken[] } + ); + + return response.data; +} + +export async function getFungibleToken( + token: TokenSymbol +): Promise { + const url = new URL(`/assets/fungible/${token}`, BASE_URL); + return await fetch(url).then( + (response) => response.json() as unknown as FungibleToken + ); +} + +export async function getUserFungibleTokens( + address: Address, + token: TokenSymbol +): Promise { + const url = new URL( + `/accounts/${address}/assets/fungible/${token}`, + BASE_URL + ); + const response = await fetch(url).then( + (response) => response.json() as unknown as { data: FungibleTokenBalance[] } + ); + + return response.data; +} + +export async function getSolution({ + account, + destinationChain, + token, + amount, + threshold, + whitelistedSourceChains, +}: SolutionOptions): Promise { + const url = new URL("/solutions/aggregation", BASE_URL); + + url.searchParams.set("account", account); + url.searchParams.set("destination", String(destinationChain)); + url.searchParams.set("token", token); + url.searchParams.set("amount", String(amount)); + // + if (threshold) url.searchParams.set("threshold", String(threshold)); + if (whitelistedSourceChains?.length) + url.searchParams.set( + "whitelistedSourceChains", + whitelistedSourceChains.join(",") + ); + + const response = await fetch(url).then( + (response) => + response.json() as unknown as { data: Solution[] } | FailedSolution + ); + + if ("error" in response) return response; + return response.data; +} diff --git a/packages/sdk/src/enums.ts b/packages/sdk/src/enums.ts new file mode 100644 index 0000000..7b0fa3e --- /dev/null +++ b/packages/sdk/src/enums.ts @@ -0,0 +1,3 @@ +export enum ChainType { + EVM = "evm", +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 41265e5..5839fef 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,28 +1,93 @@ -import { Chain, FungibleToken, Solution, SolutionOptions } from "./types"; +import { EIP1193Provider } from "eip1193-types"; +import { + getFungibleTokens, + getSolution, + getSupportedChains, + getUserFungibleTokens, + setBaseUrl, + BASE_URL, +} from "./api"; +import { + Address, + Chain, + FungibleToken, + FungibleTokenBalance, + SolutionOptions, + SolutionResponse, + TokenSymbol, +} from "./types"; -export async function getSupportedChains(): Promise { - return Promise.resolve([]); -} +export type * from "./types"; +export * as api from "./api"; +export * from "./enums"; -export async function getKnownFungibleTokens(): Promise { - return Promise.resolve([]); -} +class Gopher { + #provider: EIP1193Provider; + + // local "cache" + #tokens?: FungibleToken[]; + #chains?: Chain[]; + + constructor(provider: EIP1193Provider) { + this.#provider = provider; + } + + public async getAvailableTokens(): Promise { + if (!this.#tokens) this.#tokens = await getFungibleTokens(); + return this.#tokens; + } + + public async getAvailableChains(): Promise { + if (!this.#chains) this.#chains = await getSupportedChains(); + return this.#chains; + } + + public async getUserBalances(tokens?: FungibleToken[]): Promise<{ + [sybol: TokenSymbol]: { balances: FungibleTokenBalance[]; total: string }; + }> { + const account = await this.getAccount(); -export async function getSolution({ - account, - destinationChain, - token, - amount, - threshold, - whitelistedSourceChains, -}: SolutionOptions): Promise { - console.log( - account, - destinationChain, - token, - amount, - threshold, - whitelistedSourceChains - ); - return Promise.resolve([]); + const tokenList = tokens || (await this.getAvailableTokens()); + + const balances = await Promise.all( + tokenList.map((token) => + getUserFungibleTokens(account, token.symbol).then((balances) => ({ + symbol: token.symbol, + balances, + })) + ) + ); + + return balances.reduce((previousValue, { symbol, balances }) => { + previousValue[symbol] = { + total: balances + .reduce((prev, cur) => prev + BigInt(cur.balance), 0n) + .toString(), + balances, + }; + return previousValue; + }, {} as { [symbol: TokenSymbol]: { balances: FungibleTokenBalance[]; total: string } }); + } + + public async getSolution( + settings: Omit, + targetAccount?: Address + ): Promise { + const account = targetAccount || (await this.getAccount()); + + return await getSolution({ ...settings, account }); + } + + private async getAccount(): Promise
{ + const [account] = (await this.#provider.request({ + method: "eth_requestAccounts", + params: [], + })) as Address[]; + if (!account) + throw new Error("No available account! Check your provider or something"); + + return account; + } } + +export { Gopher, setBaseUrl, BASE_URL, EIP1193Provider }; diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index ad9b325..7d801aa 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -1,13 +1,76 @@ -export interface Chain {} -export interface FungibleToken {} +import { ChainType } from "./enums"; -export interface Solution {} +export type Address = `0x${string}`; + +export type TokenSymbol = string; + +export type ChainID = number; + +export interface FungibleToken { + addresses: Record; + decimals: number; + logoURI: string; + name: string; + symbol: TokenSymbol; +} + +export interface Chain { + chainID: ChainID; + chainType: ChainType; + name: string; + logoURI: string; + rpcurls: string[]; +} + +export interface FungibleTokenBalance { + balance: string /* big number as string*/; + chainId: ChainID; + tokenDecimals: number; +} export interface SolutionOptions { - account: string; - destinationChain: Chain; - token: FungibleToken; - amount: bigint; - threshold?: bigint; - whitelistedSourceChains: Chain[]; + account: Address; + destinationChain: ChainID; + token: TokenSymbol; + amount: number; + threshold?: number; + whitelistedSourceChains?: ChainID[]; +} + +interface Amount { + amount: string; + amountUSD: number; +} + +export type SolutionResponse = Solution[] | FailedSolution; + +export interface FailedSolution { + error: string; +} + +export interface Solution { + destinationChain: ChainID; + destinationTokenAddress: Address; + duration: number /* estimation duration by seconds */; + fee: Amount; + gasCost: Amount; + senderAddress: Address; + sourceChain: ChainID; + sourceTokenAddress: Address; + amount: string; + tool: { + logoURI: string; + name: string; + }; + transaction: Transaction; +} + +export interface Transaction { + chainId: ChainID; + data: string; + from: Address; + gasLimit: string; + gasPrice: string; + to: Address; + value: string; } diff --git a/packages/sdk/src/utils.ts b/packages/sdk/src/utils.ts new file mode 100644 index 0000000..6d8a563 --- /dev/null +++ b/packages/sdk/src/utils.ts @@ -0,0 +1,16 @@ +export function getEnv(envName: string, defaultValue: string): string { + let variable: string | undefined; + try { + if (typeof process !== "undefined" && "env" in process) + variable = process.env[envName]; + else if ("env" in import.meta) { + const env = + // @ts-expect-error + (import.meta.env as { [key: string]: string | undefined }) || {}; + variable = env[envName]; + } + } finally { + variable ??= defaultValue; + } + return variable; +} diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json index 6567c4b..0564f0c 100644 --- a/packages/sdk/tsconfig.json +++ b/packages/sdk/tsconfig.json @@ -11,7 +11,7 @@ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ /* Language and Environment */ - "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ // "jsx": "preserve", /* Specify what JSX code is generated. */ // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ @@ -25,9 +25,9 @@ // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ /* Modules */ - "module": "commonjs", /* Specify what module code is generated. */ + "module": "ESNext", /* Specify what module code is generated. */ "rootDir": "./src", /* Specify the root folder within your source files. */ - // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "Bundler", /* Specify how TypeScript looks up a file from a given module specifier. */ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ @@ -44,7 +44,7 @@ // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ /* Emit */ - // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ // "declarationMap": true, /* Create sourcemaps for d.ts files. */ // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ diff --git a/web/package.json b/web/package.json index eb55986..8975ca2 100644 --- a/web/package.json +++ b/web/package.json @@ -43,6 +43,7 @@ }, "type": "module", "dependencies": { + "@chainsafe/gopher-sdk": "workspace:^", "@floating-ui/dom": "^1.6.5", "date-fns": "^3.6.0", "highlight.js": "11.9.0", diff --git a/web/src/lib/components/Account.svelte b/web/src/lib/components/Account.svelte index 0260067..2039299 100644 --- a/web/src/lib/components/Account.svelte +++ b/web/src/lib/components/Account.svelte @@ -4,19 +4,13 @@ $: address = $selectedProvider.provider.request({ method: 'eth_requestAccounts', params: [] }); - export let promise: Promise; - const drawerStore = getDrawerStore(); - async function openSendDrawer() { - const data = await promise; - const drawerSettings: DrawerSettings = { id: 'SendTokens', width: 'w-[518px]', position: 'right', meta: { - ...data.raw, title: 'Send Tokens' } }; diff --git a/web/src/lib/components/DrawerManager.svelte b/web/src/lib/components/DrawerManager.svelte index e9d842d..ae3aa4a 100644 --- a/web/src/lib/components/DrawerManager.svelte +++ b/web/src/lib/components/DrawerManager.svelte @@ -7,8 +7,6 @@ const close = () => { drawerStore.close(); }; - - $: console.log($drawerStore); diff --git a/web/src/lib/components/Facepile.svelte b/web/src/lib/components/Facepile.svelte index c3d264d..f806427 100644 --- a/web/src/lib/components/Facepile.svelte +++ b/web/src/lib/components/Facepile.svelte @@ -1,19 +1,25 @@
diff --git a/web/src/lib/components/Portfolio.svelte b/web/src/lib/components/Portfolio.svelte index 5db05d2..38df635 100644 --- a/web/src/lib/components/Portfolio.svelte +++ b/web/src/lib/components/Portfolio.svelte @@ -3,25 +3,32 @@ import Facepile from '$lib/components/Facepile.svelte'; import { getModalStore, type ModalSettings } from '@skeletonlabs/skeleton'; import TokenModal from '$lib/components/TokenModal.svelte'; + import { gopher } from '$lib/stores/gopher'; - export let promise: Promise; + const modalStore = getModalStore(); - $: total = promise.then(({ tokens }) => - tokens.reduce((p, c) => p + Number(fromWei(c.total, c.decimals)), 0) - ); + const tokens = $gopher.getAvailableTokens(); + const balances = $gopher.getUserBalances(); + const chains = $gopher.getAvailableChains(); - const modalStore = getModalStore(); + $: total = balances.then((b) => + Object.values(b).reduce( + (p, c) => (p += Number(fromWei(c.total, c.balances[0].tokenDecimals))), + 0 + ) + ); async function handleListClick(index: number) { - const data = await promise; - const token = data.tokens[index]; + const networks = await chains; + const token = (await tokens)[index]; + const selectedBalances = (await balances)[token.symbol]; const modal: ModalSettings = { type: 'component', component: { ref: TokenModal }, title: token.name, buttonTextCancel: 'close', - value: { networks: data.raw.networks, balances: token.balances }, + value: { networks, balances: selectedBalances.balances }, meta: { icon: token.logoURI, sybol: token.symbol, decimals: token.decimals } }; modalStore.trigger(modal); @@ -63,7 +70,7 @@ - {#await promise} + {#await Promise.all([tokens, balances, chains])} {#each { length: 3 } as _} @@ -72,8 +79,8 @@
{/each} - {:then data} - {#each data.tokens as token, i} + {:then [tokens, balances, chains]} + {#each tokens as token, i} handleListClick(i)} @@ -89,11 +96,11 @@
- {fromWei(token.total, token.decimals)} + {fromWei(balances[token.symbol].total, token.decimals)} {token.symbol} - + {/each} diff --git a/web/src/lib/components/SendTokensDrawer.svelte b/web/src/lib/components/SendTokensDrawer.svelte index 16b5962..3036630 100644 --- a/web/src/lib/components/SendTokensDrawer.svelte +++ b/web/src/lib/components/SendTokensDrawer.svelte @@ -10,8 +10,14 @@ type DrawerSettings } from '@skeletonlabs/skeleton'; import { onMount } from 'svelte'; - import { hacks_getChainIcon } from '$lib/hacks'; import { fromWei, toWei } from 'web3-utils'; + import { gopher } from '$lib/stores/gopher'; + import { getNetworkByChainId, getTokenBySymbol } from '$lib/utils'; + import { type FungibleToken, type FungibleTokenBalance } from '@chainsafe/gopher-sdk'; + + const tokens = $gopher.getAvailableTokens(); + const allBalances = $gopher.getUserBalances(); + const chains = $gopher.getAvailableChains(); const drawerStore = getDrawerStore(); @@ -31,32 +37,36 @@ }; onMount(() => { - selectedToken = $drawerStore.meta.tokens.values().next().value.symbol; + tokens.then(([firstToken]) => { + selectedToken = firstToken.symbol; + }); }); - let balances; - let tokenInfo; - function updateWhitelistedOnTokenChange() { - tokenInfo = $drawerStore.meta.tokens.get(selectedToken); + let balances: FungibleTokenBalance[] = []; + let tokenInfo: FungibleToken; + async function updateWhitelistedOnTokenChange() { + tokenInfo = getTokenBySymbol(await tokens, selectedToken); - balances = $drawerStore.meta.balances.get(selectedToken) ?? []; - whitelisted = balances.map((balance) => balance.chainId); + balances = (await allBalances)[selectedToken].balances ?? []; + whitelisted = balances.map((balance) => String(balance.chainId)); } $: if (selectedToken) { updateWhitelistedOnTokenChange(); } - function requestQuota() { + async function requestQuota() { const drawerSettings: DrawerSettings = { id: 'SubmitQuota', width: 'w-[518px]', position: 'right', meta: { - ...$drawerStore.meta, title: 'Submit Quotas', + tokens: await tokens, + chains: await chains, + balances, quota: { token: selectedToken, - network: selectedNetwork, + destinationChain: selectedNetwork, whitelisted, amount: toWei(amount, tokenInfo.decimals), threshold: threshold ? toWei(threshold, tokenInfo.decimals) : undefined @@ -112,16 +122,20 @@
- {#each $drawerStore.meta.tokens.values() as token} - - {token.name} - {token.name} - - {/each} + {#await tokens} + Loading.... + {:then data} + {#each data as token} + + {token.name} + {token.name} + + {/each} + {/await}
@@ -147,9 +161,13 @@ class="self-stretch px-4 py-3 rounded-lg border border-zinc-200 dark:border-gray-600 flex-col justify-center items-start gap-1 flex text-zinc-800 dark:text-zinc-200 text-lg font-medium font-['Inter'] leading-relaxed bg-transparent" bind:value={selectedNetwork} > - {#each $drawerStore.meta.networks.values() as network} - - {/each} + {#await chains} + + {:then networks} + {#each networks as network} + + {/each} + {/await}
@@ -192,25 +210,24 @@ WHITELIST A NETWORK - {#each balances as balance} - - - {`${balance.chainId}-LOGO`} - - {$drawerStore.meta.networks.get(balance.chainId).name} - - {fromWei(balance.balance, tokenInfo.decimals)} - - - {/each} + {#await chains then networks} + {#each balances as balance} + {@const network = getNetworkByChainId(networks, balance.chainId)} + + + {`${balance.chainId}-LOGO`} + + {network.name} + + {fromWei(balance.balance, tokenInfo.decimals)} + + + {/each} + {/await} diff --git a/web/src/lib/components/SubmitTokensDrawer.svelte b/web/src/lib/components/SubmitTokensDrawer.svelte index 71ccaff..51c47fa 100644 --- a/web/src/lib/components/SubmitTokensDrawer.svelte +++ b/web/src/lib/components/SubmitTokensDrawer.svelte @@ -1,20 +1,23 @@
- - + +
diff --git a/yarn.lock b/yarn.lock index 8106ddb..71f1d6a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -46,12 +46,13 @@ __metadata: languageName: node linkType: hard -"@chainsafe/gopher-sdk@workspace:packages/sdk": +"@chainsafe/gopher-sdk@workspace:^, @chainsafe/gopher-sdk@workspace:packages/sdk": version: 0.0.0-use.local resolution: "@chainsafe/gopher-sdk@workspace:packages/sdk" dependencies: "@types/eslint": ^8.37.0 "@types/node": 18.15.11 + eip1193-types: ^0.2.1 eslint: ^8.37.0 typescript: ^5.0.3 languageName: unknown @@ -2228,6 +2229,13 @@ __metadata: languageName: node linkType: hard +"eip1193-types@npm:^0.2.1": + version: 0.2.1 + resolution: "eip1193-types@npm:0.2.1" + checksum: 423723535a890ab44d6b7c88815b9c8df48ec269f3386943fd37e6db55fcca11f9bc8b9fbae30f821db18c1b95d4a0631aa51d9a99a82d4f09bdcd43a766e2fc + languageName: node + linkType: hard + "electron-to-chromium@npm:^1.4.668": version: 1.4.772 resolution: "electron-to-chromium@npm:1.4.772" @@ -6543,6 +6551,7 @@ __metadata: version: 0.0.0-use.local resolution: "web@workspace:web" dependencies: + "@chainsafe/gopher-sdk": "workspace:^" "@floating-ui/dom": ^1.6.5 "@skeletonlabs/skeleton": 2.10.0 "@skeletonlabs/tw-plugin": 0.4.0