├── pnpm-workspace.yaml ├── src ├── core │ ├── tsconfig.json │ ├── README.md │ ├── tsconfig.cjs.json │ ├── tsconfig.esm.json │ ├── jest.config.ts │ ├── src │ │ ├── apis.ts │ │ ├── faucets.ts │ │ ├── types.ts │ │ ├── keypairs.ts │ │ ├── index.ts │ │ ├── amounts.ts │ │ ├── responses.ts │ │ ├── misc.ts │ │ ├── constants.ts │ │ ├── addresses.ts │ │ ├── validation.ts │ │ ├── urls.ts │ │ ├── __tests__ │ │ │ ├── balanceToString.test.ts │ │ │ ├── stringToBalance.test.ts │ │ │ ├── formatTimeDiff.test.ts │ │ │ ├── shortenAddress.test.ts │ │ │ └── formatBalance.test.ts │ │ ├── coins.ts │ │ ├── SuiEventFetcher.ts │ │ ├── guards.ts │ │ ├── objects.ts │ │ ├── rpcs.ts │ │ ├── format.ts │ │ ├── SuiMultiClient.ts │ │ ├── errors.ts │ │ ├── balances.ts │ │ ├── SuiClientBase.ts │ │ ├── clients.ts │ │ └── txs.ts │ └── package.json ├── dev │ ├── tsconfig.json │ ├── README.md │ ├── jest.config.ts │ ├── package.json │ ├── src │ │ ├── test-files.ts │ │ ├── test-urls.ts │ │ └── bump-version.ts │ └── upgrade-deps.sh ├── node │ ├── tsconfig.json │ ├── README.md │ ├── tsconfig.cjs.json │ ├── tsconfig.esm.json │ ├── jest.config.ts │ ├── src │ │ ├── index.ts │ │ ├── cli.ts │ │ ├── sui.ts │ │ └── files.ts │ └── package.json └── react │ ├── README.md │ ├── tsconfig.json │ ├── tsconfig.esm.json │ ├── tsconfig.cjs.json │ ├── jest.config.ts │ ├── src │ ├── glitch.tsx │ ├── misc.ts │ ├── types.ts │ ├── index.ts │ ├── hero.tsx │ ├── selectors.tsx │ ├── modals.tsx │ ├── hero.css │ ├── loader.tsx │ ├── cards.tsx │ ├── connect.tsx │ ├── glitch.css │ ├── modal.tsx │ ├── explorers.tsx │ ├── modal.css │ ├── rpcs.tsx │ ├── links.tsx │ ├── buttons.tsx │ ├── networks.tsx │ ├── icons.tsx │ └── hooks.ts │ └── package.json ├── .gitignore ├── jest.config.ts ├── turbo.json ├── jest.config.base.ts ├── biome.json ├── package.json ├── tsconfig.json ├── README.md └── LICENSE /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - src/* 3 | -------------------------------------------------------------------------------- /src/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /src/dev/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /src/node/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /src/core/README.md: -------------------------------------------------------------------------------- 1 | # @polymedia/suitcase-core 2 | 3 | Docs: https://github.com/juzybits/polymedia-suitcase#core 4 | -------------------------------------------------------------------------------- /src/node/README.md: -------------------------------------------------------------------------------- 1 | # @polymedia/suitcase-node 2 | 3 | Docs: https://github.com/juzybits/polymedia-suitcase#node 4 | -------------------------------------------------------------------------------- /src/react/README.md: -------------------------------------------------------------------------------- 1 | # @polymedia/suitcase-react 2 | 3 | Docs: https://github.com/juzybits/polymedia-suitcase#react 4 | -------------------------------------------------------------------------------- /src/react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "react-jsx" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/core/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "CommonJS", 5 | "outDir": "./dist/cjs" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/core/tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "ESNext", 5 | "outDir": "./dist/esm" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/node/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "CommonJS", 5 | "outDir": "./dist/cjs" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/node/tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "ESNext", 5 | "outDir": "./dist/esm" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/react/tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "ESNext", 5 | "outDir": "./dist/esm" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/react/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "CommonJS", 5 | "outDir": "./dist/cjs" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/dev/README.md: -------------------------------------------------------------------------------- 1 | # polymedia-suitcase-dev 2 | 3 | Tools to help with the development of this codebase. 4 | 5 | ``` 6 | pnpm tsx src/test-files.ts 7 | pnpm tsx src/test-urls.ts 8 | ``` 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # JavaScript 4 | .env.development.local 5 | .turbo 6 | .wrangler 7 | *.tsbuildinfo 8 | dist 9 | node_modules 10 | 11 | # Sui 12 | .coverage_map.mvcov 13 | .trace 14 | build 15 | traces 16 | -------------------------------------------------------------------------------- /src/core/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@jest/types"; 2 | import baseConfig from "../../jest.config.base"; 3 | 4 | const config: Config.InitialOptions = { 5 | ...baseConfig, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /src/dev/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@jest/types"; 2 | import baseConfig from "../../jest.config.base"; 3 | 4 | const config: Config.InitialOptions = { 5 | ...baseConfig, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /src/node/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@jest/types"; 2 | import baseConfig from "../../jest.config.base"; 3 | 4 | const config: Config.InitialOptions = { 5 | ...baseConfig, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /src/react/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@jest/types"; 2 | import baseConfig from "../../jest.config.base"; 3 | 4 | const config: Config.InitialOptions = { 5 | ...baseConfig, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /src/node/src/index.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error Property 'toJSON' does not exist on type 'BigInt' 2 | BigInt.prototype.toJSON = function () { 3 | return this.toString(); 4 | }; 5 | 6 | export * from "./cli.js"; 7 | export * from "./files.js"; 8 | export * from "./sui.js"; 9 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@jest/types"; 2 | import baseConfig from "./jest.config.base"; 3 | 4 | const config: Config.InitialOptions = { 5 | ...baseConfig, 6 | projects: ["/src/*"], 7 | verbose: true, 8 | }; 9 | 10 | export default config; 11 | -------------------------------------------------------------------------------- /src/react/src/glitch.tsx: -------------------------------------------------------------------------------- 1 | import "./glitch.css"; 2 | 3 | export const Glitch = ({ text }: { text: string }) => { 4 | return ( 5 |
6 | 7 | {text} 8 | 9 |
10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /src/react/src/misc.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Encode a URL for use in CSS `url()` syntax. 3 | */ 4 | export function makeCssUrl(url: string): string { 5 | return ( 6 | "url(" + 7 | encodeURI(url) // encode the URL 8 | .replace(/\(/g, "%28") // encode '(' 9 | .replace(/\)/g, "%29") + // encode ')' 10 | ")" 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/react/src/types.ts: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | 3 | /** 4 | * A function that updates the state of a `useState` or `useReducer` hook. 5 | */ 6 | export type ReactSetter = React.Dispatch>; 7 | 8 | /** 9 | * A function that updates some state. 10 | */ 11 | export type Setter = (value: T) => unknown; 12 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "tasks": { 4 | "build": { 5 | "dependsOn": ["^build"], 6 | "outputs": ["dist/**"] 7 | }, 8 | "clean": { 9 | "cache": false, 10 | "outputs": [] 11 | }, 12 | "dev": { 13 | "cache": false, 14 | "persistent": true 15 | }, 16 | "test": { 17 | "outputs": [] 18 | }, 19 | "typecheck": { 20 | "dependsOn": ["^typecheck"], 21 | "outputs": [] 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /jest.config.base.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@jest/types"; 2 | 3 | const baseConfig: Config.InitialOptions = { 4 | preset: "ts-jest", 5 | testEnvironment: "node", 6 | moduleFileExtensions: ["ts", "js", "json", "node"], 7 | testMatch: ["**/__tests__/**/*.test.ts"], 8 | moduleNameMapper: { 9 | "^(\\.{1,2}/.*)\\.js$": "$1", 10 | }, 11 | // verbose: true, // 'Validation Warning: Unknown option "verbose" with value true was found.' 12 | }; 13 | 14 | export default baseConfig; 15 | -------------------------------------------------------------------------------- /src/core/src/apis.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Make a request to the Indexer.xyz API (NFTs). 3 | */ 4 | export async function apiRequestIndexer( 5 | apiUser: string, 6 | apiKey: string, 7 | query: string, 8 | ): Promise { 9 | const resp = await fetch("https://api.indexer.xyz/graphql", { 10 | method: "POST", 11 | headers: { 12 | "Content-Type": "application/json", 13 | "x-api-user": apiUser, 14 | "x-api-key": apiKey, 15 | }, 16 | body: JSON.stringify({ query }), 17 | }); 18 | if (!resp.ok) { 19 | throw new Error(`HTTP error: ${resp.status}`); 20 | } 21 | return resp.json() as Promise; 22 | } 23 | -------------------------------------------------------------------------------- /src/core/src/faucets.ts: -------------------------------------------------------------------------------- 1 | import { requestSuiFromFaucetV2 } from "@mysten/sui/faucet"; 2 | 3 | /** 4 | * Get SUI from the faucet on localnet/devnet/testnet. 5 | */ 6 | export async function requestSuiFromFaucet( 7 | network: "localnet" | "devnet" | "testnet", 8 | recipient: string, 9 | ) { 10 | let host: string; 11 | if (network === "localnet") { 12 | host = "http://127.0.0.1:9123"; 13 | } else if (network === "devnet") { 14 | host = "https://faucet.devnet.sui.io"; 15 | } else { 16 | host = "https://faucet.testnet.sui.io"; 17 | } 18 | 19 | return requestSuiFromFaucetV2({ host, recipient }); 20 | } 21 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.2.0/schema.json", 3 | "vcs": { 4 | "enabled": true, 5 | "clientKind": "git", 6 | "useIgnoreFile": true 7 | }, 8 | "files": { 9 | "ignoreUnknown": true 10 | }, 11 | "formatter": { 12 | "lineWidth": 90 13 | }, 14 | "linter": { 15 | "rules": { 16 | "style": { 17 | "noNonNullAssertion": "off", 18 | "noDescendingSpecificity": "off" 19 | }, 20 | "a11y": { 21 | "noStaticElementInteractions": "off", 22 | "useKeyWithClickEvents": "off" 23 | }, 24 | "correctness": { 25 | "noChildrenProp": "off" 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/react/src/index.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error Property 'toJSON' does not exist on type 'BigInt' 2 | BigInt.prototype.toJSON = function () { 3 | return this.toString(); 4 | }; 5 | 6 | export * from "./buttons"; 7 | export * from "./cards"; 8 | export * from "./connect"; 9 | export * from "./explorers"; 10 | export * from "./glitch"; 11 | export * from "./hero"; 12 | export * from "./hooks"; 13 | export * from "./icons"; 14 | export * from "./inputs"; 15 | export * from "./links"; 16 | export * from "./loader"; 17 | export * from "./misc"; 18 | export * from "./modals"; 19 | export * from "./networks"; 20 | export * from "./rpcs"; 21 | export * from "./selectors"; 22 | export * from "./types"; 23 | -------------------------------------------------------------------------------- /src/dev/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "version": "0.0.0", 4 | "name": "polymedia-suitcase-dev", 5 | "license": "Apache-2.0", 6 | "scripts": { 7 | "build": "tsc", 8 | "clean": "rm -rf dist/ node_modules/ .turbo/", 9 | "dev": "tsc --watch", 10 | "lint:fix": "biome check --write *.* src/", 11 | "lint:unsafe": "biome check --write --unsafe *.* src/", 12 | "lint": "biome check *.* src/", 13 | "test": "jest --verbose --passWithNoTests", 14 | "typecheck": "tsc -b" 15 | }, 16 | "dependencies": { 17 | "@polymedia/suitcase-core": "workspace:*", 18 | "@polymedia/suitcase-node": "workspace:*" 19 | }, 20 | "devDependencies": { 21 | "@types/node": "^24.3.1" 22 | }, 23 | "type": "module", 24 | "sideEffects": false 25 | } 26 | -------------------------------------------------------------------------------- /src/dev/src/test-files.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ParseLine, 3 | readCsvFile, 4 | readTsvFile, 5 | writeCsvFile, 6 | writeTsvFile, 7 | } from "@polymedia/suitcase-node"; 8 | 9 | const inputData = [ 10 | ["r0c0", 'r0"c1', "r0\nc2"], 11 | ["r1\tc0", "r1,c1", "r1c2"], 12 | ["r2c0", "r2c1", "r2c2"], 13 | ]; 14 | type DataLine = [string, string, string]; 15 | const parseLine: ParseLine = (values) => [values[0], values[1], values[2]]; 16 | 17 | const csvFile = "test.csv"; 18 | writeCsvFile(csvFile, inputData); 19 | const csvData = readCsvFile(csvFile, parseLine); 20 | console.log(csvData); 21 | 22 | const tsvFile = "test.tsv"; 23 | writeTsvFile(tsvFile, inputData); 24 | const tsvData = readTsvFile(tsvFile, parseLine); 25 | console.log(tsvData); 26 | -------------------------------------------------------------------------------- /src/dev/upgrade-deps.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o nounset # Treat unset variables as an error when substituting 4 | set -o errexit # Exit immediately if any command returns a non-zero status 5 | set -o pipefail # Prevent errors in a pipeline from being masked 6 | # set -o xtrace # Print each command to the terminal before execution 7 | 8 | SCRIPT_DIR="$( dirname "$(readlink -f "${BASH_SOURCE[0]}")" )" 9 | PATH_PROJECT="$( cd "$SCRIPT_DIR/../.." && pwd )" 10 | 11 | JEST_VERSION="29" 12 | 13 | cd $PATH_PROJECT 14 | pnpm up --latest --recursive 15 | pnpm up --latest -w 16 | 17 | cd $PATH_PROJECT 18 | pnpm add -D @jest/types@$JEST_VERSION @types/jest@$JEST_VERSION jest@$JEST_VERSION ts-jest@$JEST_VERSION 19 | 20 | # cd $PATH_PROJECT 21 | # pnpm up --recursive 22 | # pnpm up -w 23 | -------------------------------------------------------------------------------- /src/react/src/hero.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import { Glitch } from "./glitch"; 3 | import "./hero.css"; 4 | 5 | export const HeroBanner = ({ 6 | title, 7 | subtitle, 8 | description, 9 | actions, 10 | extra, 11 | }: { 12 | title: string; 13 | subtitle?: ReactNode; 14 | description?: ReactNode; 15 | actions?: ReactNode; 16 | extra?: ReactNode; 17 | }) => { 18 | return ( 19 |
20 | 21 | {subtitle && ( 22 |
23 |

{subtitle}

24 |
25 | )} 26 | {description && ( 27 |
28 |

{description}

29 |
30 | )} 31 | {actions &&
{actions}
} 32 | {extra} 33 |
34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/core/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Transaction } from "@mysten/sui/transactions"; 2 | 3 | export const NETWORK_NAMES = ["mainnet", "testnet", "devnet", "localnet"] as const; 4 | 5 | export type NetworkName = (typeof NETWORK_NAMES)[number]; 6 | 7 | /** 8 | * A paginated response from a Sui RPC call. 9 | * 10 | * @template T The type of data returned by the fetch function 11 | * @template C The type of cursor used to paginate through the data 12 | */ 13 | export type PaginatedResponse = { 14 | data: T[]; 15 | hasNextPage: boolean; 16 | nextCursor: C; 17 | }; 18 | 19 | export const EmptyPaginatedResponse: PaginatedResponse = { 20 | data: [], 21 | hasNextPage: false, 22 | nextCursor: undefined, 23 | }; 24 | 25 | /** 26 | * The return type of `Transaction.receivingRef()`. 27 | */ 28 | export type ReceivingRef = ReturnType["receivingRef"]>; 29 | -------------------------------------------------------------------------------- /src/core/src/keypairs.ts: -------------------------------------------------------------------------------- 1 | import { decodeSuiPrivateKey, type Keypair } from "@mysten/sui/cryptography"; 2 | import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519"; 3 | import { Secp256k1Keypair } from "@mysten/sui/keypairs/secp256k1"; 4 | import { Secp256r1Keypair } from "@mysten/sui/keypairs/secp256r1"; 5 | 6 | /** 7 | * Build a `Keypair` from a secret key string like `suiprivkey1...`. 8 | */ 9 | export function pairFromSecretKey(secretKey: string): Keypair { 10 | const pair = decodeSuiPrivateKey(secretKey); 11 | 12 | if (pair.scheme === "ED25519") { 13 | return Ed25519Keypair.fromSecretKey(pair.secretKey); 14 | } 15 | if (pair.scheme === "Secp256k1") { 16 | return Secp256k1Keypair.fromSecretKey(pair.secretKey); 17 | } 18 | if (pair.scheme === "Secp256r1") { 19 | return Secp256r1Keypair.fromSecretKey(pair.secretKey); 20 | } 21 | 22 | throw new Error(`Unrecognized keypair schema: ${pair.scheme}`); 23 | } 24 | -------------------------------------------------------------------------------- /src/core/src/index.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error Property 'toJSON' does not exist on type 'BigInt' 2 | BigInt.prototype.toJSON = function () { 3 | return this.toString(); 4 | }; 5 | 6 | export * from "./addresses.js"; 7 | export * from "./amounts.js"; 8 | export * from "./apis.js"; 9 | export * from "./balances.js"; 10 | export * from "./clients.js"; 11 | export * from "./coins.js"; 12 | export * from "./constants.js"; 13 | export * from "./errors.js"; 14 | export * from "./faucets.js"; 15 | export * from "./format.js"; 16 | export * from "./guards.js"; 17 | export * from "./keypairs.js"; 18 | export * from "./misc.js"; 19 | export * from "./objects.js"; 20 | export * from "./responses.js"; 21 | export * from "./rpcs.js"; 22 | export * from "./SuiClientBase.js"; 23 | export * from "./SuiEventFetcher.js"; 24 | export * from "./SuiMultiClient.js"; 25 | export * from "./txs.js"; 26 | export * from "./types.js"; 27 | export * from "./urls.js"; 28 | export * from "./validation.js"; 29 | -------------------------------------------------------------------------------- /src/node/src/cli.ts: -------------------------------------------------------------------------------- 1 | import { createInterface } from "node:readline"; 2 | 3 | /** 4 | * Parse command line arguments and show usage instructions. 5 | */ 6 | export function parseArguments( 7 | expectedArgs: number, 8 | usageMessage: string, 9 | ): string[] | null { 10 | const args = process.argv.slice(2); // skip `node` and the script name 11 | if (args.length !== expectedArgs || args.includes("-h") || args.includes("--help")) { 12 | console.log(usageMessage); 13 | return null; 14 | } 15 | return args; 16 | } 17 | 18 | /** 19 | * Display a query to the user and wait for their input. Return true if the user enters `y`. 20 | */ 21 | export async function promptUser( 22 | question: string = "\nDoes this look okay? (y/n) ", 23 | ): Promise { 24 | return new Promise((resolve) => { 25 | const rl = createInterface({ 26 | input: process.stdin, 27 | output: process.stdout, 28 | }); 29 | 30 | rl.question(question, (answer) => { 31 | rl.close(); 32 | resolve(answer.toLowerCase() === "y"); 33 | }); 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /src/core/src/amounts.ts: -------------------------------------------------------------------------------- 1 | // TODO: add to README.md 2 | import { TimeUnit } from "./format.js"; 3 | 4 | // === UI -> onchain === 5 | 6 | export function percentToBps(pct: string): bigint { 7 | return BigInt(Math.floor(Number(pct) * 100)); 8 | } 9 | 10 | export function minutesToMs(minutes: string): bigint { 11 | return BigInt(Math.floor(Number(minutes) * TimeUnit.ONE_MINUTE)); 12 | } 13 | 14 | export function hoursToMs(hours: string): bigint { 15 | return BigInt(Math.floor(Number(hours) * TimeUnit.ONE_HOUR)); 16 | } 17 | 18 | // === onchain -> UI === 19 | 20 | export function bpsToPercent(bps: bigint, decimals: number = 2): string { 21 | const result = Number(bps) / 100; 22 | return result.toFixed(decimals); 23 | } 24 | 25 | export function msToMinutes(ms: bigint, decimals: number = 2): string { 26 | const result = Number(ms) / TimeUnit.ONE_MINUTE; 27 | return result.toFixed(decimals); 28 | } 29 | 30 | export function msToHours(ms: bigint, decimals: number = 2): string { 31 | const result = Number(ms) / TimeUnit.ONE_HOUR; 32 | return result.toFixed(decimals); 33 | } 34 | -------------------------------------------------------------------------------- /src/react/src/selectors.tsx: -------------------------------------------------------------------------------- 1 | export type RadioOption = { 2 | value: T; 3 | label: React.ReactNode; 4 | }; 5 | 6 | export type RadioSelectorProps = { 7 | options: RadioOption[]; 8 | selectedValue: T; 9 | onSelect: (value: T) => void; 10 | className?: string; 11 | }; 12 | 13 | /** 14 | * A generic radio button menu. 15 | */ 16 | export function RadioSelector({ 17 | options, 18 | selectedValue, 19 | onSelect, 20 | className = "", 21 | }: RadioSelectorProps) { 22 | return ( 23 |
24 | {options.map((option) => ( 25 |
26 | 36 |
37 | ))} 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/core/src/responses.ts: -------------------------------------------------------------------------------- 1 | // TODO: add to README.md 2 | import type { SuiObjectChange, SuiTransactionBlockResponse } from "@mysten/sui/client"; 3 | 4 | export type SuiObjectChangeExceptPublished = Extract< 5 | SuiObjectChange, 6 | { objectId: string } 7 | >; 8 | 9 | function isSuiObjectChangeExceptPublished( 10 | o: SuiObjectChange, 11 | ): o is SuiObjectChangeExceptPublished { 12 | return "objectId" in o; 13 | } 14 | 15 | /** 16 | * Extract selected object changes from a tx response. 17 | * 18 | * @param resp - The tx response to extract objects from. 19 | * @param kind - The kind of object change to extract. 20 | * @param typeRegex - Regular expression to match the object type. 21 | */ 22 | export function respToObjs(a: { 23 | resp: SuiTransactionBlockResponse; 24 | kind: SuiObjectChangeExceptPublished["type"]; 25 | regex?: RegExp; 26 | }): SuiObjectChangeExceptPublished[] { 27 | if (!a.resp.objectChanges) { 28 | throw new Error("No object changes"); 29 | } 30 | return a.resp.objectChanges 31 | .filter(isSuiObjectChangeExceptPublished) 32 | .filter((o) => o.type === a.kind && (a.regex ? a.regex.test(o.objectType) : true)); 33 | } 34 | -------------------------------------------------------------------------------- /src/react/src/modals.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react"; 2 | 3 | import { useClickOutside } from "./hooks"; 4 | import { IconClose } from "./icons"; 5 | 6 | export const Modal: React.FC<{ 7 | onClose: () => void; 8 | children: React.ReactNode; 9 | }> = ({ onClose, children }) => { 10 | const modalContentRef = useRef(null); 11 | 12 | useClickOutside(modalContentRef, onClose); 13 | 14 | useEffect(() => { 15 | const handleEscapeKey = (event: KeyboardEvent) => { 16 | if (event.key === "Escape") { 17 | onClose(); 18 | } 19 | }; 20 | 21 | document.addEventListener("keydown", handleEscapeKey); 22 | 23 | return () => { 24 | document.removeEventListener("keydown", handleEscapeKey); 25 | }; 26 | }, [onClose]); 27 | 28 | if (!React.Children.count(children)) { 29 | return null; 30 | } 31 | 32 | return ( 33 |
34 |
35 |
{children}
36 | 37 |
38 |
39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /src/react/src/hero.css: -------------------------------------------------------------------------------- 1 | .hero-banner { 2 | max-width: var(--max-width-page-regular); 3 | margin: 0 auto; 4 | display: flex; 5 | flex-direction: column; 6 | width: 100%; 7 | align-items: center; 8 | justify-content: center; 9 | text-align: center; 10 | gap: 1.5rem; 11 | padding: 4.5rem 2.5rem; 12 | @media (max-width: 700px) { 13 | /* dup: screen-medium */ 14 | padding-left: 2rem; 15 | padding-right: 2rem; 16 | } 17 | border-bottom: 1px solid var(--color-border-gray); 18 | background: linear-gradient( 19 | to right, 20 | rgb(20 22 24 / 0%), 21 | rgb(20 22 24 / 100%), 22 | rgb(20 22 24 / 0%) 23 | ); 24 | .glitch, 25 | .glitch span { 26 | font-size: 4rem; 27 | font-weight: bold; 28 | letter-spacing: 0.05em; 29 | } 30 | .hero-description { 31 | padding: 1rem 0; 32 | line-height: 1.35em; 33 | } 34 | } 35 | 36 | /* .dev-only-open-graph { 37 | padding: 2rem; 38 | .hero-banner { 39 | height: 600px; 40 | width: 1200px; 41 | border-radius: 0; 42 | .glitch, 43 | .glitch span { 44 | font-size: 11rem; 45 | animation-play-state: paused; 46 | } 47 | .hero-subtitle { 48 | font-size: 2rem; 49 | } 50 | .hero-description, 51 | .hero-actions { 52 | display: none; 53 | } 54 | } 55 | } */ 56 | -------------------------------------------------------------------------------- /src/react/src/loader.tsx: -------------------------------------------------------------------------------- 1 | import type { UseFetchResult } from "."; 2 | import { CardMsg, CardSpinner } from "./cards"; 3 | import type { UseFetchAndPaginateResult } from "./hooks"; 4 | 5 | export const Loader = ({ 6 | name, 7 | fetch, 8 | children, 9 | }: { 10 | name: string; 11 | fetch: UseFetchResult; 12 | children: (data: NonNullable) => React.ReactNode; 13 | }) => { 14 | if (fetch.err !== null) return {fetch.err}; 15 | if (fetch.isLoading || fetch.data === undefined) return ; 16 | if (fetch.data === null) return {name} not found; 17 | return <>{children(fetch.data)}; 18 | }; 19 | 20 | export const LoaderPaginated = ({ 21 | fetch, 22 | children, 23 | msgErr, 24 | msgEmpty, 25 | }: { 26 | fetch: UseFetchAndPaginateResult; 27 | children: (fetch: UseFetchAndPaginateResult) => React.ReactNode; 28 | msgErr?: React.ReactNode; 29 | msgEmpty?: React.ReactNode; 30 | }) => { 31 | if (fetch.err !== null) return {msgErr ?? fetch.err}; 32 | 33 | if (fetch.page.length === 0) { 34 | return fetch.isLoading ? ( 35 | 36 | ) : ( 37 | {msgEmpty ?? "None found"} 38 | ); 39 | } 40 | 41 | return <>{children(fetch)}; 42 | }; 43 | -------------------------------------------------------------------------------- /src/core/src/misc.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Split an array into multiple chunks of a certain size. 3 | */ 4 | export function chunkArray(array: T[], chunkSize: number): T[][] { 5 | const chunks: T[][] = []; 6 | for (let i = 0; i < array.length; i += chunkSize) { 7 | const chunk = array.slice(i, i + chunkSize); 8 | chunks.push(chunk); 9 | } 10 | return chunks; 11 | } 12 | 13 | /** 14 | * Split a string into multiple chunks of a certain size. 15 | */ 16 | export function chunkString(input: string, chunkSize: number): string[] { 17 | const chunks = []; 18 | for (let i = 0; i < input.length; i += chunkSize) { 19 | chunks.push(input.slice(i, i + chunkSize)); 20 | } 21 | return chunks; 22 | } 23 | 24 | /** 25 | * Generate an array of ranges of a certain size between two numbers. 26 | * 27 | * For example, calling `makeRanges(0, 678, 250)` will return: 28 | * ``` 29 | * [ [ 0, 250 ], [ 250, 500 ], [ 500, 678 ] ] 30 | * ``` 31 | */ 32 | export function makeRanges(from: number, to: number, size: number): number[][] { 33 | const ranges: number[][] = []; 34 | for (let start = from; start < to; start += size) { 35 | const end = Math.min(start + size, to); 36 | ranges.push([start, end]); 37 | } 38 | return ranges; 39 | } 40 | 41 | /** 42 | * Wait for a number of milliseconds. 43 | */ 44 | export async function sleep(ms: number): Promise { 45 | return new Promise((resolve) => setTimeout(resolve, ms)); 46 | } 47 | -------------------------------------------------------------------------------- /src/core/src/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The maximum value for a 64-bit unsigned integer. 3 | */ 4 | export const MAX_U64 = 18446744073709551615n; 5 | 6 | /** 7 | * The normalized 0x0 address (0x000…000) 8 | */ 9 | export const NORMALIZED_0x0_ADDRESS = 10 | "0x0000000000000000000000000000000000000000000000000000000000000000"; 11 | 12 | /** 13 | * The normalized SUI type (0x000…002::sui::SUI) 14 | */ 15 | export const NORMALIZED_SUI_TYPE = 16 | "0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI"; 17 | 18 | /** 19 | * Match a Sui address. 20 | */ 21 | export const REGEX_ADDRESS = /0[xX][0-9a-fA-F]{1,64}/; 22 | 23 | /** 24 | * Match a normalized Sui address. 25 | */ 26 | export const REGEX_ADDRESS_NORMALIZED = /0[xX][a-fA-F0-9]{64}/; 27 | 28 | /** 29 | * Match a Sui module name. 30 | */ 31 | export const REGEX_MODULE_NAME = /[A-Za-z][A-Za-z0-9_]*/; 32 | 33 | /** 34 | * Match a Sui struct name. 35 | */ 36 | export const REGEX_STRUCT_NAME = /[A-Z][a-zA-Z0-9_]*/; 37 | 38 | /** 39 | * Match a Sui type without generic parameters (e.g. `0x123::module::Struct`). 40 | */ 41 | export const REGEX_TYPE_BASIC = new RegExp( 42 | `${REGEX_ADDRESS.source}::${REGEX_MODULE_NAME.source}::${REGEX_STRUCT_NAME.source}`, 43 | ); 44 | 45 | /** 46 | * Maximum number of results returned by a single Sui RPC request. 47 | * (see sui/crates/sui-json-rpc-api/src/lib.rs) 48 | */ 49 | export const RPC_QUERY_MAX_RESULTS = 50; 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "version": "0.0.0", 4 | "name": "@polymedia/suitcase-monorepo", 5 | "author": "@juzybits (https://polymedia.app)", 6 | "homepage": "https://github.com/juzybits/polymedia-suitcase", 7 | "description": "Sui utilities for TypeScript, Node, and React", 8 | "license": "Apache-2.0", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/juzybits/polymedia-suitcase" 12 | }, 13 | "keywords": [], 14 | "scripts": { 15 | "build": "turbo run build", 16 | "bump-version": "node src/dev/dist/bump-version.js", 17 | "clean": "turbo run clean && rm -rf dist/ node_modules/ .turbo/ src/sui*/build/ src/sui*/.coverage_map.mvcov src/sui*/.trace", 18 | "dev": "turbo run dev", 19 | "lint:fix": "biome check --write *.* src/", 20 | "lint:unsafe": "biome check --write --unsafe *.* src/", 21 | "lint": "biome check *.* src/", 22 | "publish-all": "pnpm clean && pnpm i && pnpm build && pnpm publish -r --filter=./src/*", 23 | "test": "turbo run test", 24 | "test:watch": "jest --watch", 25 | "typecheck": "turbo run typecheck" 26 | }, 27 | "devDependencies": { 28 | "@biomejs/biome": "^2.2.3", 29 | "@jest/types": "^29.6.3", 30 | "@types/jest": "^29.5.14", 31 | "jest": "^29.7.0", 32 | "ts-jest": "^29.4.1", 33 | "ts-node": "^10.9.2", 34 | "turbo": "^2.5.6", 35 | "typescript": "^5.9.2" 36 | }, 37 | "engines": { 38 | "node": ">=18" 39 | }, 40 | "packageManager": "pnpm@10.12.1" 41 | } 42 | -------------------------------------------------------------------------------- /src/react/src/cards.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | 3 | export const Card = ({ 4 | className, 5 | children, 6 | ...props 7 | }: { 8 | children: ReactNode; 9 | } & React.HTMLAttributes) => { 10 | return ( 11 |
12 | {children} 13 |
14 | ); 15 | }; 16 | 17 | export const CardSpinner = ({ className = "compact" }: { className?: string }) => { 18 | return ( 19 |
20 | 21 |
22 | 23 |
24 | ); 25 | }; 26 | 27 | export const CardMsg = ({ 28 | className = "compact", 29 | children, 30 | }: { 31 | className?: string; 32 | children: ReactNode; 33 | }) => { 34 | return ( 35 |
36 | {children} 37 |
38 | ); 39 | }; 40 | 41 | const FullCardMsg = ({ children }: { children: ReactNode }) => { 42 | return ( 43 |
44 |
{children}
45 |
46 | ); 47 | }; 48 | 49 | export const CardDetail = ({ 50 | label, 51 | val, 52 | className, 53 | }: { 54 | label: string; 55 | val: ReactNode; 56 | className?: string; 57 | }) => { 58 | return ( 59 |
60 | {label} 61 | {val} 62 |
63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /src/react/src/connect.tsx: -------------------------------------------------------------------------------- 1 | import { useCurrentAccount } from "@mysten/dapp-kit"; 2 | import type { ReactNode } from "react"; 3 | import { Btn } from "./buttons"; 4 | 5 | export type BtnConnectProps = { 6 | btnMsg?: string | undefined; 7 | wrap?: boolean | undefined; 8 | openConnectModal: () => void; 9 | }; 10 | 11 | export type ConnectToGetStartedProps = { 12 | connectMsg?: string | undefined; 13 | } & BtnConnectProps; 14 | 15 | export type ConnectOrProps = { 16 | children: ReactNode; 17 | } & ConnectToGetStartedProps; 18 | 19 | export const BtnConnect = ({ btnMsg, wrap, openConnectModal }: BtnConnectProps) => { 20 | return ( 21 | Promise.resolve(openConnectModal())} wrap={wrap}> 22 | {btnMsg ?? "CONNECT"} 23 | 24 | ); 25 | }; 26 | 27 | export const ConnectToGetStarted = ({ 28 | btnMsg, 29 | wrap, 30 | connectMsg, 31 | openConnectModal, 32 | }: ConnectToGetStartedProps) => { 33 | return ( 34 | <> 35 | {connectMsg &&
{connectMsg}
} 36 | 37 | 38 | ); 39 | }; 40 | 41 | export const ConnectOr = ({ 42 | btnMsg, 43 | wrap, 44 | connectMsg, 45 | openConnectModal, 46 | children, 47 | }: ConnectOrProps) => { 48 | const currAcct = useCurrentAccount(); 49 | if (currAcct) { 50 | return children; 51 | } 52 | return ( 53 | 59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /src/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": false, 3 | "version": "0.0.67", 4 | "name": "@polymedia/suitcase-core", 5 | "author": "@juzybits (https://polymedia.app)", 6 | "homepage": "https://github.com/juzybits/polymedia-suitcase", 7 | "description": "Sui TypeScript utilities", 8 | "license": "Apache-2.0", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/juzybits/polymedia-suitcase" 12 | }, 13 | "keywords": [ 14 | "polymedia", 15 | "sui", 16 | "suitcase", 17 | "core" 18 | ], 19 | "scripts": { 20 | "build": "tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json", 21 | "clean": "rm -rf dist/ node_modules/ .turbo/ .wrangler/", 22 | "dev": "tsc --watch -p tsconfig.esm.json", 23 | "lint:fix": "biome check --write *.* src/", 24 | "lint:unsafe": "biome check --write --unsafe *.* src/", 25 | "lint": "biome check *.* src/", 26 | "test": "jest --verbose --passWithNoTests", 27 | "typecheck": "tsc -p tsconfig.esm.json" 28 | }, 29 | "peerDependencies": { 30 | "@mysten/sui": "^1.37.6" 31 | }, 32 | "devDependencies": {}, 33 | "type": "module", 34 | "sideEffects": false, 35 | "publishConfig": { 36 | "access": "public" 37 | }, 38 | "files": [ 39 | "dist/" 40 | ], 41 | "types": "./dist/esm/index.d.ts", 42 | "module": "./dist/esm/index.js", 43 | "main": "./dist/cjs/index.js", 44 | "exports": { 45 | ".": { 46 | "import": "./dist/esm/index.js", 47 | "require": "./dist/cjs/index.js" 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/react/src/glitch.css: -------------------------------------------------------------------------------- 1 | .glitch { 2 | display: inline-block; 3 | position: relative; 4 | text-shadow: 5 | 0.05em 0 0 #00fffc, 6 | -0.03em -0.04em 0 #fc00ff, 7 | 0.025em 0.04em 0 #fffc00; 8 | animation: glitch 725ms infinite; 9 | user-select: none; 10 | color: white; 11 | } 12 | 13 | .glitch span { 14 | position: absolute; 15 | top: 0; 16 | left: 0; 17 | color: white; 18 | } 19 | 20 | .glitch span:first-child { 21 | animation: glitch 500ms infinite; 22 | clip-path: polygon(0 0, 100% 0, 100% 35%, 0 35%); 23 | transform: translate(-0.04em, -0.03em); 24 | opacity: 0.75; 25 | } 26 | 27 | .glitch span:last-child { 28 | animation: glitch 375ms infinite; 29 | clip-path: polygon(0 65%, 100% 65%, 100% 100%, 0 100%); 30 | transform: translate(0.04em, 0.03em); 31 | opacity: 0.75; 32 | } 33 | 34 | @keyframes glitch { 35 | 0% { 36 | text-shadow: 37 | 0.05em 0 0 #00fffc, 38 | -0.03em -0.04em 0 #fc00ff, 39 | 0.025em 0.04em 0 #fffc00; 40 | } 41 | 15% { 42 | text-shadow: 43 | 0.05em 0 0 #00fffc, 44 | -0.03em -0.04em 0 #fc00ff, 45 | 0.025em 0.04em 0 #fffc00; 46 | } 47 | 16% { 48 | text-shadow: 49 | -0.05em -0.025em 0 #00fffc, 50 | 0.025em 0.035em 0 #fc00ff, 51 | -0.05em -0.05em 0 #fffc00; 52 | } 53 | 49% { 54 | text-shadow: 55 | -0.05em -0.025em 0 #00fffc, 56 | 0.025em 0.035em 0 #fc00ff, 57 | -0.05em -0.05em 0 #fffc00; 58 | } 59 | 50% { 60 | text-shadow: 61 | 0.05em 0.035em 0 #00fffc, 62 | 0.03em 0 0 #fc00ff, 63 | 0 -0.04em 0 #fffc00; 64 | } 65 | 99% { 66 | text-shadow: 67 | 0.05em 0.035em 0 #00fffc, 68 | 0.03em 0 0 #fc00ff, 69 | 0 -0.04em 0 #fffc00; 70 | } 71 | 100% { 72 | text-shadow: 73 | -0.05em 0 0 #00fffc, 74 | -0.025em -0.04em 0 #fc00ff, 75 | -0.04em -0.025em 0 #fffc00; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/node/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": false, 3 | "version": "0.0.67", 4 | "name": "@polymedia/suitcase-node", 5 | "author": "@juzybits (https://polymedia.app)", 6 | "homepage": "https://github.com/juzybits/polymedia-suitcase", 7 | "description": "Sui command line utilities", 8 | "license": "Apache-2.0", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/juzybits/polymedia-suitcase" 12 | }, 13 | "keywords": [ 14 | "polymedia", 15 | "sui", 16 | "suitcase", 17 | "node" 18 | ], 19 | "scripts": { 20 | "build": "tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json", 21 | "clean": "rm -rf dist/ node_modules/ .turbo/", 22 | "dev": "tsc --watch -p tsconfig.esm.json", 23 | "lint:fix": "biome check --write *.* src/", 24 | "lint:unsafe": "biome check --write --unsafe *.* src/", 25 | "lint": "biome check *.* src/", 26 | "test": "jest --verbose --passWithNoTests", 27 | "typecheck": "tsc -p tsconfig.esm.json" 28 | }, 29 | "dependencies": { 30 | "@polymedia/suitcase-core": "workspace:*" 31 | }, 32 | "peerDependencies": { 33 | "@mysten/sui": "^1.37.6" 34 | }, 35 | "devDependencies": { 36 | "@types/node": "^24.3.1" 37 | }, 38 | "type": "module", 39 | "sideEffects": false, 40 | "publishConfig": { 41 | "access": "public" 42 | }, 43 | "files": [ 44 | "dist/" 45 | ], 46 | "types": "./dist/esm/index.d.ts", 47 | "module": "./dist/esm/index.js", 48 | "main": "./dist/cjs/index.js", 49 | "exports": { 50 | ".": { 51 | "import": "./dist/esm/index.js", 52 | "require": "./dist/cjs/index.js" 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/react/src/modal.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from "react"; 2 | import "@/comp/modal.css"; 3 | 4 | type ModalProps = { 5 | open: boolean; 6 | onClose: () => void; 7 | children: React.ReactNode; 8 | title?: React.ReactNode; 9 | confirmBeforeClose?: boolean; 10 | confirmMessage?: string; 11 | }; 12 | 13 | export const Modal: React.FC = ({ 14 | open, 15 | onClose, 16 | children, 17 | title, 18 | confirmBeforeClose = false, 19 | confirmMessage = "Are you sure you want to close? Your progress will be lost.", 20 | }) => { 21 | const handleClose = useCallback(() => { 22 | if (confirmBeforeClose && !confirm(confirmMessage)) { 23 | return; // User clicked "Cancel", don't close 24 | } 25 | onClose(); 26 | }, [confirmBeforeClose, confirmMessage, onClose]); 27 | 28 | useEffect(() => { 29 | const handleEscapeKey = (event: KeyboardEvent) => { 30 | if (event.key === "Escape") { 31 | handleClose(); 32 | } 33 | }; 34 | 35 | if (open) { 36 | document.addEventListener("keydown", handleEscapeKey); 37 | // prevent body scroll when modal is open 38 | document.body.style.overflow = "hidden"; 39 | } 40 | 41 | return () => { 42 | document.removeEventListener("keydown", handleEscapeKey); 43 | document.body.style.overflow = "unset"; 44 | }; 45 | }, [open, handleClose]); 46 | 47 | if (!open) { 48 | return null; 49 | } 50 | 51 | return ( 52 |
53 |
e.stopPropagation()}> 54 |
55 | {title &&

{title}

} 56 | 59 |
60 |
{children}
61 |
62 |
63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /src/dev/src/test-urls.ts: -------------------------------------------------------------------------------- 1 | import { 2 | makePolymediaUrl, 3 | makeSuiscanUrl, 4 | makeSuivisionUrl, 5 | type NetworkName, 6 | type SuiExplorerItem, 7 | } from "@polymedia/suitcase-core"; 8 | 9 | type Datum = [NetworkName, SuiExplorerItem, string]; 10 | const inputData: Datum[] = [ 11 | [ 12 | "mainnet", 13 | "address", 14 | "0xa69a1e0714a16c7ce1cf768a22b78ac5b1fdb488eb3fb0365c5efa95ba2f67cc", 15 | ], 16 | ["mainnet", "tx", "RrCxVkdxRp2jYfBG8esSrQncALPHjMkLqyBa8N4onoH"], 17 | [ 18 | "mainnet", 19 | "package", 20 | "0x30a644c3485ee9b604f52165668895092191fcaf5489a846afa7fc11cdb9b24a", 21 | ], 22 | [ 23 | "mainnet", 24 | "object", 25 | "0x71d2211afbb63a83efc9050ded5c5bb7e58882b17d872e32e632a978ab7b5700", 26 | ], 27 | [ 28 | "mainnet", 29 | "coin", 30 | "0x30a644c3485ee9b604f52165668895092191fcaf5489a846afa7fc11cdb9b24a::spam::SPAM", 31 | ], 32 | 33 | [ 34 | "testnet", 35 | "address", 36 | "0xa69a1e0714a16c7ce1cf768a22b78ac5b1fdb488eb3fb0365c5efa95ba2f67cc", 37 | ], 38 | ["testnet", "tx", "9T23wRiawSutAJwsYjFYhKDZvL5cE8wp5PCYQH47r7GG"], 39 | [ 40 | "testnet", 41 | "package", 42 | "0xb0783634bd4aeb2c97d3e707fce338c94d135d72e1cb701ca220b34f7b18b877", 43 | ], 44 | [ 45 | "testnet", 46 | "object", 47 | "0x6f0919d420bcfd5156534e864f0ec99ef8f1137ba59f44d4a39edca73e7ae464", 48 | ], 49 | [ 50 | "testnet", 51 | "coin", 52 | "0xb0783634bd4aeb2c97d3e707fce338c94d135d72e1cb701ca220b34f7b18b877::spam::SPAM", 53 | ], 54 | ]; 55 | 56 | for (const data of inputData) { 57 | const polymediaUrl = makePolymediaUrl(data[0], data[1], data[2]); 58 | console.log(polymediaUrl); 59 | } 60 | 61 | for (const data of inputData) { 62 | const suiscanUrl = makeSuiscanUrl(data[0], data[1], data[2]); 63 | console.log(suiscanUrl); 64 | } 65 | 66 | for (const data of inputData) { 67 | const suivisionUrl = makeSuivisionUrl(data[0], data[1], data[2]); 68 | console.log(suivisionUrl); 69 | } 70 | -------------------------------------------------------------------------------- /src/react/src/explorers.tsx: -------------------------------------------------------------------------------- 1 | import { type RadioOption, RadioSelector } from "./selectors"; 2 | 3 | export const EXPLORER_NAMES = ["Polymedia", "Suiscan", "SuiVision"] as const; 4 | 5 | export type ExplorerName = (typeof EXPLORER_NAMES)[number]; 6 | 7 | /** 8 | * A radio button menu to select a Sui explorer and save the choice to local storage. 9 | */ 10 | export const ExplorerRadioSelector: React.FC<{ 11 | selectedExplorer: ExplorerName; 12 | onSwitch: (newExplorer: ExplorerName) => void; 13 | className?: string; 14 | }> = ({ selectedExplorer, onSwitch, className = "" }) => { 15 | const options: RadioOption[] = EXPLORER_NAMES.map((explorer) => ({ 16 | value: explorer, 17 | label: explorer, 18 | })); 19 | 20 | const onSelect = (newExplorer: ExplorerName) => { 21 | switchExplorer(newExplorer, onSwitch); 22 | }; 23 | 24 | return ( 25 | 31 | ); 32 | }; 33 | 34 | /** 35 | * Load the chosen Sui explorer name from local storage. 36 | */ 37 | export function loadExplorer(defaultExplorer: ExplorerName): ExplorerName { 38 | const explorer = localStorage.getItem("polymedia.explorer"); 39 | if (isExplorerName(explorer)) { 40 | return explorer; 41 | } 42 | return defaultExplorer; 43 | } 44 | 45 | /** 46 | * Change the chosen Sui explorer, update local storage, and optionally trigger a callback. 47 | */ 48 | export function switchExplorer( 49 | newExplorer: ExplorerName, 50 | onSwitch?: (newExplorer: ExplorerName) => void, 51 | ): void { 52 | localStorage.setItem("polymedia.explorer", newExplorer); 53 | if (onSwitch) { 54 | onSwitch(newExplorer); 55 | } else { 56 | window.location.reload(); 57 | } 58 | } 59 | 60 | function isExplorerName(value: string | null): value is ExplorerName { 61 | return value !== null && EXPLORER_NAMES.includes(value as ExplorerName); 62 | } 63 | -------------------------------------------------------------------------------- /src/dev/src/bump-version.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Increments the patch version in each package.json: from 0.0.[N] to 0.0.[N+1] 3 | * 4 | * Usage: 5 | * node src/dev/bump-version.mjs 6 | */ 7 | 8 | import fs from "node:fs"; 9 | import path from "node:path"; 10 | import { fileURLToPath } from "node:url"; 11 | 12 | // Get the directory name of the current file 13 | const currentFile = fileURLToPath(import.meta.url); 14 | const currentDir = path.dirname(currentFile); 15 | 16 | // Paths to the package.json files relative to the script 17 | const packagePaths = [ 18 | path.resolve(currentDir, "../../core/package.json"), 19 | path.resolve(currentDir, "../../node/package.json"), 20 | path.resolve(currentDir, "../../react/package.json"), 21 | ]; 22 | 23 | // Increment the version number from 0.0.[N] to 0.0.[N+1] 24 | function incrementVersion(version: string) { 25 | const versionParts = version.split("."); 26 | const patchNumber = parseInt(versionParts[2], 10); 27 | versionParts[2] = (patchNumber + 1).toString(); 28 | return versionParts.join("."); 29 | } 30 | 31 | // Function to increment the version number in a given package.json file 32 | function bumpVersion(packageJsonPath: string) { 33 | try { 34 | // Read and parse the package.json file 35 | const packageJson = fs.readFileSync(packageJsonPath, "utf8"); 36 | const packageData = JSON.parse(packageJson) as { version: string }; 37 | 38 | // Increment and replace the version 39 | const newVersion = incrementVersion(packageData.version); 40 | packageData.version = newVersion; 41 | 42 | // Write the updated package.json 43 | const newFileContent = `${JSON.stringify(packageData, null, 4)}\n`; 44 | fs.writeFileSync(packageJsonPath, newFileContent, "utf8"); 45 | 46 | console.log( 47 | `Version bumped to ${newVersion} for ${path.basename(path.dirname(packageJsonPath))}`, 48 | ); 49 | } catch (err) { 50 | console.error(`Error reading or writing file at ${packageJsonPath}:`, err); 51 | } 52 | } 53 | 54 | // Increment version for all package.json files 55 | packagePaths.forEach(bumpVersion); 56 | -------------------------------------------------------------------------------- /src/core/src/addresses.ts: -------------------------------------------------------------------------------- 1 | import { isValidSuiAddress, normalizeSuiAddress } from "@mysten/sui/utils"; 2 | 3 | import { REGEX_ADDRESS } from "./constants.js"; 4 | 5 | /** 6 | * Generate a random Sui address (for development only). 7 | */ 8 | export function generateRandomAddress() { 9 | // Function to generate a random byte in hexadecimal format 10 | const randomByteHex = () => 11 | Math.floor(Math.random() * 256) 12 | .toString(16) 13 | .padStart(2, "0"); 14 | 15 | // Generate 32 random bytes and convert each to hex 16 | const address = `0x${Array.from({ length: 32 }, randomByteHex).join("")}`; 17 | 18 | return address; 19 | } 20 | 21 | /** 22 | * Remove leading zeros from a Sui address (lossless). For example it will turn 23 | * '0x0000000000000000000000000000000000000000000000000000000000000002' into '0x2'. 24 | */ 25 | export function removeAddressLeadingZeros(address: string): string { 26 | return address.replaceAll(/0x0+/g, "0x"); 27 | } 28 | 29 | /** 30 | * Abbreviate a Sui address for display purposes (lossy). Default format is '0x1234…5678', 31 | * given an address like '0x1234000000000000000000000000000000000000000000000000000000005678'. 32 | */ 33 | export function shortenAddress( 34 | text: string | null | undefined, 35 | start = 4, 36 | end = 4, 37 | separator = "…", 38 | prefix = "0x", 39 | ): string { 40 | if (typeof text !== "string") return ""; 41 | 42 | const addressRegex = new RegExp(`\\b${REGEX_ADDRESS.source}\\b`, "g"); 43 | 44 | return text.replace(addressRegex, (match) => { 45 | // check if the address is too short to be abbreviated 46 | if (match.length - prefix.length <= start + end) { 47 | return match; 48 | } 49 | // otherwise, abbreviate the address 50 | return prefix + match.slice(2, 2 + start) + separator + match.slice(-end); 51 | }); 52 | } 53 | 54 | /** 55 | * Validate a Sui address and return its normalized form, or `null` if invalid. 56 | */ 57 | export function validateAndNormalizeAddress(address: string): string | null { 58 | if (address.length === 0) { 59 | return null; 60 | } 61 | const normalizedAddr = normalizeSuiAddress(address); 62 | if (!isValidSuiAddress(normalizedAddr)) { 63 | return null; 64 | } 65 | return normalizedAddr; 66 | } 67 | -------------------------------------------------------------------------------- /src/react/src/modal.css: -------------------------------------------------------------------------------- 1 | .poly-modal-background { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | width: 100%; 6 | height: 100dvh; 7 | z-index: 1000; 8 | background-color: rgba(0, 0, 0, 0.7); 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | box-sizing: border-box; 13 | padding: 2rem; 14 | 15 | @media (max-width: 700px) { 16 | /* dup: screen-medium */ 17 | padding: 1.5rem; 18 | } 19 | } 20 | 21 | .poly-modal-content { 22 | position: relative; 23 | width: min(700px, 100%); 24 | max-height: calc(100dvh - 4rem); 25 | background: linear-gradient(135deg, #2a2a2a, #1a1a1a); 26 | color: var(--color-text); 27 | border: none; 28 | border-radius: var(--border-radius); 29 | box-shadow: 0 0 2rem rgba(0, 0, 0, 1); 30 | display: flex; 31 | flex-direction: column; 32 | overflow-y: auto; 33 | scrollbar-width: none; 34 | -ms-overflow-style: none; 35 | 36 | &::-webkit-scrollbar { 37 | display: none; 38 | } 39 | 40 | /* modal animation */ 41 | animation: modalFadeIn 0.1s ease-in-out forwards; 42 | } 43 | 44 | .poly-modal-header { 45 | display: flex; 46 | justify-content: space-between; 47 | align-items: center; 48 | padding: 1rem 0.75rem 0.75rem 1.5rem; 49 | border-bottom: var(--border-gray); 50 | flex-shrink: 0; 51 | gap: 1rem; 52 | 53 | h2 { 54 | margin: 0; 55 | font-size: 1.3em; 56 | font-weight: bold; 57 | color: var(--color-text); 58 | flex: 1; 59 | min-width: 0; 60 | align-self: center; 61 | } 62 | 63 | .poly-modal-close { 64 | cursor: pointer; 65 | height: 2rem; 66 | width: 2rem; 67 | background: none; 68 | border: none; 69 | color: var(--color-text); 70 | font-size: 1.5em; 71 | border-radius: 100%; 72 | margin: 0; 73 | display: flex; 74 | align-items: center; 75 | justify-content: center; 76 | flex-shrink: 0; 77 | align-self: flex-start; 78 | 79 | &:hover { 80 | background-color: rgba(255, 255, 255, 0.1); 81 | } 82 | } 83 | } 84 | 85 | .poly-modal-body { 86 | padding: 1.5rem; 87 | display: flex; 88 | flex-direction: column; 89 | gap: 1.5rem; 90 | color: var(--color-text); 91 | flex-grow: 1; 92 | } 93 | 94 | @keyframes modalFadeIn { 95 | from { 96 | transform: scale(0.95); 97 | } 98 | to { 99 | transform: scale(1); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": false, 3 | "version": "0.0.67", 4 | "name": "@polymedia/suitcase-react", 5 | "author": "@juzybits (https://polymedia.app)", 6 | "homepage": "https://github.com/juzybits/polymedia-suitcase", 7 | "description": "React components for Sui apps", 8 | "license": "Apache-2.0", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/juzybits/polymedia-suitcase" 12 | }, 13 | "keywords": [ 14 | "polymedia", 15 | "sui", 16 | "suitcase", 17 | "react" 18 | ], 19 | "scripts": { 20 | "build": "tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && cp src/*.css dist/esm/ && cp src/*.css dist/cjs/", 21 | "clean": "rm -rf dist/ node_modules/ .turbo/", 22 | "dev": "tsc --watch -p tsconfig.esm.json", 23 | "dev:all": "concurrently 'pnpm dev' 'pnpm dev:styles'", 24 | "dev:styles": "fswatch -0 src/*.css | xargs -0 -I {} cp src/*.css dist/esm/ && cp src/*.css dist/cjs/", 25 | "lint:fix": "biome check --write *.* src/", 26 | "lint:unsafe": "biome check --write --unsafe *.* src/", 27 | "lint": "biome check *.* src/", 28 | "test": "jest --verbose --passWithNoTests", 29 | "typecheck": "tsc -p tsconfig.esm.json" 30 | }, 31 | "dependencies": { 32 | "@polymedia/suitcase-core": "workspace:*", 33 | "normalize.css": "^8.0.1" 34 | }, 35 | "peerDependencies": { 36 | "@mysten/dapp-kit": "^0.17.7", 37 | "@mysten/sui": "^1.37.6", 38 | "react": "^18.0.0 || ^19.0.0", 39 | "react-router-dom": "^7.0.0" 40 | }, 41 | "devDependencies": { 42 | "@types/react": "^19.1.12", 43 | "concurrently": "^9.2.1" 44 | }, 45 | "type": "module", 46 | "sideEffects": false, 47 | "publishConfig": { 48 | "access": "public" 49 | }, 50 | "files": [ 51 | "dist/" 52 | ], 53 | "types": "./dist/esm/index.d.ts", 54 | "module": "./dist/esm/index.js", 55 | "main": "./dist/cjs/index.js", 56 | "exports": { 57 | ".": { 58 | "import": "./dist/esm/index.js", 59 | "require": "./dist/cjs/index.js" 60 | }, 61 | "./*.css": { 62 | "import": "./dist/esm/*.css", 63 | "require": "./dist/cjs/*.css" 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/react/src/rpcs.tsx: -------------------------------------------------------------------------------- 1 | import { type NetworkName, RPC_ENDPOINTS } from "@polymedia/suitcase-core"; 2 | 3 | import { type RadioOption, RadioSelector } from "./selectors"; 4 | 5 | /** 6 | * A radio button menu to select an RPC endpoint and save the choice to local storage. 7 | */ 8 | export const RpcRadioSelector: React.FC<{ 9 | network: NetworkName; 10 | selectedRpc: string; 11 | supportedRpcs?: string[]; 12 | onSwitch: (newRpc: string) => void; 13 | className?: string; 14 | }> = ({ 15 | network, 16 | selectedRpc, 17 | supportedRpcs = RPC_ENDPOINTS[network], 18 | onSwitch, 19 | className = "", 20 | }) => { 21 | const options: RadioOption[] = supportedRpcs.map((rpc) => ({ 22 | value: rpc, 23 | label: rpc, 24 | })); 25 | 26 | const onSelect = (newRpc: string) => { 27 | switchRpc({ 28 | network, 29 | newRpc, 30 | supportedRpcs, 31 | defaultRpc: supportedRpcs[0], 32 | onSwitch, 33 | }); 34 | }; 35 | 36 | return ( 37 | 43 | ); 44 | }; 45 | 46 | /** 47 | * Load the RPC URL for the current network from local storage. 48 | */ 49 | export type LoadRpcParams = { 50 | network: NetworkName; 51 | supportedRpcs?: string[]; 52 | defaultRpc?: string; 53 | }; 54 | 55 | export function loadRpc({ 56 | network, 57 | supportedRpcs = RPC_ENDPOINTS[network], 58 | defaultRpc = supportedRpcs[0], 59 | }: LoadRpcParams): string { 60 | const storedRpc = localStorage.getItem(`polymedia.rpc.${network}`); 61 | if (storedRpc && supportedRpcs.includes(storedRpc)) { 62 | return storedRpc; 63 | } 64 | return defaultRpc; 65 | } 66 | 67 | export type SwitchRpcParams = { 68 | network: NetworkName; 69 | newRpc: string; 70 | supportedRpcs?: string[]; 71 | defaultRpc?: string; 72 | onSwitch?: (newRpc: string) => void; 73 | }; 74 | 75 | /** 76 | * Change RPCs, update local storage, and optionally trigger a callback. 77 | */ 78 | export function switchRpc({ 79 | network, 80 | newRpc, 81 | supportedRpcs = RPC_ENDPOINTS[network], 82 | defaultRpc = supportedRpcs[0], 83 | onSwitch, 84 | }: SwitchRpcParams): void { 85 | newRpc = supportedRpcs.includes(newRpc) ? newRpc : defaultRpc; 86 | localStorage.setItem(`polymedia.rpc.${network}`, newRpc); 87 | if (onSwitch) { 88 | onSwitch(newRpc); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/core/src/validation.ts: -------------------------------------------------------------------------------- 1 | // TODO: add to README.md 2 | // === float === 3 | 4 | import { TypeTagSerializer } from "@mysten/sui/bcs"; 5 | import type { CoinMetaFetcher } from "./coins.js"; 6 | import { REGEX_TYPE_BASIC } from "./constants.js"; 7 | 8 | export const REGEX_FLOAT = /^[+-]?([0-9]+\.?[0-9]*|\.[0-9]+)$/; 9 | 10 | export function isFloat(val: string): boolean { 11 | return REGEX_FLOAT.test(val); 12 | } 13 | 14 | export function validateFloat({ 15 | value, 16 | min, 17 | max, 18 | required = true, 19 | }: { 20 | value: string; 21 | min?: number; 22 | max?: number; 23 | required?: boolean; 24 | }): string | undefined { 25 | if (required && !value.length) { 26 | return "Required"; 27 | } 28 | if (!isFloat(value)) { 29 | return "Invalid number"; 30 | } 31 | if (min !== undefined && parseFloat(value) < min) { 32 | return `Min value is ${min}`; 33 | } 34 | if (max !== undefined && parseFloat(value) > max) { 35 | return `Max value is ${max}`; 36 | } 37 | return undefined; 38 | } 39 | 40 | // === coin type === 41 | 42 | export function isBasicSuiType(val: string): boolean { 43 | return new RegExp(`^${REGEX_TYPE_BASIC.source}$`).test(val); 44 | } 45 | 46 | export async function validateCoinType({ 47 | value, 48 | coinMetaFetcher, 49 | required = true, 50 | }: { 51 | value: string; 52 | coinMetaFetcher: CoinMetaFetcher; 53 | required?: boolean; 54 | }): Promise { 55 | if (required && !value.length) { 56 | return "Required"; 57 | } 58 | if (!isBasicSuiType(value)) { 59 | return "Invalid coin type"; 60 | } 61 | try { 62 | const coinMeta = await coinMetaFetcher.getCoinMeta(value); 63 | if (coinMeta === null) { 64 | return "CoinMetadata not found"; 65 | } 66 | } catch (_err) { 67 | return "Failed to fetch coin metadata"; 68 | } 69 | return undefined; 70 | } 71 | 72 | // === struct type === 73 | 74 | export function validateStructType({ 75 | value, 76 | required = true, 77 | }: { 78 | value: string; 79 | required?: boolean; 80 | }): string | undefined { 81 | if (required && !value.length) { 82 | return "Required"; 83 | } 84 | const startsOk = REGEX_TYPE_BASIC.test(value); 85 | if (!startsOk) { 86 | return "Invalid type"; 87 | } 88 | try { 89 | const type = TypeTagSerializer.parseFromStr(value); 90 | if (!("struct" in type)) { 91 | return "Invalid type"; 92 | } 93 | } catch (_err) { 94 | return "Invalid type"; 95 | } 96 | return undefined; 97 | } 98 | -------------------------------------------------------------------------------- /src/core/src/urls.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A Sui explorer item type, as in: 3 | * https://explorer.polymedia.app/address/0x... 4 | * https://explorer.polymedia.app/object/0x... 5 | * etc 6 | */ 7 | export type SuiExplorerItem = "address" | "object" | "package" | "tx" | "coin"; 8 | 9 | export type ExplorerUrlMaker = ( 10 | network: string, 11 | kind: SuiExplorerItem, 12 | addr: string, 13 | ) => string; 14 | 15 | /** 16 | * Build an explorer.polymedia.app URL. 17 | */ 18 | export const makePolymediaUrl: ExplorerUrlMaker = ( 19 | network: string, 20 | kind: SuiExplorerItem, 21 | address: string, 22 | ): string => { 23 | const baseUrl = "https://explorer.polymedia.app"; 24 | 25 | let path: string; 26 | if (kind === "tx") { 27 | path = "txblock"; 28 | } else if (kind === "package") { 29 | path = "object"; 30 | } else if (kind === "coin") { 31 | path = "object"; 32 | address = address.split("::")[0]; 33 | } else { 34 | path = kind; 35 | } 36 | 37 | let url = `${baseUrl}/${path}/${address}`; 38 | if (network !== "mainnet") { 39 | const networkLabel = network === "localnet" ? "local" : network; 40 | url += `?network=${networkLabel}`; 41 | } 42 | return url; 43 | }; 44 | 45 | /** 46 | * Build a suiscan.xyz URL. 47 | */ 48 | export const makeSuiscanUrl: ExplorerUrlMaker = ( 49 | network: string, 50 | kind: SuiExplorerItem, 51 | address: string, 52 | ): string => { 53 | if (isLocalnet(network)) { 54 | return makePolymediaUrl(network, kind, address); 55 | } 56 | const baseUrl = `https://suiscan.xyz/${network}`; 57 | 58 | let path: string; 59 | if (kind === "address") { 60 | path = "account"; 61 | } else if (kind === "package") { 62 | path = "object"; 63 | } else { 64 | path = kind; 65 | } 66 | 67 | const url = `${baseUrl}/${path}/${address}`; 68 | return url; 69 | }; 70 | 71 | /** 72 | * Build a suivision.xyz URL. 73 | */ 74 | export const makeSuivisionUrl: ExplorerUrlMaker = ( 75 | network: string, 76 | kind: SuiExplorerItem, 77 | address: string, 78 | ): string => { 79 | if (isLocalnet(network)) { 80 | return makePolymediaUrl(network, kind, address); 81 | } 82 | const baseUrl = 83 | network === "mainnet" ? "https://suivision.xyz" : `https://${network}.suivision.xyz`; 84 | 85 | let path: string; 86 | if (kind === "tx") { 87 | path = "txblock"; 88 | } else if (kind === "address") { 89 | path = "account"; 90 | } else { 91 | path = kind; 92 | } 93 | 94 | const url = `${baseUrl}/${path}/${address}`; 95 | return url; 96 | }; 97 | 98 | function isLocalnet(network: string): boolean { 99 | return network === "localnet" || network === "http://127.0.0.1:9000"; 100 | } 101 | -------------------------------------------------------------------------------- /src/react/src/links.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type ExplorerUrlMaker, 3 | makePolymediaUrl, 4 | makeSuiscanUrl, 5 | makeSuivisionUrl, 6 | type NetworkName, 7 | type SuiExplorerItem, 8 | shortenAddress, 9 | } from "@polymedia/suitcase-core"; 10 | 11 | import type { ExplorerName } from "./explorers"; 12 | 13 | // === types === 14 | 15 | export type ExplorerLinkProps = { 16 | network: NetworkName; 17 | kind: SuiExplorerItem; 18 | addr: string; 19 | html?: React.AnchorHTMLAttributes; 20 | children?: React.ReactNode; 21 | }; 22 | 23 | // === components === 24 | 25 | /** 26 | * An external link like: 27 | * `{text}` 28 | */ 29 | export const LinkExternal: React.FC< 30 | React.AnchorHTMLAttributes & { 31 | follow?: boolean; 32 | children: React.ReactNode; 33 | } 34 | > = ({ follow = true, children, ...props }) => { 35 | const target = props.target ?? "_blank"; 36 | const rel = props.rel ?? `noopener noreferrer ${follow ? "" : "nofollow"}`; 37 | return ( 38 | 39 | {children} 40 | 41 | ); 42 | }; 43 | 44 | /** 45 | * A link to a Sui explorer (Polymedia, Suiscan, or SuiVision). 46 | */ 47 | export const LinkToExplorer: React.FC< 48 | ExplorerLinkProps & { 49 | explorer: ExplorerName; 50 | } 51 | > = ({ explorer, network, kind, addr, html = {}, children = null }) => { 52 | let makeUrl: ExplorerUrlMaker; 53 | if (explorer === "Polymedia") { 54 | makeUrl = makePolymediaUrl; 55 | } else if (explorer === "Suiscan") { 56 | makeUrl = makeSuiscanUrl; 57 | } else if (explorer === "SuiVision") { 58 | makeUrl = makeSuivisionUrl; 59 | } else { 60 | throw new Error(`Unknown explorer: ${explorer}`); 61 | } 62 | html.href = makeUrl(network, kind, addr); 63 | return {children || shortenAddress(addr)}; 64 | }; 65 | 66 | /** 67 | * Higher-Order Component to create external links for explorers. 68 | */ 69 | const createExplorerLinkComponent = ( 70 | makeUrl: ExplorerUrlMaker, 71 | ): React.FC => { 72 | return ({ network, kind, addr, html = {}, children = null }: ExplorerLinkProps) => { 73 | html.href = makeUrl(network, kind, addr); 74 | return {children || shortenAddress(addr)}; 75 | }; 76 | }; 77 | 78 | /** 79 | * A link to explorer.polymedia.app. 80 | */ 81 | export const LinkToPolymedia = createExplorerLinkComponent(makePolymediaUrl); 82 | 83 | /** 84 | * A link to suiscan.xyz. 85 | */ 86 | export const LinkToSuiscan = createExplorerLinkComponent(makeSuiscanUrl); 87 | 88 | /** 89 | * A link to suivision.xyz. 90 | */ 91 | export const LinkToSuivision = createExplorerLinkComponent(makeSuivisionUrl); 92 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["${configDir}/src"], 3 | "exclude": ["${configDir}/dist", "${configDir}/node_modules"], 4 | "compilerOptions": { 5 | /* Language and Environment */ 6 | "target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 7 | "lib": [ 8 | "ESNext", 9 | "DOM" 10 | ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, 11 | // "jsx": "react-jsx", /* Specify what JSX code is generated. */ 12 | "useDefineForClassFields": true /* Emit ECMAScript-standard-compliant class fields. */, 13 | 14 | /* Modules */ 15 | "module": "ESNext" /* Specify what module code is generated. */, 16 | "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, 17 | "rootDir": "${configDir}/src" /* Specify the root folder within your source files. */, 18 | // "resolveJsonModule": true, /* Enable importing .json files. */ 19 | 20 | /* Emit */ 21 | // "noEmit": true, /* Disable emitting files from a compilation. */ 22 | "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */, 23 | "sourceMap": true /* Create source map files for emitted JavaScript files. */, 24 | "outDir": "${configDir}/dist" /* Specify an output folder for all emitted files. */, 25 | 26 | /* Interop Constraints */ 27 | "isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */, 28 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 29 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 30 | 31 | /* Type Checking */ 32 | "strict": true, // Enable all strict type-checking options. 33 | "noUnusedLocals": true, // Enable error reporting when local variables aren't read. 34 | "noUnusedParameters": true, // Raise an error when a function parameter isn't read. 35 | "noImplicitReturns": true, // Check all code paths in a function to ensure they return a value. 36 | "noImplicitOverride": true, // Function overrides must be explicitly marked with the `override` modifier. 37 | "noFallthroughCasesInSwitch": true, // Ensure that non-empty cases inside a switch statement include either break, return, or throw. 38 | // "noUncheckedIndexedAccess": true, // obj["key"] returns `string | undefined`. 39 | "noPropertyAccessFromIndexSignature": true, // obj.unknownProp → error, use obj["unknownProp"]. 40 | "exactOptionalPropertyTypes": true, // Distinguish between undefined and missing optional properties. 41 | 42 | /* Completeness */ 43 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/core/src/__tests__/balanceToString.test.ts: -------------------------------------------------------------------------------- 1 | import { balanceToString } from "../balances"; 2 | 3 | describe("balanceToString", () => { 4 | it("should convert basic BigInt values with no decimals to string", () => { 5 | expect(balanceToString(123n, 0)).toBe("123"); 6 | expect(balanceToString(0n, 0)).toBe("0"); 7 | expect(balanceToString(-123n, 0)).toBe("-123"); 8 | }); 9 | 10 | it("should convert BigInt values with decimals to string", () => { 11 | expect(balanceToString(123456n, 3)).toBe("123.456"); 12 | expect(balanceToString(1000000001n, 9)).toBe("1.000000001"); 13 | expect(balanceToString(123456789n, 6)).toBe("123.456789"); 14 | }); 15 | 16 | it("should handle trailing zeros in fractional part", () => { 17 | expect(balanceToString(123400n, 3)).toBe("123.4"); 18 | expect(balanceToString(12345000n, 5)).toBe("123.45"); 19 | expect(balanceToString(123000000n, 6)).toBe("123"); 20 | }); 21 | 22 | it("should handle cases where there are fewer digits than decimals", () => { 23 | expect(balanceToString(1n, 3)).toBe("0.001"); 24 | expect(balanceToString(100n, 5)).toBe("0.001"); 25 | expect(balanceToString(100000n, 6)).toBe("0.1"); 26 | }); 27 | 28 | it("should handle large BigInt values correctly", () => { 29 | expect(balanceToString(123456789012345678901234567890n, 0)).toBe( 30 | "123456789012345678901234567890", 31 | ); 32 | expect(balanceToString(123456789012345678901234567890123456789n, 9)).toBe( 33 | "123456789012345678901234567890.123456789", 34 | ); 35 | }); 36 | 37 | it("should handle very small fractional values correctly", () => { 38 | expect(balanceToString(1n, 9)).toBe("0.000000001"); 39 | expect(balanceToString(1000n, 9)).toBe("0.000001"); 40 | }); 41 | 42 | it('should return "0" for a zero value regardless of decimals', () => { 43 | expect(balanceToString(0n, 3)).toBe("0"); 44 | expect(balanceToString(0n, 0)).toBe("0"); 45 | }); 46 | 47 | it("should correctly handle negative values with decimals", () => { 48 | expect(balanceToString(-123456n, 3)).toBe("-123.456"); 49 | expect(balanceToString(-1000000001n, 9)).toBe("-1.000000001"); 50 | expect(balanceToString(-123456789n, 6)).toBe("-123.456789"); 51 | }); 52 | 53 | it("should correctly handle edge cases where fractional part is all zeros", () => { 54 | expect(balanceToString(1000n, 3)).toBe("1"); 55 | expect(balanceToString(123000000n, 6)).toBe("123"); 56 | expect(balanceToString(-1000n, 3)).toBe("-1"); 57 | expect(balanceToString(-123000000n, 6)).toBe("-123"); 58 | }); 59 | 60 | it("should handle numbers with leading and trailing zeros correctly", () => { 61 | expect(balanceToString(123000n, 6)).toBe("0.123"); 62 | expect(balanceToString(123000000n, 9)).toBe("0.123"); 63 | expect(balanceToString(123n, 5)).toBe("0.00123"); 64 | }); 65 | 66 | it("should correctly format numbers with large decimals", () => { 67 | expect(balanceToString(1n, 18)).toBe("0.000000000000000001"); 68 | expect(balanceToString(1000000000000000000n, 18)).toBe("1"); 69 | }); 70 | 71 | it("should correctly format numbers with large integer part and small decimals", () => { 72 | expect(balanceToString(1234567890000000000n, 18)).toBe("1.23456789"); 73 | expect(balanceToString(1234567890000000000n, 15)).toBe("1234.56789"); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/core/src/coins.ts: -------------------------------------------------------------------------------- 1 | import type { SuiClient } from "@mysten/sui/client"; 2 | import { normalizeStructTag } from "@mysten/sui/utils"; 3 | 4 | /** 5 | * Like `CoinMetadata` from `@mysten/sui`, but includes the coin `type`. 6 | */ 7 | export type CoinMeta = { 8 | type: string; 9 | symbol: string; 10 | decimals: number; 11 | name: string; 12 | description: string; 13 | id: string | null; 14 | iconUrl: string | null; 15 | }; 16 | 17 | /** 18 | * Fetch coin metadata from the RPC and cache it. 19 | */ 20 | export class CoinMetaFetcher { 21 | protected readonly suiClient: SuiClient; 22 | protected readonly cache = new Map(); 23 | 24 | constructor({ 25 | suiClient, 26 | preloadUrl = "https://coinmeta.polymedia.app/api/data.json", 27 | preloadData, 28 | }: { 29 | suiClient: SuiClient; 30 | preloadUrl?: string; 31 | preloadData?: CoinMeta[]; 32 | }) { 33 | this.suiClient = suiClient; 34 | 35 | if (preloadData) { 36 | preloadData.forEach((coinMeta) => { 37 | this.cache.set(coinMeta.type, coinMeta); 38 | }); 39 | } 40 | 41 | if (preloadUrl) { 42 | (async () => { 43 | try { 44 | const resp = await fetch(preloadUrl); 45 | const data = await resp.json(); 46 | if (!Array.isArray(data)) { 47 | throw new Error("Invalid preload data"); 48 | } 49 | for (const m of data) { 50 | if (typeof m !== "object" || m === null) { 51 | throw new Error("Invalid preload data"); 52 | } 53 | if ( 54 | typeof m.type !== "string" || 55 | typeof m.symbol !== "string" || 56 | typeof m.decimals !== "number" || 57 | typeof m.name !== "string" || 58 | typeof m.description !== "string" || 59 | (m.id !== undefined && typeof m.id !== "string" && m.id !== null) || 60 | (m.iconUrl !== undefined && 61 | typeof m.iconUrl !== "string" && 62 | m.iconUrl !== null) 63 | ) { 64 | continue; 65 | } 66 | this.cache.set(m.type, m); 67 | } 68 | } catch (err) { 69 | console.warn(`Failed to preload coin metadata from "${preloadUrl}":`, err); 70 | } 71 | })(); 72 | } 73 | } 74 | 75 | public async getCoinMeta(coinType: string): Promise { 76 | const normalizedType = normalizeStructTag(coinType); 77 | const cachedMeta = this.cache.get(normalizedType); 78 | if (cachedMeta !== undefined) { 79 | return cachedMeta; 80 | } 81 | 82 | const rawMeta = await this.suiClient.getCoinMetadata({ coinType: normalizedType }); 83 | const coinMeta = !rawMeta 84 | ? null 85 | : { 86 | id: rawMeta.id ?? null, 87 | type: normalizedType, 88 | symbol: rawMeta.symbol, 89 | decimals: rawMeta.decimals, 90 | name: rawMeta.name, 91 | description: rawMeta.description, 92 | iconUrl: rawMeta.iconUrl ?? null, 93 | }; 94 | this.cache.set(normalizedType, coinMeta); 95 | 96 | return coinMeta; 97 | } 98 | 99 | public async getCoinMetas(coinTypes: string[]): Promise> { 100 | const uniqueTypes = Array.from( 101 | new Set(coinTypes.map((coinType) => normalizeStructTag(coinType))), 102 | ); 103 | 104 | const results = await Promise.allSettled( 105 | uniqueTypes.map((coinType) => this.getCoinMeta(coinType)), 106 | ); 107 | 108 | const metas = new Map(); 109 | results.forEach((result, index) => { 110 | metas.set(uniqueTypes[index], result.status === "fulfilled" ? result.value : null); 111 | }); 112 | 113 | return metas; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/react/src/buttons.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type ButtonHTMLAttributes, 3 | type ComponentProps, 4 | forwardRef, 5 | type ReactNode, 6 | type RefObject, 7 | } from "react"; 8 | import { Link } from "react-router-dom"; 9 | import type { useFetchAndPaginate } from "./hooks"; 10 | import { LinkExternal } from "./links"; 11 | 12 | export const Btn = ({ 13 | children, 14 | wrap = true, 15 | isSubmitting = false, 16 | type = "button", 17 | className = undefined, 18 | ...props 19 | }: { 20 | children: ReactNode; 21 | wrap?: boolean | undefined; 22 | isSubmitting?: boolean | undefined; 23 | type?: ButtonHTMLAttributes["type"]; 24 | className?: ButtonHTMLAttributes["className"]; 25 | } & Omit, "type" | "className">) => { 26 | const button = ( 27 | 34 | ); 35 | 36 | return wrap ?
{button}
: button; 37 | }; 38 | 39 | export const BtnLinkExternal = ( 40 | props: ComponentProps & { disabled?: boolean }, 41 | ) => { 42 | let className = "btn"; 43 | if (props.className) { 44 | className += ` ${props.className}`; 45 | } 46 | if (props.disabled) { 47 | className += " disabled"; 48 | } 49 | return ( 50 |
51 | 52 |
53 | ); 54 | }; 55 | 56 | export const BtnLinkInternal = forwardRef< 57 | HTMLAnchorElement, 58 | ComponentProps & { disabled?: boolean } 59 | >((props, ref) => { 60 | let className = "btn"; 61 | if (props.className) { 62 | className += ` ${props.className}`; 63 | } 64 | if (props.disabled) { 65 | className += " disabled"; 66 | } 67 | return ( 68 |
69 | 70 |
71 | ); 72 | }); 73 | 74 | /** 75 | * A button component to navigate through paginated data (see `useFetchAndPaginate()`). 76 | */ 77 | export const BtnPrevNext = ({ 78 | data, 79 | onPageChange, 80 | scrollToRefOnPageChange, 81 | }: { 82 | data: ReturnType; 83 | onPageChange?: () => void; 84 | scrollToRefOnPageChange?: RefObject; 85 | }) => { 86 | if (!data.hasMultiplePages) { 87 | return null; 88 | } 89 | 90 | const handlePageChange = () => { 91 | if (scrollToRefOnPageChange?.current) { 92 | const navBarHeight = parseInt( 93 | getComputedStyle(document.documentElement).getPropertyValue("--nav-bar-height"), 94 | 10, 95 | ); 96 | const extraOffset = 9; 97 | const totalOffset = navBarHeight + extraOffset; 98 | const yOffset = 99 | scrollToRefOnPageChange.current.getBoundingClientRect().top + 100 | window.scrollY - 101 | totalOffset; 102 | window.scrollTo({ top: yOffset }); 103 | } 104 | 105 | onPageChange?.(); 106 | }; 107 | 108 | const handlePrevClick = () => { 109 | data.goToPreviousPage(); 110 | handlePageChange(); 111 | return Promise.resolve(); 112 | }; 113 | 114 | const handleNextClick = async () => { 115 | await data.goToNextPage(); 116 | handlePageChange(); 117 | return Promise.resolve(); 118 | }; 119 | 120 | return ( 121 |
122 | 123 | PREV 124 | 125 | 129 | NEXT 130 | 131 |
132 | ); 133 | }; 134 | -------------------------------------------------------------------------------- /src/core/src/SuiEventFetcher.ts: -------------------------------------------------------------------------------- 1 | import type { EventId, SuiClient, SuiEvent } from "@mysten/sui/client"; 2 | 3 | import { sleep } from "./misc.js"; 4 | 5 | /** 6 | * Fetch Sui events and parse them into custom objects. 7 | * @see SuiEventFetcher.fetchEvents 8 | */ 9 | export class SuiEventFetcher { 10 | private eventType: string; 11 | private parseEvent: (suiEvent: SuiEvent) => T | null; 12 | private eventCursor: EventId | null; 13 | private suiClient: SuiClient; 14 | private rateLimitDelay = 300; // how long to sleep between RPC requests, in milliseconds 15 | 16 | /** 17 | * @param eventType The full Sui event object type, e.g. '0x123::your_module::YourEvent'. 18 | * @param parseEvent A function that can parse raw Sui events into custom objects. 19 | * @param nextCursor (optional) To start fetching events starting at an old cursor. 20 | * @param networkName (optional) The network name. Defaults to 'mainnet'. 21 | */ 22 | constructor( 23 | suiClient: SuiClient, 24 | eventType: string, 25 | parseEvent: (suiEvent: SuiEvent) => T | null, 26 | nextCursor: EventId | null = null, 27 | ) { 28 | this.eventType = eventType; 29 | this.parseEvent = parseEvent; 30 | this.eventCursor = nextCursor; 31 | this.suiClient = suiClient; 32 | } 33 | 34 | /** 35 | * Fetch the latest events. Every time the function is called it looks 36 | * for events that took place since the last call. 37 | */ 38 | public async fetchEvents(): Promise { 39 | try { 40 | if (!this.eventCursor) { 41 | // 1st run 42 | await this.fetchLastEventAndUpdateCursor(); 43 | return []; 44 | } else { 45 | return await this.fetchEventsFromCursor(); 46 | } 47 | } catch (error) { 48 | console.error("[SuiEventFetcher]", error); 49 | return []; 50 | } 51 | } 52 | 53 | private async fetchLastEventAndUpdateCursor(): Promise { 54 | // console.debug(`[SuiEventFetcher] fetchLastEventAndUpdateCursor()`); 55 | 56 | // fetch last event 57 | const suiEvents = await this.suiClient.queryEvents({ 58 | query: { MoveEventType: this.eventType }, 59 | limit: 1, 60 | order: "descending", 61 | }); 62 | 63 | // update cursor 64 | if (!suiEvents.nextCursor) { 65 | console.error("[SuiEventFetcher] unexpected missing cursor"); 66 | } else { 67 | this.eventCursor = suiEvents.nextCursor; 68 | } 69 | } 70 | 71 | private async fetchEventsFromCursor(): Promise { 72 | // console.debug(`[SuiEventFetcher] fetchEventsFromCursor()`); 73 | 74 | // fetch events from cursor 75 | const suiEvents = await this.suiClient.queryEvents({ 76 | query: { MoveEventType: this.eventType }, 77 | cursor: this.eventCursor, 78 | order: "ascending", 79 | // limit: 10, 80 | }); 81 | 82 | // update cursor 83 | if (!suiEvents.nextCursor) { 84 | console.error("[SuiEventFetcher] unexpected missing cursor"); 85 | return []; 86 | } 87 | this.eventCursor = suiEvents.nextCursor; 88 | 89 | // parse events 90 | const objects: T[] = []; 91 | for (const suiEvent of suiEvents.data) { 92 | const obj = this.parseEvent(suiEvent); 93 | if (obj) { 94 | objects.push(obj); 95 | } 96 | } 97 | // console.debug('suiEvents.data.length:', suiEvents.data.length) 98 | // console.debug('hasNextPage:', suiEvents.hasNextPage); 99 | // console.debug('nextCursor:', suiEvents.nextCursor ? suiEvents.nextCursor.txDigest : 'none'); 100 | 101 | // call this function recursively if there's newer events that didn't fit in the page 102 | if (suiEvents.hasNextPage) { 103 | // console.debug(`[SuiEventFetcher] has next page, will fetching recursively`); 104 | await sleep(this.rateLimitDelay); 105 | const nextObjects = await this.fetchEventsFromCursor(); 106 | objects.push(...nextObjects); 107 | } 108 | 109 | return objects; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/core/src/guards.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ObjectOwner, 3 | SuiArgument, 4 | SuiObjectChange, 5 | SuiObjectRef, 6 | SuiParsedData, 7 | SuiTransaction, 8 | } from "@mysten/sui/client"; 9 | 10 | // === ObjectOwner === 11 | 12 | /** 13 | * All possible `ObjectOwner` subtypes. 14 | */ 15 | type OwnerKeys = ObjectOwner extends infer T 16 | ? T extends Record 17 | ? keyof T 18 | : T extends string 19 | ? T 20 | : never 21 | : never; 22 | 23 | /** 24 | * An `ObjectOwner` of a specific kind. 25 | * ``` 26 | */ 27 | export type OwnerKind = Extract | K>; 28 | 29 | /** 30 | * Type guard to check if an `ObjectOwner` is of a specific kind. 31 | */ 32 | export function isOwnerKind( 33 | owner: ObjectOwner, 34 | kind: K, 35 | ): owner is OwnerKind { 36 | return kind === owner || (typeof owner === "object" && kind in owner); 37 | } 38 | 39 | // === SuiArgument === 40 | 41 | /** 42 | * All possible `SuiArgument` subtypes. 43 | */ 44 | type ArgKeys = SuiArgument extends infer T 45 | ? T extends Record 46 | ? keyof T 47 | : T extends string 48 | ? T 49 | : never 50 | : never; 51 | 52 | /** 53 | * A `SuiArgument` of a specific kind. 54 | */ 55 | export type ArgKind = Extract | K>; 56 | 57 | /** 58 | * Type guard to check if a `SuiArgument` is of a specific kind. 59 | */ 60 | export function isArgKind( 61 | arg: SuiArgument, 62 | kind: K, 63 | ): arg is ArgKind { 64 | return kind === arg || (typeof arg === "object" && kind in arg); 65 | } 66 | 67 | // === SuiObjectChange === 68 | 69 | /** 70 | * All possible `SuiObjectChange` subtypes. 71 | */ 72 | type ObjChangeKeys = SuiObjectChange extends { type: infer T } ? T : never; 73 | 74 | /** 75 | * A `SuiObjChange` of a specific kind. 76 | */ 77 | export type ObjChangeKind = Extract< 78 | SuiObjectChange, 79 | { type: K } 80 | >; 81 | 82 | /** 83 | * Type guard to check if a `SuiObjectChange` is of a specific kind. 84 | */ 85 | export function isObjChangeKind( 86 | change: SuiObjectChange, 87 | kind: K, 88 | ): change is ObjChangeKind { 89 | return change.type === kind; 90 | } 91 | 92 | // === SuiObjectRef === 93 | 94 | /** Type guard to check if an object is a `SuiObjectRef`. */ 95 | export function isSuiObjectRef(obj: unknown): obj is SuiObjectRef { 96 | return ( 97 | typeof obj === "object" && 98 | obj !== null && 99 | "objectId" in obj && 100 | "version" in obj && 101 | "digest" in obj 102 | ); 103 | } 104 | 105 | // === SuiParsedData === 106 | 107 | /** 108 | * All possible `SuiParsedData` subtypes. 109 | */ 110 | type ParsedDataKeys = SuiParsedData extends infer T 111 | ? T extends { dataType: infer DT } 112 | ? DT 113 | : never 114 | : never; 115 | 116 | /** 117 | * A `SuiParsedData` of a specific kind. 118 | */ 119 | export type ParsedDataKind = Extract< 120 | SuiParsedData, 121 | { dataType: K } 122 | >; 123 | 124 | /** 125 | * Type guard to check if a `SuiParsedData` is of a specific kind. 126 | */ 127 | export function isParsedDataKind( 128 | data: SuiParsedData, 129 | kind: K, 130 | ): data is ParsedDataKind { 131 | return data.dataType === kind; 132 | } 133 | 134 | // === SuiTransaction === 135 | 136 | /** 137 | * All possible `SuiTransaction` subtypes. 138 | */ 139 | type TxKeys = SuiTransaction extends infer T 140 | ? T extends Record 141 | ? keyof T 142 | : never 143 | : never; 144 | 145 | /** 146 | * A `SuiTransaction` of a specific kind. 147 | */ 148 | export type TxKind = Extract | K>; 149 | 150 | /** 151 | * Type guard to check if a `SuiTransaction` is of a specific kind. 152 | */ 153 | export function isTxKind(tx: SuiTransaction, kind: K): tx is TxKind { 154 | return kind in tx; 155 | } 156 | -------------------------------------------------------------------------------- /src/core/src/objects.ts: -------------------------------------------------------------------------------- 1 | import type { SuiObjectRef, SuiObjectResponse, SuiParsedData } from "@mysten/sui/client"; 2 | 3 | import { isOwnerKind } from "./guards.js"; 4 | 5 | /** 6 | * A Sui object display with common properties and arbitrary ones. 7 | */ 8 | export type ObjectDisplay = { 9 | [key: string]: string | null; 10 | name: string | null; 11 | description: string | null; 12 | link: string | null; 13 | image_url: string | null; 14 | thumbnail_url: string | null; 15 | project_name: string | null; 16 | project_url: string | null; 17 | project_image_url: string | null; 18 | creator: string | null; 19 | }; 20 | 21 | /** 22 | * Validate a `SuiObjectResponse` and return its `.data.bcs.bcsBytes`. 23 | */ 24 | export function objResToBcs(resp: SuiObjectResponse): string { 25 | if (resp.data?.bcs?.dataType !== "moveObject") { 26 | throw Error(`response bcs missing: ${JSON.stringify(resp, null, 2)}`); 27 | } 28 | return resp.data.bcs.bcsBytes; 29 | } 30 | 31 | /** 32 | * Validate a `SuiObjectResponse` and return its `.data.content`. 33 | */ 34 | export function objResToContent(resp: SuiObjectResponse): SuiParsedData { 35 | if (!resp.data?.content) { 36 | throw Error(`response has no content: ${JSON.stringify(resp, null, 2)}`); 37 | } 38 | return resp.data.content; 39 | } 40 | 41 | /** 42 | * Validate a `SuiObjectResponse` and return its `.data.display.data` or `null`. 43 | */ 44 | export function objResToDisplay(resp: SuiObjectResponse): ObjectDisplay { 45 | if (!resp.data?.display) { 46 | throw Error(`response has no display: ${JSON.stringify(resp, null, 2)}`); 47 | } 48 | 49 | return { 50 | ...newEmptyDisplay(), 51 | ...resp.data.display.data, 52 | }; 53 | } 54 | 55 | /** 56 | * Create an `ObjectDisplay` object with all fields set to `null`. 57 | */ 58 | export function newEmptyDisplay(): ObjectDisplay { 59 | return { 60 | name: null, 61 | description: null, 62 | link: null, 63 | image_url: null, 64 | thumbnail_url: null, 65 | project_name: null, 66 | project_url: null, 67 | project_image_url: null, 68 | creator: null, 69 | }; 70 | } 71 | 72 | /** 73 | * Validate a `SuiObjectResponse` and return its `.data.content.fields`. 74 | */ 75 | // biome-ignore lint/suspicious/noExplicitAny: iykyk 76 | export function objResToFields(resp: SuiObjectResponse): Record { 77 | if (resp.data?.content?.dataType !== "moveObject") { 78 | throw Error(`response content missing: ${JSON.stringify(resp, null, 2)}`); 79 | } 80 | // biome-ignore lint/suspicious/noExplicitAny: iykyk 81 | return resp.data.content.fields as Record; 82 | } 83 | 84 | /** 85 | * Validate a `SuiObjectResponse` and return its `.data.objectId`. 86 | */ 87 | export function objResToId(resp: SuiObjectResponse): string { 88 | if (!resp.data) { 89 | throw Error(`response has no data: ${JSON.stringify(resp, null, 2)}`); 90 | } 91 | return resp.data.objectId; 92 | } 93 | 94 | /** 95 | * Validate a `SuiObjectResponse` and return its owner: an address, object ID, "shared" or "immutable". 96 | */ 97 | export function objResToOwner(resp: SuiObjectResponse): string { 98 | if (!resp.data?.owner) { 99 | throw Error(`response has no owner data: ${JSON.stringify(resp, null, 2)}`); 100 | } 101 | if (isOwnerKind(resp.data.owner, "AddressOwner")) { 102 | return resp.data.owner.AddressOwner; 103 | } 104 | if (isOwnerKind(resp.data.owner, "ObjectOwner")) { 105 | return resp.data.owner.ObjectOwner; 106 | } 107 | if (isOwnerKind(resp.data.owner, "Shared")) { 108 | return "shared"; 109 | } 110 | if (isOwnerKind(resp.data.owner, "Immutable")) { 111 | return "immutable"; 112 | } 113 | return "unknown"; 114 | } 115 | 116 | /** 117 | * Validate a `SuiObjectResponse` and return its `{.data.objectId, .data.digest, .data.version}`. 118 | */ 119 | export function objResToRef(resp: SuiObjectResponse): SuiObjectRef { 120 | if (!resp.data) { 121 | throw Error(`response has no data: ${JSON.stringify(resp, null, 2)}`); 122 | } 123 | return { 124 | objectId: resp.data.objectId, 125 | digest: resp.data.digest, 126 | version: resp.data.version, 127 | }; 128 | } 129 | -------------------------------------------------------------------------------- /src/core/src/rpcs.ts: -------------------------------------------------------------------------------- 1 | import { getFullnodeUrl, SuiClient } from "@mysten/sui/client"; 2 | 3 | import type { NetworkName } from "./types.js"; 4 | 5 | /** 6 | * Default RPC endpoint URLs for SuiMultiClient. 7 | * Manually tested for the last time on September 2024. 8 | */ 9 | export const RPC_ENDPOINTS: Record = { 10 | mainnet: [ 11 | getFullnodeUrl("mainnet"), 12 | "https://mainnet.suiet.app", 13 | "https://rpc-mainnet.suiscan.xyz", 14 | "https://mainnet.sui.rpcpool.com", 15 | "https://sui-mainnet.nodeinfra.com", 16 | "https://mainnet-rpc.sui.chainbase.online", 17 | "https://sui-mainnet-ca-1.cosmostation.io", 18 | "https://sui-mainnet-ca-2.cosmostation.io", 19 | "https://sui-mainnet-us-1.cosmostation.io", 20 | "https://sui-mainnet-us-2.cosmostation.io", 21 | 22 | // "https://sui-mainnet.public.blastapi.io", // 500 23 | // "https://sui-mainnet-endpoint.blockvision.org", // 429 too many requests 24 | 25 | // "https://sui-rpc.publicnode.com", // 504 Gateway Timeout on queryTransactionBlocks() with showEffects/show* 26 | // "https://sui1mainnet-rpc.chainode.tech", // CORS error 27 | // "https://sui-mainnet-rpc.allthatnode.com", // 000 28 | // "https://sui-mainnet-rpc-korea.allthatnode.com", // 000 29 | // "https://sui-mainnet-rpc-germany.allthatnode.com", // 000 30 | // "https://sui-mainnet-rpc.bartestnet.com", // 403 31 | // "https://sui-rpc-mainnet.testnet-pride.com", // 502 32 | // "https://sui-mainnet-eu-1.cosmostation.io", // 000 33 | // "https://sui-mainnet-eu-2.cosmostation.io", // 000 34 | // "https://sui-mainnet-eu-3.cosmostation.io", // CORS error 35 | // "https://sui-mainnet-eu-4.cosmostation.io", // CORS error 36 | ], 37 | testnet: [ 38 | getFullnodeUrl("testnet"), 39 | "https://rpc-testnet.suiscan.xyz", 40 | "https://sui-testnet-endpoint.blockvision.org", 41 | "https://sui-testnet.public.blastapi.io", 42 | "https://testnet.suiet.app", 43 | "https://sui-testnet.nodeinfra.com", 44 | "https://testnet.sui.rpcpool.com", 45 | "https://sui-testnet-rpc.publicnode.com", 46 | ], 47 | devnet: [ 48 | getFullnodeUrl("devnet"), 49 | // "https://devnet.suiet.app", // no data 50 | ], 51 | localnet: [ 52 | // to simulate multiple RPC endpoints locally 53 | `${getFullnodeUrl("localnet")}?localnet-1`, 54 | `${getFullnodeUrl("localnet")}?localnet-2`, 55 | `${getFullnodeUrl("localnet")}?localnet-3`, 56 | `${getFullnodeUrl("localnet")}?localnet-4`, 57 | `${getFullnodeUrl("localnet")}?localnet-5`, 58 | ], 59 | }; 60 | 61 | /** 62 | * A result returned by `measureRpcLatency`. 63 | */ 64 | export type RpcLatencyResult = { 65 | endpoint: string; 66 | latency?: number; 67 | error?: string; 68 | }; 69 | 70 | /** 71 | * Measure Sui RPC latency by making requests to various endpoints. 72 | */ 73 | export async function measureRpcLatency({ 74 | // TODO: average, p-50, p-90 75 | endpoints, 76 | rpcRequest = async (client: SuiClient) => { 77 | await client.getObject({ id: "0x123" }); 78 | }, 79 | }: { 80 | endpoints: string[]; 81 | rpcRequest?: ((client: SuiClient) => Promise) | undefined; 82 | }): Promise { 83 | const promises = endpoints.map(async (url) => { 84 | try { 85 | const suiClient = new SuiClient({ url }); 86 | const startTime = performance.now(); 87 | await rpcRequest(suiClient); 88 | const latency = performance.now() - startTime; 89 | return { endpoint: url, latency }; 90 | } catch (err) { 91 | return { endpoint: url, error: String(err) }; 92 | } 93 | }); 94 | 95 | const results = await Promise.allSettled(promises); 96 | return results.map((result) => { 97 | if (result.status === "fulfilled") { 98 | return result.value; 99 | } else { 100 | // should never happen 101 | return { 102 | endpoint: "Unknown endpoint", 103 | error: String(result.reason.message) || "Unknown error", 104 | }; 105 | } 106 | }); 107 | } 108 | 109 | /** 110 | * Instantiate SuiClient using the RPC endpoint with the lowest latency. 111 | */ 112 | export async function newLowLatencySuiClient({ 113 | endpoints, 114 | rpcRequest, 115 | }: { 116 | endpoints: string[]; 117 | rpcRequest?: (client: SuiClient) => Promise; 118 | }): Promise { 119 | const results = await measureRpcLatency({ endpoints, rpcRequest }); 120 | const suiClient = new SuiClient({ url: results[0].endpoint }); 121 | return suiClient; 122 | } 123 | -------------------------------------------------------------------------------- /src/core/src/__tests__/stringToBalance.test.ts: -------------------------------------------------------------------------------- 1 | import { stringToBalance } from "../balances"; 2 | 3 | describe("stringToBalance", () => { 4 | it("should convert a basic integer string to BigInt with no decimals", () => { 5 | expect(stringToBalance("123", 0)).toBe(123n); 6 | expect(stringToBalance("0", 0)).toBe(0n); 7 | }); 8 | 9 | it("should convert a string with decimals to BigInt", () => { 10 | expect(stringToBalance("123.456", 3)).toBe(123456n); 11 | expect(stringToBalance("1.000000001", 9)).toBe(1000000001n); 12 | expect(stringToBalance("123.456789", 6)).toBe(123456789n); 13 | }); 14 | 15 | it("should handle leading and trailing zeros correctly", () => { 16 | expect(stringToBalance("000123.456", 3)).toBe(123456n); 17 | expect(stringToBalance("123.456000", 6)).toBe(123456000n); 18 | expect(stringToBalance("00123.000456", 6)).toBe(123000456n); 19 | }); 20 | 21 | it("should pad with zeros if the decimal part is shorter than the given decimals", () => { 22 | expect(stringToBalance("123.4", 3)).toBe(123400n); 23 | expect(stringToBalance("123.45", 5)).toBe(12345000n); 24 | }); 25 | 26 | it("should handle cases with more decimal places than the specified decimals by truncating", () => { 27 | expect(stringToBalance("123.45678", 3)).toBe(123456n); // Truncate to 123.456 28 | }); 29 | 30 | it("should handle large numbers correctly", () => { 31 | expect(stringToBalance("123456789012345678901234567890", 0)).toBe( 32 | 123456789012345678901234567890n, 33 | ); 34 | expect(stringToBalance("123456789012345678901234567890.123456789", 9)).toBe( 35 | 123456789012345678901234567890123456789n, 36 | ); 37 | }); 38 | 39 | it("should handle very small fractional numbers correctly", () => { 40 | expect(stringToBalance("0.000000001", 9)).toBe(1n); 41 | expect(stringToBalance("0.000001", 9)).toBe(1000n); 42 | }); 43 | 44 | it("should return 0n for inputs with only zeros", () => { 45 | expect(stringToBalance("0", 3)).toBe(0n); 46 | expect(stringToBalance("0.000", 3)).toBe(0n); 47 | }); 48 | 49 | it("should return 0n for empty inputs", () => { 50 | expect(stringToBalance("", 3)).toBe(0n); 51 | expect(stringToBalance(" ", 3)).toBe(0n); 52 | expect(stringToBalance(".", 3)).toBe(0n); 53 | expect(stringToBalance("-", 3)).toBe(0n); 54 | }); 55 | 56 | it("should throw an error for inputs with special characters or symbols", () => { 57 | expect(() => stringToBalance("-.", 3)).toThrow(); 58 | expect(() => stringToBalance("1-2", 3)).toThrow(); 59 | expect(() => stringToBalance("1..2", 3)).toThrow(); 60 | expect(() => stringToBalance("123abc", 3)).toThrow(); 61 | expect(() => stringToBalance("abc", 3)).toThrow(); 62 | expect(() => stringToBalance("123.456.789", 3)).toThrow(); 63 | expect(() => stringToBalance("+123.456", 3)).toThrow("Invalid input"); 64 | expect(() => stringToBalance("123$456", 3)).toThrow("Invalid input"); 65 | expect(() => stringToBalance("#123.456", 3)).toThrow("Invalid input"); 66 | }); 67 | 68 | it("should correctly handle negative values", () => { 69 | expect(stringToBalance("-123.456", 3)).toBe(-123456n); 70 | expect(stringToBalance("-0.001", 3)).toBe(-1n); 71 | expect(stringToBalance("-0", 3)).toBe(0n); 72 | expect(stringToBalance("-0.000", 3)).toBe(0n); 73 | }); 74 | 75 | it("should handle no decimals input correctly", () => { 76 | expect(stringToBalance("123", 5)).toBe(12300000n); 77 | expect(stringToBalance("1.5", 0)).toBe(1n); // If no decimals are specified, it should truncate the fractional part 78 | }); 79 | 80 | it("should handle maximum safe integer value", () => { 81 | const maxSafeInteger = Number.MAX_SAFE_INTEGER.toString(); 82 | expect(stringToBalance(maxSafeInteger, 0)).toBe(BigInt(maxSafeInteger)); 83 | }); 84 | 85 | it("should handle exceedingly long inputs", () => { 86 | const longInput = `${"1".repeat(100)}.${"9".repeat(50)}`; 87 | const expectedOutput = BigInt("1".repeat(100) + "9".repeat(9)); 88 | expect(stringToBalance(longInput, 9)).toBe(expectedOutput); 89 | }); 90 | 91 | it("should handle multiple leading zeros in integer part", () => { 92 | expect(stringToBalance("0000123.456", 3)).toBe(123456n); 93 | }); 94 | 95 | it("should handle different decimals values correctly", () => { 96 | expect(stringToBalance("123.456", 0)).toBe(123n); 97 | expect(stringToBalance("123", 6)).toBe(123000000n); 98 | expect(stringToBalance("1.234567", 8)).toBe(123456700n); 99 | }); 100 | 101 | it("should handle inputs with leading and trailing whitespace", () => { 102 | expect(stringToBalance(" 123.456 ", 3)).toBe(123456n); 103 | expect(stringToBalance(" 123.456 ", 6)).toBe(123456000n); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /src/core/src/format.ts: -------------------------------------------------------------------------------- 1 | /** Time units in milliseconds. */ 2 | export enum TimeUnit { 3 | ONE_SECOND = 1000, 4 | ONE_MINUTE = 60_000, 5 | ONE_HOUR = 3_600_000, 6 | ONE_DAY = 86_400_000, 7 | } 8 | 9 | /** 10 | * Return a human-readable string from a number of basis points. 11 | * E.g. "100 bps" -> "1%". 12 | */ 13 | export const formatBps = (bps: number): string => { 14 | return `${bps / 100}%`; 15 | }; 16 | 17 | /** 18 | * Return a human-readable date string from a timestamp in milliseconds. 19 | */ 20 | export const formatDate = (ms: number): string => { 21 | return new Date(ms).toLocaleString(); 22 | }; 23 | 24 | /** 25 | * Return a human-readable string from a number of milliseconds. 26 | * E.g. "1 day", "2 hours", "3 minutes", "4 seconds". 27 | */ 28 | export const formatDuration = (ms: number): string => { 29 | const formatUnit = (value: number, unit: string) => 30 | `${value} ${unit}${value !== 1 ? "s" : ""}`; 31 | 32 | if (ms >= Number(TimeUnit.ONE_DAY)) { 33 | return formatUnit(Math.floor(ms / TimeUnit.ONE_DAY), "day"); 34 | } 35 | if (ms >= Number(TimeUnit.ONE_HOUR)) { 36 | return formatUnit(Math.floor(ms / TimeUnit.ONE_HOUR), "hour"); 37 | } 38 | if (ms >= Number(TimeUnit.ONE_MINUTE)) { 39 | return formatUnit(Math.floor(ms / TimeUnit.ONE_MINUTE), "minute"); 40 | } 41 | return formatUnit(Math.floor(ms / TimeUnit.ONE_SECOND), "second"); 42 | }; 43 | 44 | /** 45 | * Return a human-readable string with the time difference between two timestamps. 46 | * E.g. "30s"/"30 sec", "1h"/"1 hour", "2d"/"2 days". 47 | */ 48 | export function formatTimeDiff({ 49 | timestamp, 50 | now = Date.now(), 51 | format = "short", 52 | minTimeUnit = TimeUnit.ONE_SECOND, 53 | }: { 54 | timestamp: number; 55 | now?: number; 56 | format?: "short" | "long"; 57 | minTimeUnit?: TimeUnit; 58 | }): string { 59 | if (!timestamp) return ""; 60 | 61 | let diff = Math.abs(now - timestamp); 62 | 63 | if (diff < Number(minTimeUnit)) { 64 | return getEndLabel(minTimeUnit, format); 65 | } 66 | 67 | let timeUnit: [string, number][]; 68 | if (diff >= Number(TimeUnit.ONE_DAY)) { 69 | timeUnit = [ 70 | [TIME_LABEL.day[format], TimeUnit.ONE_DAY], 71 | [TIME_LABEL.hour[format], TimeUnit.ONE_HOUR], 72 | ]; 73 | } else if (diff >= Number(TimeUnit.ONE_HOUR)) { 74 | timeUnit = [ 75 | [TIME_LABEL.hour[format], TimeUnit.ONE_HOUR], 76 | [TIME_LABEL.min[format], TimeUnit.ONE_MINUTE], 77 | ]; 78 | } else { 79 | timeUnit = [ 80 | [TIME_LABEL.min[format], TimeUnit.ONE_MINUTE], 81 | [TIME_LABEL.sec[format], TimeUnit.ONE_SECOND], 82 | ]; 83 | } 84 | 85 | const convertAmount = (amount: number, label: string) => { 86 | const spacing = format === "short" ? "" : " "; 87 | if (amount > 1) return `${amount}${spacing}${label}${format === "long" ? "s" : ""}`; 88 | if (amount === 1) return `${amount}${spacing}${label}`; 89 | return ""; 90 | }; 91 | 92 | const resultArr = timeUnit.map(([label, denom]) => { 93 | const whole = Math.floor(diff / denom); 94 | diff = diff - whole * denom; 95 | return convertAmount(whole, label); 96 | }); 97 | 98 | const result = resultArr.join(" ").trim(); 99 | 100 | return result || getEndLabel(minTimeUnit, format); 101 | } 102 | 103 | const TIME_LABEL = { 104 | year: { long: "year", short: "y" }, 105 | month: { long: "month", short: "m" }, 106 | day: { long: "day", short: "d" }, 107 | hour: { long: "hour", short: "h" }, 108 | min: { long: "min", short: "m" }, 109 | sec: { long: "sec", short: "s" }, 110 | }; 111 | 112 | function getEndLabel(minTimeUnit: TimeUnit, format: "short" | "long"): string { 113 | let minLabel = ""; 114 | switch (minTimeUnit) { 115 | case TimeUnit.ONE_DAY: 116 | minLabel = TIME_LABEL.day[format]; 117 | break; 118 | case TimeUnit.ONE_HOUR: 119 | minLabel = TIME_LABEL.hour[format]; 120 | break; 121 | case TimeUnit.ONE_MINUTE: 122 | minLabel = TIME_LABEL.min[format]; 123 | break; 124 | default: 125 | minLabel = TIME_LABEL.sec[format]; 126 | } 127 | if (format === "short") { 128 | return `< 1${minLabel}`; 129 | } else { 130 | return `< 1 ${minLabel}`; 131 | } 132 | } 133 | 134 | /** 135 | * Return the domain from a URL. 136 | * E.g. `"https://polymedia.app"` -> `"polymedia.app"`. 137 | */ 138 | export const urlToDomain = (url: string): string => { 139 | const match = /^https?:\/\/([^/]+)/.exec(url); 140 | return match ? match[1] : ""; 141 | }; 142 | 143 | /** 144 | * Return a shortened version of a transaction digest. 145 | * E.g. "yjxT3tJvRdkg5p5NFN64hGUGSntWoB8MtA34ErFYMgW" -> "yjxT…YMgW". 146 | */ 147 | export const shortenDigest = ( 148 | digest: string, 149 | start = 4, 150 | end = 4, 151 | separator = "…", 152 | ): string => { 153 | return digest.slice(0, start) + separator + digest.slice(-end); 154 | }; 155 | -------------------------------------------------------------------------------- /src/node/src/sui.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "node:child_process"; 2 | import fs from "node:fs"; 3 | import { homedir } from "node:os"; 4 | import path from "node:path"; 5 | import { promisify } from "node:util"; 6 | import { 7 | getFullnodeUrl, 8 | SuiClient, 9 | type SuiTransactionBlockResponse, 10 | type SuiTransactionBlockResponseOptions, 11 | } from "@mysten/sui/client"; 12 | import type { Signer } from "@mysten/sui/cryptography"; 13 | import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519"; 14 | import { Transaction } from "@mysten/sui/transactions"; 15 | import { fromBase64 } from "@mysten/sui/utils"; 16 | import { 17 | type NetworkName, 18 | validateAndNormalizeAddress, 19 | type WaitForTxOptions, 20 | } from "@polymedia/suitcase-core"; 21 | 22 | const execAsync = promisify(exec); 23 | 24 | /** 25 | * Get the current active address (sui client active-address). 26 | */ 27 | export async function getActiveAddress(): Promise { 28 | const { stdout } = await execAsync("sui client active-address"); 29 | const address = validateAndNormalizeAddress(stdout.trim()); 30 | if (!address) throw new Error("No active address was found"); 31 | return address; 32 | } 33 | 34 | /** 35 | * Build a `Ed25519Keypair` object for the current active address 36 | * by loading the secret key from ~/.sui/sui_config/sui.keystore 37 | */ 38 | export async function getActiveKeypair(): Promise { 39 | const sender = await getActiveAddress(); 40 | const keystorePath = path.join(homedir(), ".sui", "sui_config", "sui.keystore"); 41 | const keystore = await fs.promises.readFile(keystorePath, "utf8"); 42 | const keys = JSON.parse(keystore) as string[]; 43 | 44 | for (const priv of keys) { 45 | const raw = fromBase64(priv); 46 | if (raw[0] !== 0) continue; 47 | const pair = Ed25519Keypair.fromSecretKey(raw.slice(1)); 48 | if (pair.getPublicKey().toSuiAddress() === sender) { 49 | return pair; 50 | } 51 | } 52 | throw new Error(`keypair not found for sender: ${sender}`); 53 | } 54 | 55 | /** 56 | * Get the active Sui environment from `sui client active-env`. 57 | */ 58 | export async function getActiveEnv(): Promise { 59 | const { stdout } = await execAsync("sui client active-env"); 60 | return stdout.trim() as NetworkName; 61 | } 62 | 63 | /** 64 | * Initialize objects to execute Sui transactions blocks 65 | * using the current Sui active network and address. 66 | */ 67 | export async function setupSuiTransaction() { 68 | const [network, signer] = await Promise.all([getActiveEnv(), getActiveKeypair()]); 69 | const client = new SuiClient({ url: getFullnodeUrl(network) }); 70 | const tx = new Transaction(); 71 | return { network, client, tx, signer }; 72 | } 73 | 74 | // biome-ignore-start lint/suspicious/noExplicitAny: iykyk 75 | /** 76 | * Suppress "Client/Server api version mismatch" warnings. 77 | */ 78 | export function suppressSuiVersionMismatchWarnings() { 79 | // Skip if already wrapped 80 | if ((process.stderr.write as any).__isSuppressingVersionMismatch) return; 81 | // Store the original stderr.write function, properly bound to stderr 82 | const originalStderr = process.stderr.write.bind(process.stderr); 83 | // Override stderr.write with our custom function 84 | process.stderr.write = ( 85 | str: string | Uint8Array, 86 | encoding?: BufferEncoding | ((err?: Error | null) => void), 87 | cb?: (err?: Error | null) => void, 88 | ): boolean => { 89 | // If it's a version mismatch warning, return true (indicating success) without writing 90 | if (str.toString().includes("Client/Server api version mismatch")) { 91 | return true; 92 | } 93 | // For all other messages, pass through to the original stderr.write 94 | return originalStderr(str, encoding as any, cb); 95 | }; 96 | // Mark as wrapped in case this function is called multiple times 97 | (process.stderr.write as any).__isSuppressingVersionMismatch = true; 98 | } 99 | // biome-ignore-end lint/suspicious/noExplicitAny: iykyk 100 | 101 | /** 102 | * Execute a transaction block with `showEffects` and `showObjectChanges` set to `true`. 103 | */ 104 | export async function signAndExecuteTx({ 105 | client, 106 | tx, 107 | signer, 108 | txRespOptions = { showEffects: true, showObjectChanges: true }, 109 | waitForTxOptions = { timeout: 60_000, pollInterval: 250 }, 110 | }: { 111 | client: SuiClient; 112 | tx: Transaction; 113 | signer: Signer; 114 | txRespOptions?: SuiTransactionBlockResponseOptions; 115 | waitForTxOptions?: WaitForTxOptions | false; 116 | }): Promise { 117 | const resp = await client.signAndExecuteTransaction({ 118 | signer, 119 | transaction: tx, 120 | options: txRespOptions, 121 | }); 122 | 123 | if (!waitForTxOptions) { 124 | return resp; 125 | } 126 | 127 | return await client.waitForTransaction({ 128 | digest: resp.digest, 129 | options: txRespOptions, 130 | ...waitForTxOptions, 131 | }); 132 | } 133 | -------------------------------------------------------------------------------- /src/node/src/files.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | import { fileURLToPath } from "node:url"; 4 | 5 | // === types === 6 | 7 | /** 8 | * A generic function to transform a CSV/TSV line into an object. 9 | */ 10 | export type ParseLine = (values: string[]) => T | null; 11 | 12 | // === public functions === 13 | 14 | /** 15 | * Check if a file exists in the filesystem. 16 | */ 17 | export function fileExists(filename: string): boolean { 18 | try { 19 | fs.accessSync(filename); 20 | return true; 21 | } catch (_err) { 22 | return false; 23 | } 24 | } 25 | 26 | /** 27 | * Extract the file name from a module URL, without path or extension. 28 | */ 29 | export function getFileName(importMetaUrl: string): string { 30 | const __filename = fileURLToPath(importMetaUrl); 31 | return path.basename(__filename, path.extname(__filename)); 32 | } 33 | 34 | /** 35 | * Read a CSV file and parse each line into an object. 36 | */ 37 | export function readCsvFile( 38 | filename: string, 39 | parseLine: ParseLine, 40 | skipHeader = false, 41 | reverse = false, 42 | ): T[] { 43 | return readDsvFile(filename, parseLine, ",", skipHeader, reverse); 44 | } 45 | 46 | /** 47 | * Read a JSON file and parse its contents into an object. 48 | */ 49 | export function readJsonFile(filename: string): T { 50 | const fileContent = fs.readFileSync(filename, "utf8"); 51 | const jsonData = JSON.parse(fileContent) as T; 52 | return jsonData; 53 | } 54 | 55 | /** 56 | * Read a TSV file and parse each line into an object. 57 | */ 58 | export function readTsvFile( 59 | filename: string, 60 | parseLine: ParseLine, 61 | skipHeader = false, 62 | reverse = false, 63 | ): T[] { 64 | return readDsvFile(filename, parseLine, "\t", skipHeader, reverse); 65 | } 66 | 67 | /** 68 | * Write objects into a CSV file. 69 | * It won't work correctly if the input data contains commas. 70 | * Better use `writeTsvFile()`. 71 | */ 72 | export function writeCsvFile(filename: string, data: unknown[][]): void { 73 | writeDsvFile(filename, data, ","); 74 | } 75 | 76 | /** 77 | * Write an object's JSON representation into a file. 78 | */ 79 | export function writeJsonFile(filename: string, contents: unknown): void { 80 | writeTextFile(filename, JSON.stringify(contents, null, 4)); 81 | } 82 | 83 | /** 84 | * Write a string into a file. 85 | */ 86 | export function writeTextFile(filename: string, contents: string): void { 87 | fs.writeFileSync(filename, `${contents}\n`); 88 | } 89 | 90 | /** 91 | * Write objects into a TSV file. 92 | */ 93 | export function writeTsvFile(filename: string, data: unknown[][]): void { 94 | writeDsvFile(filename, data, "\t"); 95 | } 96 | 97 | // === private functions === 98 | 99 | /** 100 | * Read a DSV file and parse each line into an object. 101 | */ 102 | function readDsvFile( 103 | filename: string, 104 | parseLine: ParseLine, 105 | delimiter: string, 106 | skipHeader = false, 107 | reverse = false, 108 | ): T[] { 109 | const results: T[] = []; 110 | const fileContent = fs.readFileSync(filename, "utf8"); 111 | 112 | let lines = fileContent.split("\n"); 113 | if (reverse) { 114 | lines = lines.reverse(); 115 | } 116 | if (skipHeader && lines.length > 0) { 117 | lines = lines.slice(1); 118 | } 119 | 120 | for (const line of lines) { 121 | const trimmedLine = line.trim(); 122 | if (!trimmedLine) { 123 | continue; 124 | } 125 | 126 | // Split the line by the delimiter and remove quotes from each value 127 | const values = splitDsvLine(trimmedLine, delimiter); 128 | const parsedLine = parseLine(values); 129 | if (parsedLine !== null) { 130 | results.push(parsedLine); 131 | } 132 | } 133 | 134 | return results; 135 | } 136 | 137 | function splitDsvLine(line: string, delimiter: string): string[] { 138 | const values = line.split(delimiter); 139 | return values.map((value) => unescapeDsvString(value)); 140 | } 141 | 142 | function unescapeDsvString(value: string): string { 143 | // Remove surrounding quotes if present 144 | if (value.startsWith('"') && value.endsWith('"')) { 145 | value = value.slice(1, -1); 146 | } 147 | // Unescape double quotes 148 | value = value.replace(/""/g, '"'); 149 | 150 | // Return the processed string 151 | return value; 152 | } 153 | 154 | /** 155 | * Write objects into a DSV file. 156 | */ 157 | function writeDsvFile(filename: string, data: unknown[][], delimiter: string): void { 158 | const rows = data.map((line) => makeDsvLine(Object.values(line), delimiter)); 159 | writeTextFile(filename, rows.join("\n")); 160 | } 161 | 162 | function makeDsvLine(values: unknown[], delimiter: string): string { 163 | return values.map(escapeDsvString).join(delimiter); 164 | } 165 | 166 | function escapeDsvString(value: unknown): string { 167 | return `"${String(value) 168 | .replace(/"/g, '""') // escape double quotes by doubling them 169 | .replace(/\t/g, " ") // replace tabs with a space 170 | .replace(/(\r\n|\n|\r)/g, " ")}"`; // replace newlines with a space 171 | } 172 | -------------------------------------------------------------------------------- /src/core/src/__tests__/formatTimeDiff.test.ts: -------------------------------------------------------------------------------- 1 | import { formatTimeDiff, TimeUnit } from "../format"; 2 | 3 | describe("formatTimeDiff", () => { 4 | const now = Date.UTC(2024, 0, 1, 0, 0, 0, 0); // Jan 1, 2024, 00:00:00 UTC 5 | 6 | it("shows time diff in seconds, minutes, hours, days", () => { 7 | expect( 8 | formatTimeDiff({ 9 | timestamp: now + (30 * TimeUnit.ONE_SECOND + 123), 10 | now, 11 | }), 12 | ).toBe("30s"); 13 | expect( 14 | formatTimeDiff({ 15 | timestamp: now + (90 * TimeUnit.ONE_SECOND + 123), 16 | now, 17 | }), 18 | ).toBe("1m 30s"); 19 | expect( 20 | formatTimeDiff({ 21 | timestamp: 22 | now + 2 * TimeUnit.ONE_HOUR + 5 * TimeUnit.ONE_MINUTE + 5 * TimeUnit.ONE_SECOND, 23 | now, 24 | }), 25 | ).toBe("2h 5m"); 26 | expect( 27 | formatTimeDiff({ 28 | timestamp: 29 | now + 30 | 3 * TimeUnit.ONE_DAY + 31 | 4 * TimeUnit.ONE_HOUR + 32 | 5 * TimeUnit.ONE_MINUTE + 33 | 5 * TimeUnit.ONE_SECOND, 34 | now, 35 | }), 36 | ).toBe("3d 4h"); 37 | }); 38 | 39 | it("uses full labels if format is 'long'", () => { 40 | expect( 41 | formatTimeDiff({ 42 | timestamp: now + 30 * TimeUnit.ONE_SECOND, 43 | now, 44 | format: "long", 45 | }), 46 | ).toBe("30 secs"); 47 | expect( 48 | formatTimeDiff({ 49 | timestamp: now + 90 * TimeUnit.ONE_SECOND, 50 | now, 51 | format: "long", 52 | }), 53 | ).toBe("1 min 30 secs"); 54 | expect( 55 | formatTimeDiff({ 56 | timestamp: now + 2 * TimeUnit.ONE_HOUR + 5 * TimeUnit.ONE_MINUTE, 57 | now, 58 | format: "long", 59 | }), 60 | ).toBe("2 hours 5 mins"); 61 | expect( 62 | formatTimeDiff({ 63 | timestamp: now + 3 * TimeUnit.ONE_DAY + 4 * TimeUnit.ONE_HOUR, 64 | now, 65 | format: "long", 66 | }), 67 | ).toBe("3 days 4 hours"); 68 | }); 69 | 70 | it("returns endLabel if time diff is less than minTimeUnit", () => { 71 | expect( 72 | formatTimeDiff({ 73 | timestamp: now + (TimeUnit.ONE_SECOND - 1), 74 | now, 75 | minTimeUnit: TimeUnit.ONE_SECOND, 76 | }), 77 | ).toBe("< 1s"); 78 | expect( 79 | formatTimeDiff({ 80 | timestamp: now + 59 * TimeUnit.ONE_SECOND, 81 | now, 82 | minTimeUnit: TimeUnit.ONE_MINUTE, 83 | }), 84 | ).toBe("< 1m"); 85 | expect( 86 | formatTimeDiff({ 87 | timestamp: now + (TimeUnit.ONE_MINUTE - 1), 88 | now, 89 | minTimeUnit: TimeUnit.ONE_MINUTE, 90 | format: "long", 91 | }), 92 | ).toBe("< 1 min"); 93 | expect( 94 | formatTimeDiff({ 95 | timestamp: now + (TimeUnit.ONE_HOUR - 1), 96 | now, 97 | minTimeUnit: TimeUnit.ONE_HOUR, 98 | format: "long", 99 | }), 100 | ).toBe("< 1 hour"); 101 | expect( 102 | formatTimeDiff({ 103 | timestamp: now + (TimeUnit.ONE_DAY - 1), 104 | now, 105 | minTimeUnit: TimeUnit.ONE_DAY, 106 | format: "long", 107 | }), 108 | ).toBe("< 1 day"); 109 | }); 110 | 111 | it("returns only one unit for exact time diffs", () => { 112 | expect( 113 | formatTimeDiff({ 114 | timestamp: now + TimeUnit.ONE_MINUTE, 115 | now, 116 | minTimeUnit: TimeUnit.ONE_MINUTE, 117 | }), 118 | ).toBe("1m"); 119 | expect( 120 | formatTimeDiff({ 121 | timestamp: now + TimeUnit.ONE_HOUR, 122 | now, 123 | minTimeUnit: TimeUnit.ONE_HOUR, 124 | }), 125 | ).toBe("1h"); 126 | expect( 127 | formatTimeDiff({ 128 | timestamp: now + TimeUnit.ONE_DAY, 129 | now, 130 | minTimeUnit: TimeUnit.ONE_DAY, 131 | }), 132 | ).toBe("1d"); 133 | }); 134 | 135 | it("works for past and future timestamps", () => { 136 | expect( 137 | formatTimeDiff({ 138 | timestamp: now - 90 * TimeUnit.ONE_SECOND, 139 | now, 140 | }), 141 | ).toBe("1m 30s"); 142 | expect( 143 | formatTimeDiff({ 144 | timestamp: now - 2 * TimeUnit.ONE_HOUR - 5 * TimeUnit.ONE_MINUTE, 145 | now, 146 | }), 147 | ).toBe("2h 5m"); 148 | }); 149 | 150 | it("returns endLabel if result is empty string", () => { 151 | // This can only happen if time diff is 0 152 | expect( 153 | formatTimeDiff({ 154 | timestamp: now, 155 | now, 156 | }), 157 | ).toBe("< 1s"); 158 | }); 159 | 160 | it("returns correct result for boundary values (exactly 1m, 1h, 1d)", () => { 161 | expect( 162 | formatTimeDiff({ 163 | timestamp: now + TimeUnit.ONE_MINUTE, 164 | now, 165 | }), 166 | ).toBe("1m"); 167 | expect( 168 | formatTimeDiff({ 169 | timestamp: now + TimeUnit.ONE_HOUR, 170 | now, 171 | }), 172 | ).toBe("1h"); 173 | expect( 174 | formatTimeDiff({ 175 | timestamp: now + TimeUnit.ONE_DAY, 176 | now, 177 | }), 178 | ).toBe("1d"); 179 | // long format 180 | expect( 181 | formatTimeDiff({ 182 | timestamp: now + TimeUnit.ONE_MINUTE, 183 | now, 184 | format: "long", 185 | }), 186 | ).toBe("1 min"); 187 | expect( 188 | formatTimeDiff({ 189 | timestamp: now + TimeUnit.ONE_HOUR, 190 | now, 191 | format: "long", 192 | }), 193 | ).toBe("1 hour"); 194 | expect( 195 | formatTimeDiff({ 196 | timestamp: now + TimeUnit.ONE_DAY, 197 | now, 198 | format: "long", 199 | }), 200 | ).toBe("1 day"); 201 | }); 202 | }); 203 | -------------------------------------------------------------------------------- /src/core/src/SuiMultiClient.ts: -------------------------------------------------------------------------------- 1 | import { SuiClient } from "@mysten/sui/client"; 2 | 3 | import { sleep } from "./misc.js"; 4 | import { RPC_ENDPOINTS } from "./rpcs.js"; 5 | import type { NetworkName } from "./types.js"; 6 | 7 | /** 8 | * Make many RPC requests using multiple endpoints. 9 | * @see SuiMultiClient.executeInBatches() 10 | */ 11 | export class SuiMultiClient { 12 | private readonly clients: SuiClientWithEndpoint[]; 13 | private readonly rateLimitDelay: number; 14 | private clientIdx: number; // the index of the next client to be returned by getNextClient() 15 | 16 | /** 17 | * @param endpointUrls A list of Sui RPC endpoint URLs. 18 | * @param rateLimitDelay (optional) Minimum time between batches, in milliseconds. 19 | */ 20 | constructor(endpointUrls: string[], rateLimitDelay = 300) { 21 | this.clients = []; 22 | this.clientIdx = 0; 23 | this.rateLimitDelay = rateLimitDelay; 24 | const endpoints = endpointUrls; 25 | for (const endpoint of endpoints) { 26 | const client = new SuiClient({ url: endpoint }); 27 | const clientWithEndpoint = Object.assign(client, { endpoint }); 28 | this.clients.push(clientWithEndpoint); 29 | } 30 | } 31 | 32 | /** 33 | * Create a SuiMultiClient instance with the default endpoints for a given network. 34 | * @param network The network name to select default RPC endpoints. 35 | * @param rateLimitDelay (optional) Minimum time between batches, in milliseconds. 36 | */ 37 | public static newWithDefaultEndpoints( 38 | network: NetworkName, 39 | rateLimitDelay?: number, 40 | ): SuiMultiClient { 41 | const endpoints = RPC_ENDPOINTS[network]; 42 | return new SuiMultiClient(endpoints, rateLimitDelay); 43 | } 44 | 45 | /** 46 | * Returns a different SuiClient in a round-robin fashion 47 | */ 48 | private getNextClient(): SuiClientWithEndpoint { 49 | const client = this.clients[this.clientIdx]; 50 | this.clientIdx = (this.clientIdx + 1) % this.clients.length; 51 | return client; 52 | } 53 | 54 | /** 55 | * Execute `SuiClient` RPC operations in parallel using multiple endpoints. 56 | * If any operation fails, it's retried by calling this function recursively. 57 | * @param inputs The inputs for each RPC call. 58 | * @param operation A function that performs the RPC operation. 59 | * @returns The results of the RPC operations in the same order as the inputs. 60 | */ 61 | public async executeInBatches( 62 | inputs: InputType[], 63 | operation: (client: SuiClientWithEndpoint, input: InputType) => Promise, 64 | onUpdate?: (msg: string) => unknown, 65 | ): Promise { 66 | const results = new Array(inputs.length).fill(null); 67 | const retries: InputType[] = []; 68 | const batchSize = this.clients.length; 69 | const totalBatches = Math.ceil(inputs.length / batchSize); 70 | onUpdate?.( 71 | `[SuiMultiClient] Executing ${inputs.length} operations in batches of ${batchSize}`, 72 | ); 73 | 74 | for ( 75 | let start = 0, batchNum = 1; 76 | start < inputs.length; 77 | start += batchSize, batchNum++ 78 | ) { 79 | onUpdate?.(`[SuiMultiClient] Processing batch ${batchNum} of ${totalBatches}`); 80 | 81 | // Execute all operations in the current batch 82 | const batch = inputs.slice(start, start + batchSize); 83 | const timeStart = Date.now(); 84 | const batchResults = await Promise.allSettled( 85 | batch.map((input) => { 86 | const client = this.getNextClient(); 87 | return operation(client, input); 88 | }), 89 | ); 90 | const timeTaken = Date.now() - timeStart; 91 | 92 | // Process results and keep track of failed operations for retries 93 | batchResults.forEach((result, index) => { 94 | if (result.status === "fulfilled") { 95 | results[start + index] = result.value; 96 | } else { 97 | onUpdate?.( 98 | `[SuiMultiClient] ERROR. status: ${result.status}, reason: ${result.reason}`, 99 | ); 100 | retries.push(batch[index]); // TODO: ignore failing RPC endpoints moving forward 101 | } 102 | }); 103 | 104 | // Respect rate limit delay 105 | if (timeTaken < this.rateLimitDelay) { 106 | await sleep(this.rateLimitDelay - timeTaken); 107 | } 108 | } 109 | 110 | // Retry failed operations by calling executeInBatches recursively 111 | if (retries.length > 0) { 112 | const retryResults = await this.executeInBatches(retries, operation); 113 | for (let i = 0, retryIndex = 0; i < results.length; i++) { 114 | if (results[i] === null) { 115 | results[i] = retryResults[retryIndex++]; 116 | } 117 | } 118 | } 119 | 120 | // Safe to cast as all nulls have been replaced with OutputType 121 | return results as OutputType[]; 122 | } 123 | 124 | /** 125 | * Test the latency of various Sui RPC endpoints. 126 | */ 127 | public async testEndpoints( 128 | operation: (client: SuiClientWithEndpoint) => Promise, 129 | ): Promise { 130 | console.log(`testing ${this.clients.length} endpoints`); 131 | console.time("total time"); 132 | for (const client of this.clients) { 133 | console.time(`time: ${client.endpoint}`); 134 | await operation(client); 135 | console.timeEnd(`time: ${client.endpoint}`); 136 | } 137 | console.log(""); 138 | console.timeEnd("total time"); 139 | } 140 | } 141 | 142 | /** 143 | * A `SuiClient` object that exposes the URL of its RPC endpoint. 144 | */ 145 | export type SuiClientWithEndpoint = SuiClient & { 146 | endpoint: string; 147 | }; 148 | -------------------------------------------------------------------------------- /src/core/src/errors.ts: -------------------------------------------------------------------------------- 1 | export type MoveAbort = { 2 | packageId: string; 3 | module: string; 4 | function: string; 5 | instruction: number; 6 | code: number; 7 | command: number; 8 | }; 9 | 10 | export type ErrorInfo = { 11 | /** Error constant name. */ 12 | symbol: string; 13 | /** Custom error message. */ 14 | msg?: string; 15 | }; 16 | 17 | export type ErrorsByPackage = Record>; 18 | 19 | /** 20 | * Attempts to convert any kind of value into a readable string. 21 | */ 22 | export function anyToStr(val: unknown): string | null { 23 | if (val === null || val === undefined) { 24 | return null; 25 | } 26 | const str = 27 | val instanceof Error 28 | ? val.message 29 | : typeof val === "string" 30 | ? val 31 | : (() => { 32 | try { 33 | return JSON.stringify(val); 34 | } catch { 35 | return String(val); 36 | } 37 | })(); 38 | return str.trim() || null; 39 | } 40 | 41 | /** 42 | * Parse a Move abort string into its different parts. 43 | * 44 | * Based on `sui/crates/sui/src/clever_error_rendering.rs`. 45 | * 46 | * Example error string: 47 | * `MoveAbort(MoveLocation { module: ModuleId { address: 0x123, name: Identifier("the_module") }, function: 1, instruction: 29, function_name: Some("the_function") }, 5008) in command 2` 48 | */ 49 | export function parseMoveAbort(error: string): MoveAbort | null { 50 | const match = 51 | /MoveAbort.*address:\s*(.*?),.* name:.*Identifier\((.*?)\).*instruction:\s+(\d+),.*function_name:.*Some\((.*?)\).*},\s*(\d+).*in command\s*(\d+)/.exec( 52 | error, 53 | ); 54 | if (!match) { 55 | return null; 56 | } 57 | const cleanString = (s: string) => s.replace(/\\/g, "").replace(/"/g, ""); 58 | return { 59 | packageId: `0x${match[1]!}`, 60 | module: cleanString(match[2]!), 61 | instruction: parseInt(match[3]!, 10), 62 | function: cleanString(match[4]!), 63 | code: parseInt(match[5]!, 10), 64 | command: parseInt(match[6]!, 10), 65 | }; 66 | } 67 | 68 | /** 69 | * Parse transaction errors and convert them into user-friendly messages. 70 | */ 71 | export class TxErrorParser { 72 | public readonly errsByPkg: ErrorsByPackage; 73 | 74 | constructor(errorsByPackage: ErrorsByPackage) { 75 | this.errsByPkg = errorsByPackage; 76 | } 77 | 78 | /** 79 | * Convert a transaction error into a user-friendly message. 80 | * @param err The error object/string to parse 81 | * @param defaultMsg Default message if error can't be parsed or is not a known error 82 | * @param customMsgs Optional map of error symbols to custom messages 83 | * @returns User-friendly error message or null if user rejected 84 | */ 85 | public errToStr( 86 | err: unknown, 87 | defaultMsg: string, 88 | customMsgs?: Record, 89 | ): string | null { 90 | const str = anyToStr(err); 91 | if (!str) { 92 | return defaultMsg; 93 | } 94 | 95 | // Handle common cases 96 | if (str.includes("User rejected")) { 97 | return null; 98 | } 99 | if (str.includes("InsufficientCoinBalance")) { 100 | return "You don't have enough balance"; 101 | } 102 | 103 | const parsed = parseMoveAbort(str); 104 | if (!parsed) { 105 | return defaultMsg; 106 | } 107 | 108 | // Look up error info for the specific package 109 | const pkgErrs = this.errsByPkg[parsed.packageId]; 110 | if (!pkgErrs || !(parsed.code in pkgErrs)) { 111 | return defaultMsg; 112 | } 113 | 114 | const info = pkgErrs[parsed.code]!; 115 | 116 | // Check custom error messages passed to this method 117 | if (customMsgs && info.symbol in customMsgs) { 118 | return customMsgs[info.symbol]!; 119 | } 120 | 121 | // Check custom error messages passed to constructor 122 | return info.msg || info.symbol; 123 | } 124 | } 125 | 126 | // === legacy === 127 | 128 | export type ErrorInfos = Record; 129 | 130 | /** 131 | * Parse transaction errors and convert them into user-friendly messages. 132 | * 133 | * @param packageId The package ID of the transaction. 134 | * @param errCodes A map of numeric error codes to string error symbols (constant names). 135 | * @deprecated Use `TxErrorParser` instead. 136 | */ 137 | export class TxErrorParserDeprecated { 138 | // TODO: remove 139 | constructor( 140 | public readonly packageId: string, 141 | public readonly errInfos: ErrorInfos, 142 | ) {} 143 | 144 | /** 145 | * Convert a transaction error into a user-friendly message. 146 | * @param err The error object/string to parse 147 | * @param defaultMsg Default message if error can't be parsed or is not a known error 148 | * @param customMsgs Optional map of error symbols to custom messages 149 | * @returns User-friendly error message or null if user rejected 150 | */ 151 | public errToStr( 152 | err: unknown, 153 | defaultMsg: string, 154 | customMsgs?: Record, 155 | ): string | null { 156 | const str = anyToStr(err); 157 | if (!str) { 158 | return defaultMsg; 159 | } 160 | 161 | // Handle common cases 162 | if (str.includes("User rejected")) { 163 | return null; 164 | } 165 | if (str.includes("InsufficientCoinBalance")) { 166 | return "You don't have enough balance"; 167 | } 168 | 169 | const parsed = parseMoveAbort(str); 170 | if ( 171 | !parsed || 172 | parsed.packageId !== this.packageId || 173 | !(parsed.code in this.errInfos) 174 | ) { 175 | return str; 176 | } 177 | const info = this.errInfos[parsed.code]; 178 | 179 | // Check custom error messages passed to this method 180 | if (customMsgs && info.symbol in customMsgs) { 181 | return customMsgs[info.symbol]; 182 | } 183 | 184 | // Check custom error messages passed to constructor 185 | return info.msg || info.symbol; 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/core/src/__tests__/shortenAddress.test.ts: -------------------------------------------------------------------------------- 1 | import { shortenAddress } from "../addresses"; 2 | 3 | describe("shortenAddress", () => { 4 | // === Input validation === 5 | it("should return empty string for invalid inputs", () => { 6 | expect(shortenAddress(null)).toBe(""); 7 | expect(shortenAddress(undefined)).toBe(""); 8 | expect(shortenAddress("")).toBe(""); 9 | }); 10 | 11 | // === Basic functionality === 12 | it("should shorten standard Sui addresses with default parameters", () => { 13 | const address = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; 14 | expect(shortenAddress(address)).toBe("0x1234…cdef"); 15 | }); 16 | 17 | it("should shorten normalized addresses", () => { 18 | const address = "0x0000000000000000000000000000000000000000000000000000000000000002"; 19 | expect(shortenAddress(address)).toBe("0x0000…0002"); 20 | }); 21 | 22 | it("should shorten short addresses with leading zeros removed", () => { 23 | const address = "0x2"; 24 | expect(shortenAddress(address)).toBe("0x2"); // too short to abbreviate (1 char < 4+4) 25 | 26 | const address2 = "0x123456789"; 27 | expect(shortenAddress(address2)).toBe("0x1234…6789"); 28 | }); 29 | 30 | it("should not shorten addresses that are too short", () => { 31 | expect(shortenAddress("0x1")).toBe("0x1"); 32 | expect(shortenAddress("0x1234")).toBe("0x1234"); 33 | expect(shortenAddress("0x12345678")).toBe("0x12345678"); // exactly 8 chars, still not shortened 34 | expect(shortenAddress("0x123456789")).toBe("0x1234…6789"); // 9 chars, gets shortened 35 | }); 36 | 37 | // === Custom parameters === 38 | it("should respect custom start and end parameters", () => { 39 | const address = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; 40 | expect(shortenAddress(address, 6, 6)).toBe("0x123456…abcdef"); 41 | expect(shortenAddress(address, 2, 2)).toBe("0x12…ef"); 42 | }); 43 | 44 | it("should use custom separator and prefix", () => { 45 | const address = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; 46 | expect(shortenAddress(address, 4, 4, "...")).toBe("0x1234...cdef"); 47 | expect(shortenAddress(address, 4, 4, "…", "0X")).toBe("0X1234…cdef"); 48 | expect(shortenAddress(address, 4, 4, "…", "")).toBe("1234…cdef"); 49 | }); 50 | 51 | // === Case handling === 52 | it("should handle different case hex addresses", () => { 53 | expect( 54 | shortenAddress( 55 | "0X1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF", 56 | ), 57 | ).toBe("0x1234…CDEF"); 58 | expect( 59 | shortenAddress( 60 | "0x1234567890AbCdEf1234567890aBcDeF1234567890AbCdEf1234567890aBcDeF", 61 | ), 62 | ).toBe("0x1234…cDeF"); 63 | }); 64 | 65 | // === Multiple addresses in text === 66 | it("should handle text with multiple addresses", () => { 67 | const text = 68 | "Transfer from 0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef to 0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"; 69 | const expected = "Transfer from 0x1234…cdef to 0xabcd…7890"; 70 | expect(shortenAddress(text)).toBe(expected); 71 | }); 72 | 73 | it("should handle mixed content with addresses", () => { 74 | const text = 75 | "Check address 0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef and visit https://example.com"; 76 | const expected = "Check address 0x1234…cdef and visit https://example.com"; 77 | expect(shortenAddress(text)).toBe(expected); 78 | }); 79 | 80 | // === Regex validation === 81 | it("should respect the 64 character limit with word boundaries", () => { 82 | // Valid: exactly 64 hex chars 83 | const validLongAddress = `0x${"1".repeat(64)}`; 84 | expect(shortenAddress(validLongAddress)).toBe("0x1111…1111"); 85 | 86 | // Invalid: 65 hex chars (exceeds limit) 87 | const invalidLongAddress = `0x${"1".repeat(65)}`; 88 | expect(shortenAddress(invalidLongAddress)).toBe(invalidLongAddress); // unchanged 89 | }); 90 | 91 | it("should not match invalid hex characters", () => { 92 | const invalidAddress = "0x123g567890abcdef"; // 'g' is not valid hex 93 | expect(shortenAddress(invalidAddress)).toBe(invalidAddress); 94 | 95 | const textWithInvalid = "Valid: 0x123456789 Invalid: 0x123g567890abcdef"; 96 | expect(shortenAddress(textWithInvalid)).toBe( 97 | "Valid: 0x1234…6789 Invalid: 0x123g567890abcdef", 98 | ); 99 | }); 100 | 101 | // === Edge cases === 102 | it("should handle edge cases with custom parameters", () => { 103 | const address = "0x123456789"; 104 | 105 | // When start + end > available chars, should not shorten 106 | expect(shortenAddress(address, 10, 10)).toBe(address); 107 | expect(shortenAddress(address, 3, 3)).toBe("0x123…789"); 108 | }); 109 | 110 | it("should handle minimum valid addresses", () => { 111 | expect(shortenAddress("0x1")).toBe("0x1"); 112 | expect(shortenAddress("0xa")).toBe("0xa"); 113 | expect(shortenAddress("0xF")).toBe("0xF"); 114 | }); 115 | 116 | it("should handle addresses with different case prefixes", () => { 117 | expect( 118 | shortenAddress( 119 | "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", 120 | ), 121 | ).toBe("0x1234…cdef"); 122 | expect( 123 | shortenAddress( 124 | "0X1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF", 125 | ), 126 | ).toBe("0x1234…CDEF"); 127 | }); 128 | 129 | it("should handle addresses at different positions in text", () => { 130 | const address = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; 131 | expect(shortenAddress(`Start ${address}`)).toBe("Start 0x1234…cdef"); 132 | expect(shortenAddress(`${address} end`)).toBe("0x1234…cdef end"); 133 | expect(shortenAddress(`Start ${address} end`)).toBe("Start 0x1234…cdef end"); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /src/core/src/__tests__/formatBalance.test.ts: -------------------------------------------------------------------------------- 1 | import { formatBalance } from "../balances"; 2 | 3 | describe("formatBalance", () => { 4 | describe("standard format", () => { 5 | it("should format numbers < 1000 with 2 decimals", () => { 6 | expect(formatBalance(123n, 0)).toBe("123.00"); 7 | expect(formatBalance(123456n, 3)).toBe("123.45"); 8 | expect(formatBalance(999999n, 3)).toBe("999.99"); 9 | }); 10 | 11 | it("should format numbers >= 1000 without decimals", () => { 12 | expect(formatBalance(1000n, 0)).toBe("1,000"); 13 | expect(formatBalance(1234567n, 3)).toBe("1,234"); 14 | expect(formatBalance(1234567890n, 6)).toBe("1,234"); 15 | }); 16 | 17 | it("should handle numbers with more than 2 decimal places", () => { 18 | expect(formatBalance(123456n, 4)).toBe("12.34"); 19 | expect(formatBalance(1234567n, 5)).toBe("12.34"); 20 | expect(formatBalance(123456789n, 18)).toBe("0.000000000123456789"); 21 | expect(formatBalance(1234567890123456789n, 18)).toBe("1.23"); 22 | }); 23 | 24 | it("should handle zero correctly", () => { 25 | expect(formatBalance(0n, 2)).toBe("0.00"); 26 | expect(formatBalance(0n, 0)).toBe("0.00"); 27 | }); 28 | 29 | it("should handle negative numbers", () => { 30 | expect(formatBalance(-123n, 0)).toBe("-123.00"); 31 | expect(formatBalance(-1234n, 0)).toBe("-1,234"); 32 | }); 33 | 34 | it("should handle very small positive numbers", () => { 35 | expect(formatBalance(1n, 18)).toBe("0.000000000000000001"); 36 | expect(formatBalance(10n, 18)).toBe("0.00000000000000001"); 37 | expect(formatBalance(100n, 18)).toBe("0.0000000000000001"); 38 | }); 39 | 40 | it("should handle very small negative numbers", () => { 41 | expect(formatBalance(-1n, 18)).toBe("-0.000000000000000001"); 42 | expect(formatBalance(-10n, 18)).toBe("-0.00000000000000001"); 43 | expect(formatBalance(-100n, 18)).toBe("-0.0000000000000001"); 44 | }); 45 | 46 | it("should handle maximum safe integer", () => { 47 | const maxSafeInteger = BigInt(Number.MAX_SAFE_INTEGER); 48 | expect(formatBalance(maxSafeInteger, 0)).toBe("9,007,199,254,740,991"); 49 | }); 50 | 51 | it("should handle very large numbers", () => { 52 | expect(formatBalance(1234567890123456789012345678901234567890n, 0)).toBe( 53 | "1,234,567,890,123,456,789,012,345,678,901,234,567,890", 54 | ); 55 | }); 56 | }); 57 | 58 | describe("compact format", () => { 59 | it("should use standard format for numbers < 1 million", () => { 60 | expect(formatBalance(123456n, 0, "compact")).toBe("123,456"); 61 | expect(formatBalance(999999n, 0, "compact")).toBe("999,999"); 62 | }); 63 | 64 | it("should format numbers in millions", () => { 65 | expect(formatBalance(1000000n, 0, "compact")).toBe("1.00M"); 66 | expect(formatBalance(1234567n, 0, "compact")).toBe("1.23M"); 67 | expect(formatBalance(12345678n, 0, "compact")).toBe("12.34M"); 68 | expect(formatBalance(123456789n, 0, "compact")).toBe("123.45M"); 69 | }); 70 | 71 | it("should format numbers in billions", () => { 72 | expect(formatBalance(1000000000n, 0, "compact")).toBe("1.00B"); 73 | expect(formatBalance(1234567890n, 0, "compact")).toBe("1.23B"); 74 | expect(formatBalance(12345678901n, 0, "compact")).toBe("12.34B"); 75 | expect(formatBalance(123456789012n, 0, "compact")).toBe("123.45B"); 76 | }); 77 | 78 | it("should format numbers in trillions", () => { 79 | expect(formatBalance(1000000000000n, 0, "compact")).toBe("1.00T"); 80 | expect(formatBalance(1234567890123n, 0, "compact")).toBe("1.23T"); 81 | expect(formatBalance(12345678901234n, 0, "compact")).toBe("12.34T"); 82 | expect(formatBalance(123456789012345n, 0, "compact")).toBe("123.45T"); 83 | }); 84 | 85 | it("should handle decimals in compact format", () => { 86 | expect(formatBalance(1234567890n, 6, "compact")).toBe("1,234"); 87 | expect(formatBalance(1234567890123n, 9, "compact")).toBe("1,234"); 88 | }); 89 | 90 | it("should handle negative numbers in compact format", () => { 91 | expect(formatBalance(-1234567890n, 0, "compact")).toBe("-1.23B"); 92 | expect(formatBalance(-1234567890123n, 0, "compact")).toBe("-1.23T"); 93 | }); 94 | 95 | it("should handle numbers just below and above rounding thresholds", () => { 96 | expect(formatBalance(999999999n, 0, "compact")).toBe("999.99M"); 97 | expect(formatBalance(999999999999n, 0, "compact")).toBe("999.99B"); 98 | expect(formatBalance(1000000001n, 0, "compact")).toBe("1.00B"); 99 | expect(formatBalance(1000000000001n, 0, "compact")).toBe("1.00T"); 100 | }); 101 | 102 | it("should handle maximum safe integer", () => { 103 | const maxSafeInteger = BigInt(Number.MAX_SAFE_INTEGER); 104 | expect(formatBalance(maxSafeInteger, 0, "compact")).toBe("9,007T"); 105 | }); 106 | 107 | it("should handle numbers larger than maximum safe integer", () => { 108 | const largeNumber = BigInt("1234567890123456789012345678901234567890"); 109 | expect(formatBalance(largeNumber, 0, "compact")).toBe( 110 | "1,234,567,890,123,456,789,012,345,678T", 111 | ); 112 | }); 113 | 114 | it("should handle different decimal places in compact format", () => { 115 | expect(formatBalance(1234567890n, 3, "compact")).toBe("1.23M"); 116 | expect(formatBalance(1234567890n, 6, "compact")).toBe("1,234"); 117 | expect(formatBalance(1234567890n, 9, "compact")).toBe("1.23"); 118 | }); 119 | 120 | it("should handle zero in compact format", () => { 121 | expect(formatBalance(0n, 0, "compact")).toBe("0.00"); 122 | }); 123 | 124 | it("should handle very small non-zero numbers in compact format", () => { 125 | expect(formatBalance(1n, 18, "compact")).toBe("0.000000000000000001"); 126 | expect(formatBalance(10n, 18, "compact")).toBe("0.00000000000000001"); 127 | expect(formatBalance(100n, 18, "compact")).toBe("0.0000000000000001"); 128 | }); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /src/react/src/networks.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from "react"; 2 | 3 | import { useClickOutside } from "./hooks"; 4 | import { type RadioOption, RadioSelector } from "./selectors"; 5 | 6 | export type BaseNetworkName = string; 7 | 8 | /** 9 | * A radio button menu to select a Sui network and save the choice to local storage. 10 | */ 11 | export function NetworkRadioSelector(props: { 12 | selectedNetwork: NetworkName; 13 | supportedNetworks: readonly NetworkName[]; 14 | onSwitch: (newNetwork: NetworkName) => void; 15 | className?: string; 16 | }) { 17 | const options: RadioOption[] = props.supportedNetworks.map((network) => ({ 18 | value: network, 19 | label: network, 20 | })); 21 | 22 | const onSelect = (newNetwork: NetworkName) => { 23 | switchNetwork(newNetwork, props.supportedNetworks, props.onSwitch); 24 | }; 25 | 26 | return ( 27 | 33 | ); 34 | } 35 | 36 | /** 37 | * A dropdown menu to choose between mainnet/testnet/devnet/localnet. 38 | */ 39 | export function NetworkDropdownSelector(props: { 40 | currentNetwork: NetworkName; 41 | supportedNetworks: readonly NetworkName[]; 42 | onSwitch?: (newNetwork: NetworkName) => void; 43 | disabled?: boolean; 44 | className?: string; 45 | id?: string; 46 | }) { 47 | const [isOpen, setIsOpen] = useState(false); 48 | 49 | const selectorRef = useRef(null); 50 | useClickOutside(selectorRef, () => { 51 | setIsOpen(false); 52 | }); 53 | 54 | const SelectedOption: React.FC = () => { 55 | return ( 56 |
setIsOpen(true)} */> 57 | { 60 | !props.disabled && setIsOpen(true); 61 | }} 62 | > 63 | {props.currentNetwork} 64 | 65 |
66 | ); 67 | }; 68 | 69 | const NetworkOptions: React.FC = () => { 70 | const otherNetworks = props.supportedNetworks.filter( 71 | (net) => net !== props.currentNetwork, 72 | ); 73 | return ( 74 |
75 | {otherNetworks.map((net) => ( 76 | 77 | ))} 78 |
79 | ); 80 | }; 81 | 82 | const NetworkOption: React.FC<{ network: NetworkName }> = ({ network }) => { 83 | return ( 84 |
85 | { 88 | if (!props.disabled) { 89 | switchNetwork(network, props.supportedNetworks, props.onSwitch); 90 | setIsOpen(false); 91 | } 92 | }} 93 | > 94 | {network} 95 | 96 |
97 | ); 98 | }; 99 | 100 | return ( 101 |
{ 110 | setIsOpen(false); 111 | }} 112 | > 113 | 114 | {isOpen && } 115 |
116 | ); 117 | } 118 | 119 | /** 120 | * Check if the current hostname is a localhost environment. 121 | */ 122 | export function isLocalhost(): boolean { 123 | const hostname = window.location.hostname; 124 | const localNetworkPattern = 125 | /(^127\.)|(^10\.)|(^172\.1[6-9]\.)|(^172\.2[0-9]\.)|(^172\.3[0-1]\.)|(^192\.168\.)/; 126 | return hostname === "localhost" || localNetworkPattern.test(hostname); 127 | } 128 | 129 | /** 130 | * Load the network name based on URL parameters and local storage. 131 | */ 132 | export function loadNetwork( 133 | supportedNetworks: readonly NetworkName[], 134 | defaultNetwork: NetworkName, 135 | ): NetworkName { 136 | if (!isNetworkName(defaultNetwork, supportedNetworks)) { 137 | throw new Error(`Network not supported: ${defaultNetwork}`); 138 | } 139 | 140 | // Use 'network' URL parameter, if valid 141 | const params = new URLSearchParams(window.location.search); 142 | const networkFromUrl = params.get("network"); 143 | if (isNetworkName(networkFromUrl, supportedNetworks)) { 144 | params.delete("network"); 145 | const newQuery = params.toString(); 146 | const newUrl = 147 | window.location.pathname + (newQuery ? `?${newQuery}` : "") + window.location.hash; 148 | window.history.replaceState({}, document.title, newUrl); 149 | 150 | localStorage.setItem("polymedia.network", networkFromUrl); 151 | return networkFromUrl; 152 | } 153 | 154 | // Use network from local storage, if valid 155 | const networkFromLocal = localStorage.getItem("polymedia.network"); 156 | if (isNetworkName(networkFromLocal, supportedNetworks)) { 157 | return networkFromLocal; 158 | } 159 | 160 | // Use default network 161 | localStorage.setItem("polymedia.network", defaultNetwork); 162 | return defaultNetwork; 163 | } 164 | 165 | /** 166 | * Change networks, update local storage, and optionally trigger a callback. 167 | */ 168 | export function switchNetwork( 169 | newNetwork: NetworkName, 170 | supportedNetworks: readonly NetworkName[], 171 | onSwitch?: (newNetwork: NetworkName) => void, 172 | ): void { 173 | if (!isNetworkName(newNetwork, supportedNetworks)) { 174 | throw new Error(`Network not supported: ${newNetwork}`); 175 | } 176 | localStorage.setItem("polymedia.network", newNetwork); 177 | if (onSwitch) { 178 | onSwitch(newNetwork); 179 | } else { 180 | window.location.reload(); 181 | } 182 | } 183 | 184 | function isNetworkName( 185 | value: string | null, 186 | supportedNetworks: readonly NetworkName[], 187 | ): value is NetworkName { 188 | return value !== null && supportedNetworks.includes(value as NetworkName); 189 | } 190 | -------------------------------------------------------------------------------- /src/react/src/icons.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | 3 | export type IconProps = { 4 | children: React.ReactNode; 5 | } & React.SVGProps; 6 | 7 | const Icon: React.FC = ({ 8 | height = "24px", 9 | width = "24px", 10 | fill = "currentColor", 11 | children, 12 | ...props 13 | }) => ( 14 | 23 | {children} 24 | 25 | ); 26 | 27 | export const IconCart: React.FC> = (props) => ( 28 | 29 | 30 | 31 | ); 32 | 33 | export const IconClose: React.FC> = (props) => ( 34 | 35 | 43 | 51 | 52 | ); 53 | 54 | export const IconCheck: React.FC> = (props) => ( 55 | 56 | 57 | 58 | 59 | ); 60 | 61 | export const IconDetails: React.FC> = (props) => ( 62 | 63 | 64 | 65 | ); 66 | 67 | export const IconGears: React.FC> = (props) => ( 68 | 69 | 70 | 71 | ); 72 | 73 | export const IconHistory: React.FC> = (props) => ( 74 | 75 | 76 | 77 | ); 78 | 79 | export const IconInfo: React.FC> = (props) => ( 80 | 81 | 82 | 83 | ); 84 | 85 | export const IconNew: React.FC> = (props) => ( 86 | 87 | 88 | 89 | ); 90 | 91 | export const IconItems: React.FC> = (props) => ( 92 | 93 | {/* "view cozy" */} 94 | 95 | 96 | ); 97 | 98 | export const IconVerified: React.FC> = (props) => ( 99 | 100 | 101 | 102 | 103 | ); 104 | -------------------------------------------------------------------------------- /src/core/src/balances.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Convert a bigint to a string, scaled down to the specified decimals. 3 | */ 4 | export function balanceToString(value: bigint, coinDecimals: number): string { 5 | if (value === 0n) { 6 | return "0"; 7 | } 8 | 9 | const isNegative = value < 0n; 10 | const absoluteValue = isNegative ? -value : value; 11 | 12 | const valStr = absoluteValue.toString(); 13 | 14 | if (coinDecimals === 0) { 15 | // if no decimals, return the value as a string 16 | return (isNegative ? "-" : "") + valStr; 17 | } 18 | 19 | // pad the string to ensure it has enough digits 20 | const paddedValStr = valStr.padStart(coinDecimals + 1, "0"); 21 | const integerPart = paddedValStr.slice(0, -coinDecimals); 22 | const fractionalPart = paddedValStr.slice(-coinDecimals); 23 | 24 | // combine integer and fractional parts 25 | let result = `${integerPart}.${fractionalPart}`; 26 | 27 | // remove unnecessary trailing zeros after the decimal point 28 | result = result.replace(/\.?0+$/, ""); 29 | 30 | return isNegative ? `-${result}` : result; 31 | } 32 | 33 | /** 34 | * Convert a string to a bigint, scaled up to the specified decimals. 35 | */ 36 | export function stringToBalance(value: string, coinDecimals: number): bigint { 37 | value = value.trim(); 38 | 39 | if (["", ".", "-"].includes(value)) { 40 | return 0n; 41 | } 42 | 43 | // validate the input 44 | if ("-." === value || !/^-?\d*\.?\d*$/.test(value)) { 45 | throw new Error("Invalid input"); 46 | } 47 | 48 | const [integerPart, rawDecimalPart = ""] = value.split("."); 49 | 50 | // truncate the decimal part if it has more places than the specified decimals 51 | const decimalPart = rawDecimalPart.slice(0, coinDecimals); 52 | 53 | // pad the decimal part with zeros if it's shorter than the specified decimals 54 | const fullNumber = integerPart + decimalPart.padEnd(coinDecimals, "0"); 55 | 56 | return BigInt(fullNumber); 57 | } 58 | 59 | /** 60 | * Format a number into a readable string. 61 | * 62 | * - 'standard' format: 63 | * - If the number is < 1000, show 2 decimals (e.g. '123.45') 64 | * - If the number is >= 1000, don't show any decimals (e.g. '1,234') 65 | * 66 | * - 'compact' format: 67 | * - If the number is < 1 million, use 'standard' format 68 | * - If the number is >= 1 million, use word notation (e.g. '540.23M', '20.05B') 69 | */ 70 | export function formatNumber( 71 | num: number, 72 | format: "standard" | "compact" = "standard", 73 | ): string { 74 | if (format === "standard") { 75 | return formatNumberStandard(num); 76 | } else { 77 | return formatNumberCompact(num); 78 | } 79 | } 80 | 81 | function formatNumberStandard(num: number): string { 82 | if (num < 1) { 83 | return String(num); 84 | } else if (num < 1000) { 85 | return num.toLocaleString("en-US", { 86 | minimumFractionDigits: 2, 87 | maximumFractionDigits: 2, 88 | }); 89 | } else { 90 | return num.toLocaleString("en-US", { maximumFractionDigits: 0 }); 91 | } 92 | } 93 | 94 | function formatNumberCompact(num: number): string { 95 | if (num < 1_000_000) { 96 | return formatNumberStandard(num); 97 | } else if (num < 1_000_000_000) { 98 | return `${formatNumberStandard(num / 1_000_000)}M`; 99 | } else if (num < 1_000_000_000_000) { 100 | return `${formatNumberStandard(num / 1_000_000_000)}B`; 101 | } else { 102 | return `${formatNumberStandard(num / 1_000_000_000_000)}T`; 103 | } 104 | } 105 | 106 | /** 107 | * Format a bigint into a readable string, scaled down to the specified decimals. 108 | * 109 | * - 'standard' format: 110 | * - If the number is < 1000, show 2 decimals (e.g. '123.45') 111 | * - If the number is >= 1000, don't show any decimals (e.g. '1,234') 112 | * 113 | * - 'compact' format: 114 | * - If the number is < 1 million, use 'standard' format 115 | * - If the number is >= 1 million, use word notation (e.g. '540.23M', '20.05B') 116 | */ 117 | export function formatBalance( 118 | big: bigint, 119 | decimals: number, 120 | format: "standard" | "compact" = "standard", 121 | ): string { 122 | const isNegative = big < 0n; 123 | const absoluteBig = isNegative ? -big : big; 124 | const stringValue = balanceToString(absoluteBig, decimals); 125 | 126 | // If the number is effectively zero, return "0.00" 127 | if (stringValue === "0") { 128 | return "0.00"; 129 | } 130 | 131 | const [integerPart, fractionalPart = ""] = stringValue.split("."); 132 | 133 | const result = 134 | format === "standard" 135 | ? formatBigIntStandard(integerPart, fractionalPart) 136 | : formatBigIntCompact(integerPart, fractionalPart); 137 | 138 | return isNegative ? `-${result}` : result; 139 | } 140 | 141 | function formatBigIntStandard(integerPart: string, fractionalPart: string): string { 142 | const bigIntValue = BigInt(integerPart); 143 | if (bigIntValue === 0n && fractionalPart !== "") { 144 | // For very small numbers (0.xxx), show all significant digits 145 | // Remove trailing zeros from fractional part 146 | const significantDecimals = fractionalPart.replace(/0+$/, ""); 147 | return `0.${significantDecimals}`; 148 | } 149 | 150 | if (bigIntValue < 1000n) { 151 | const formattedFraction = fractionalPart.slice(0, 2).padEnd(2, "0"); 152 | return `${integerPart}.${formattedFraction}`; 153 | } else { 154 | return integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ","); 155 | } 156 | } 157 | 158 | function formatBigIntCompact(integerPart: string, fractionalPart: string): string { 159 | const bigIntValue = BigInt(integerPart); 160 | if (bigIntValue < 1_000_000n) { 161 | return formatBigIntStandard(integerPart, fractionalPart); 162 | } else if (bigIntValue < 1_000_000_000n) { 163 | return formatCompactPart(integerPart, 6, "M"); 164 | } else if (bigIntValue < 1_000_000_000_000n) { 165 | return formatCompactPart(integerPart, 9, "B"); 166 | } else { 167 | return formatCompactPart(integerPart, 12, "T"); 168 | } 169 | } 170 | 171 | function formatCompactPart(integerPart: string, digits: number, suffix: string): string { 172 | const wholePart = integerPart.slice(0, -digits) || "0"; 173 | const decimalPart = integerPart.slice(-digits).padStart(2, "0").slice(0, 2); 174 | 175 | if (wholePart.length <= 3) { 176 | return `${wholePart}.${decimalPart}${suffix}`; 177 | } else { 178 | return `${addThousandsSeparators(wholePart)}${suffix}`; 179 | } 180 | } 181 | 182 | function addThousandsSeparators(numStr: string): string { 183 | return numStr.replace(/\B(?=(\d{3})+(?!\d))/g, ","); 184 | } 185 | -------------------------------------------------------------------------------- /src/core/src/SuiClientBase.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | DevInspectTransactionBlockParams, 3 | QueryTransactionBlocksParams, 4 | SuiClient, 5 | SuiObjectResponse, 6 | SuiTransactionBlockResponse, 7 | SuiTransactionBlockResponseOptions, 8 | } from "@mysten/sui/client"; 9 | import type { SignatureWithBytes } from "@mysten/sui/cryptography"; 10 | import type { Transaction } from "@mysten/sui/transactions"; 11 | 12 | import { chunkArray } from "./misc.js"; 13 | import { objResToId } from "./objects.js"; 14 | import type { SignTx, WaitForTxOptions } from "./txs.js"; 15 | 16 | /** 17 | * The maximum number of objects that can be fetched from the RPC in a single request. 18 | */ 19 | const MAX_OBJECTS_PER_REQUEST = 50; 20 | 21 | /** 22 | * Abstract class for building Sui SDK clients. 23 | */ 24 | export abstract class SuiClientBase { 25 | public readonly suiClient: SuiClient; 26 | public readonly signTx: SignTx; 27 | public readonly txRespOptions: SuiTransactionBlockResponseOptions; 28 | public readonly waitForTxOptions: WaitForTxOptions | false; 29 | 30 | /** 31 | * @param suiClient The client used to communicate with Sui. 32 | * @param signTx A function that can sign a `Transaction`. 33 | * @param txRespOptions Which fields to include in transaction responses. 34 | * @param waitForTxOptions Options for `SuiClient.waitForTransaction()`. 35 | */ 36 | constructor({ 37 | suiClient, 38 | signTx, 39 | txRespOptions = { showEffects: true, showObjectChanges: true }, 40 | waitForTxOptions = { timeout: 45_000, pollInterval: 250 }, 41 | }: { 42 | suiClient: SuiClient; 43 | signTx: SignTx; 44 | txRespOptions?: SuiTransactionBlockResponseOptions; 45 | waitForTxOptions?: WaitForTxOptions | false; 46 | }) { 47 | this.suiClient = suiClient; 48 | this.signTx = signTx; 49 | this.txRespOptions = txRespOptions; 50 | this.waitForTxOptions = waitForTxOptions; 51 | } 52 | 53 | // === data fetching === 54 | 55 | /** 56 | * Fetch and parse objects from the RPC and cache them. 57 | * @param objectIds The IDs of the objects to fetch. 58 | * @param cache The cache to use (if any). Keys are object IDs and values are the parsed objects. 59 | * @param fetchFn A function that fetches objects from the Sui network. 60 | * @param parseFn A function that parses a `SuiObjectResponse` into an object. 61 | * @returns The parsed objects. 62 | */ 63 | public async fetchAndParseObjs({ 64 | ids, 65 | fetchFn, 66 | parseFn, 67 | cache, 68 | }: { 69 | ids: string[]; 70 | fetchFn: (ids: string[]) => Promise; 71 | parseFn: (resp: SuiObjectResponse) => T | null; 72 | cache?: Map; 73 | }): Promise { 74 | const results: T[] = []; 75 | const uncachedIds: string[] = []; 76 | 77 | for (const id of ids) { 78 | const cachedObject = cache ? cache.get(id) : undefined; 79 | if (cachedObject) { 80 | results.push(cachedObject); 81 | } else { 82 | uncachedIds.push(id); 83 | } 84 | } 85 | 86 | if (uncachedIds.length === 0) { 87 | return results; 88 | } 89 | 90 | const idChunks = chunkArray(uncachedIds, MAX_OBJECTS_PER_REQUEST); 91 | const allResults = await Promise.all(idChunks.map(fetchFn)); 92 | 93 | for (const resps of allResults) { 94 | for (const resp of resps) { 95 | const parsedObject = parseFn(resp); 96 | if (parsedObject) { 97 | results.push(parsedObject); 98 | if (cache) { 99 | cache.set(objResToId(resp), parsedObject); 100 | } 101 | } 102 | } 103 | } 104 | 105 | return results; 106 | } 107 | 108 | /** 109 | * Fetch and parse transactions from the RPC. 110 | */ 111 | public async fetchAndParseTxs({ 112 | parseFn, 113 | query, 114 | }: { 115 | parseFn: (resp: SuiTransactionBlockResponse) => T | null; 116 | query: QueryTransactionBlocksParams; 117 | }) { 118 | const pagTxRes = await this.suiClient.queryTransactionBlocks(query); 119 | 120 | const results = { 121 | hasNextPage: pagTxRes.hasNextPage, 122 | nextCursor: pagTxRes.nextCursor, 123 | data: pagTxRes.data 124 | .map((resp) => parseFn(resp)) 125 | .filter((result) => result !== null), 126 | }; 127 | 128 | return results; 129 | } 130 | 131 | // === transactions === 132 | 133 | public async executeTx({ 134 | signedTx, 135 | waitForTxOptions = this.waitForTxOptions, 136 | txRespOptions = this.txRespOptions, 137 | dryRun = false, 138 | sender, 139 | }: { 140 | signedTx: SignatureWithBytes; 141 | waitForTxOptions?: WaitForTxOptions | false; 142 | txRespOptions?: SuiTransactionBlockResponseOptions; 143 | dryRun?: boolean; 144 | sender?: string; 145 | }): Promise { 146 | if (dryRun) { 147 | return this.dryRunTx({ tx: signedTx.bytes, sender }); 148 | } 149 | 150 | const resp = await this.suiClient.executeTransactionBlock({ 151 | transactionBlock: signedTx.bytes, 152 | signature: signedTx.signature, 153 | options: txRespOptions, 154 | }); 155 | 156 | if (resp.effects && resp.effects.status.status !== "success") { 157 | throw new Error(`transaction failed: ${JSON.stringify(resp, null, 2)}`); 158 | } 159 | 160 | if (!waitForTxOptions) { 161 | return resp; 162 | } 163 | 164 | return await this.suiClient.waitForTransaction({ 165 | digest: resp.digest, 166 | options: txRespOptions, 167 | ...waitForTxOptions, 168 | }); 169 | } 170 | 171 | public async signAndExecuteTx({ 172 | tx, 173 | waitForTxOptions = this.waitForTxOptions, 174 | txRespOptions = this.txRespOptions, 175 | dryRun = false, 176 | sender, 177 | }: { 178 | tx: Transaction; 179 | waitForTxOptions?: WaitForTxOptions | false; 180 | txRespOptions?: SuiTransactionBlockResponseOptions; 181 | dryRun?: boolean; 182 | sender?: string; 183 | }): Promise { 184 | if (dryRun) { 185 | return await this.dryRunTx({ tx, sender }); 186 | } 187 | 188 | const signedTx = await this.signTx(tx); 189 | const resp = await this.executeTx({ signedTx, waitForTxOptions, txRespOptions }); 190 | 191 | return resp; 192 | } 193 | 194 | public async dryRunTx({ 195 | tx, 196 | sender = "0x7777777777777777777777777777777777777777777777777777777777777777", 197 | }: { 198 | tx: DevInspectTransactionBlockParams["transactionBlock"]; 199 | sender?: string | undefined; 200 | }): Promise { 201 | const resp = await this.suiClient.devInspectTransactionBlock({ 202 | sender, 203 | transactionBlock: tx, 204 | }); 205 | if (resp.effects && resp.effects.status.status !== "success") { 206 | throw new Error(`devInspect failed: ${JSON.stringify(resp, null, 2)}`); 207 | } 208 | return { digest: "", ...resp }; 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/core/src/clients.ts: -------------------------------------------------------------------------------- 1 | import type { BcsType } from "@mysten/sui/bcs"; 2 | import type { 3 | DynamicFieldInfo, 4 | SuiClient, 5 | SuiExecutionResult, 6 | SuiObjectRef, 7 | } from "@mysten/sui/client"; 8 | import type { Transaction, TransactionResult } from "@mysten/sui/transactions"; 9 | 10 | import { removeAddressLeadingZeros } from "./addresses.js"; 11 | import { RPC_QUERY_MAX_RESULTS } from "./constants.js"; 12 | import { sleep } from "./misc.js"; 13 | 14 | /** 15 | * Call `SuiClient.devInspectTransactionBlock()` and return the execution results. 16 | */ 17 | export async function devInspectAndGetExecutionResults( 18 | suiClient: SuiClient, 19 | tx: Transaction | Uint8Array | string, 20 | sender = "0x7777777777777777777777777777777777777777777777777777777777777777", 21 | ): Promise { 22 | const resp = await suiClient.devInspectTransactionBlock({ 23 | sender: sender, 24 | transactionBlock: tx, 25 | }); 26 | if (resp.error) { 27 | throw new Error(`Response error: ${JSON.stringify(resp, null, 2)}`); 28 | } 29 | if (!resp.results?.length) { 30 | throw new Error(`Response has no results: ${JSON.stringify(resp, null, 2)}`); 31 | } 32 | return resp.results; 33 | } 34 | 35 | /** 36 | * Call `SuiClient.devInspectTransactionBlock()` and return the deserialized return values. 37 | * @returns An array with the deserialized return values of each transaction in the TransactionBlock. 38 | * 39 | * @example 40 | ``` 41 | const blockReturnValues = await devInspectAndGetReturnValues(suiClient, tx, [ 42 | [ 43 | bcs.vector(bcs.Address), 44 | bcs.Bool, 45 | bcs.U64, 46 | ], 47 | ]); 48 | ``` 49 | */ 50 | // biome-ignore lint/suspicious/noExplicitAny: iykyk 51 | export async function devInspectAndGetReturnValues( 52 | suiClient: SuiClient, 53 | tx: Transaction | Uint8Array | string, 54 | blockParsers: BcsType[][], 55 | sender = "0x7777777777777777777777777777777777777777777777777777777777777777", 56 | ): Promise { 57 | const blockResults = await devInspectAndGetExecutionResults(suiClient, tx, sender); 58 | 59 | if (blockParsers.length !== blockResults.length) { 60 | throw new Error( 61 | `You provided parsers for ${blockParsers.length} txs but the txblock contains ${blockResults.length} txs`, 62 | ); 63 | } 64 | 65 | // The values returned from each of the transactions in the TransactionBlock 66 | const blockReturns: T[] = []; 67 | 68 | for (const [txIdx, txResult] of blockResults.entries()) { 69 | if (!txResult.returnValues?.length) { 70 | throw new Error( 71 | `Transaction ${txIdx} didn't return any values: ${JSON.stringify(txResult, null, 2)}`, 72 | ); 73 | } 74 | 75 | const txParsers = blockParsers[txIdx]; 76 | 77 | if (txParsers.length !== txResult.returnValues.length) { 78 | throw new Error( 79 | `You provided parsers for ${txParsers.length} return values but tx ${txIdx} contains ${txResult.returnValues.length} return values`, 80 | ); 81 | } 82 | 83 | // The values returned from the transaction (a function can return multiple values) 84 | const txReturns: T[number][] = []; 85 | 86 | for (const [valueIdx, value] of txResult.returnValues.entries()) { 87 | const parser = txParsers[valueIdx]; 88 | const valueData = Uint8Array.from(value[0]); 89 | const valueDeserialized = parser.parse(valueData); 90 | txReturns.push(valueDeserialized); 91 | } 92 | 93 | blockReturns.push(txReturns as T[number]); 94 | } 95 | 96 | return blockReturns; 97 | } 98 | 99 | /** 100 | * Get dynamic object fields owned by an object. 101 | * If limit is not specified, fetch all DOFs. 102 | */ 103 | export async function fetchDynamicFields({ 104 | client, 105 | parentId, 106 | limit, 107 | cursor, 108 | sleepMsBetweenReqs, 109 | onUpdate, 110 | }: { 111 | client: SuiClient; 112 | parentId: string; 113 | limit?: number; 114 | cursor?: string | null | undefined; 115 | sleepMsBetweenReqs?: number; 116 | onUpdate?: (msg: string) => unknown; 117 | }): Promise<{ 118 | data: DynamicFieldInfo[]; 119 | hasNextPage: boolean; 120 | cursor: string | null | undefined; 121 | }> { 122 | const fields: DynamicFieldInfo[] = []; 123 | let hasNextPage = true; 124 | while (hasNextPage && (!limit || fields.length < limit)) { 125 | onUpdate?.(`Fetching batch ${fields.length}${limit ? `/${limit}` : ""}`); 126 | 127 | const batchLimit = !limit 128 | ? RPC_QUERY_MAX_RESULTS 129 | : Math.min(RPC_QUERY_MAX_RESULTS, limit - fields.length); 130 | 131 | const page = await client.getDynamicFields({ 132 | parentId, 133 | cursor, 134 | limit: batchLimit, 135 | }); 136 | 137 | fields.push(...page.data); 138 | hasNextPage = page.hasNextPage; 139 | cursor = page.nextCursor; 140 | 141 | sleepMsBetweenReqs && (await sleep(sleepMsBetweenReqs)); 142 | } 143 | return { data: fields, hasNextPage, cursor }; 144 | } 145 | 146 | /** 147 | * Get a `Coin` of a given value from the owner. Handles coin merging and splitting. 148 | * Assumes that the owner has enough balance. 149 | * @deprecated Use `coinWithBalance` from `@mysten/sui` instead. 150 | */ 151 | export async function getCoinOfValue( 152 | suiClient: SuiClient, 153 | tx: Transaction, 154 | owner: string, 155 | coinType: string, 156 | coinValue: number | bigint, 157 | ): Promise { 158 | let coinOfValue: TransactionResult; 159 | coinType = removeAddressLeadingZeros(coinType); 160 | if (coinType === "0x2::sui::SUI") { 161 | coinOfValue = tx.splitCoins(tx.gas, [tx.pure.u64(coinValue)]); 162 | } else { 163 | const paginatedCoins = await suiClient.getCoins({ owner, coinType }); 164 | 165 | // Merge all coins into one 166 | const [firstCoin, ...otherCoins] = paginatedCoins.data; 167 | const firstCoinInput = tx.object(firstCoin.coinObjectId); 168 | if (otherCoins.length > 0) { 169 | tx.mergeCoins( 170 | firstCoinInput, 171 | otherCoins.map((coin) => coin.coinObjectId), 172 | ); 173 | } 174 | coinOfValue = tx.splitCoins(firstCoinInput, [tx.pure.u64(coinValue)]); 175 | } 176 | return coinOfValue; 177 | } 178 | 179 | /** 180 | * Fetch the latest version of an object and return its `SuiObjectRef`. 181 | */ 182 | export async function getSuiObjectRef( 183 | suiClient: SuiClient, 184 | objectId: string, 185 | ): Promise { 186 | const resp = await suiClient.getObject({ id: objectId }); 187 | if (resp.error || !resp.data) { 188 | throw new Error( 189 | `[getSuiObjectRef] failed to fetch objectId | error: ${JSON.stringify(resp.error)}`, 190 | ); 191 | } 192 | return { 193 | objectId: resp.data.objectId, 194 | digest: resp.data.digest, 195 | version: resp.data.version, 196 | }; 197 | } 198 | -------------------------------------------------------------------------------- /src/core/src/txs.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | SuiCallArg, 3 | SuiClient, 4 | SuiObjectRef, 5 | SuiTransactionBlockResponse, 6 | SuiTransactionBlockResponseOptions, 7 | } from "@mysten/sui/client"; 8 | import type { SignatureWithBytes, Signer } from "@mysten/sui/cryptography"; 9 | import type { 10 | Transaction, 11 | TransactionObjectInput, 12 | TransactionResult, 13 | } from "@mysten/sui/transactions"; 14 | 15 | import { isSuiObjectRef } from "./guards.js"; 16 | import { sleep } from "./misc.js"; 17 | 18 | // === misc === 19 | 20 | /** 21 | * An item in the array returned by a `Transaction.moveCall()` call. 22 | */ 23 | export type NestedResult = ReturnType extends (infer Item)[] 24 | ? Item 25 | : never; 26 | 27 | /** 28 | * Either a `TransactionObjectInput` or a `SuiObjectRef`. 29 | */ 30 | export type ObjectInput = TransactionObjectInput | SuiObjectRef; 31 | 32 | /** 33 | * Get the value of a `SuiCallArg` (transaction input). 34 | * If the argument is a pure value, return it. 35 | * If the argument is an object, return its ID. 36 | */ 37 | export function getArgVal(arg: SuiCallArg): T { 38 | if (arg.type === "pure") { 39 | return arg.value as T; 40 | } 41 | return arg.objectId as T; 42 | } 43 | 44 | /** 45 | * Transform an `ObjectInput` into an argument for `Transaction.moveCall()`. 46 | */ 47 | export function objectArg(tx: Transaction, obj: ObjectInput) { 48 | return isSuiObjectRef(obj) ? tx.objectRef(obj) : tx.object(obj); 49 | } 50 | 51 | /** 52 | * Validate a `SuiTransactionBlockResponse` of the `ProgrammableTransaction` kind 53 | * and return its `.transaction.data`. 54 | */ 55 | export function txResToData(resp: SuiTransactionBlockResponse) { 56 | if (resp.errors && resp.errors.length > 0) { 57 | throw Error(`response error: ${JSON.stringify(resp, null, 2)}`); 58 | } 59 | if (resp.transaction?.data.transaction.kind !== "ProgrammableTransaction") { 60 | throw Error( 61 | `response has no data or is not a ProgrammableTransaction: ${JSON.stringify(resp, null, 2)}`, 62 | ); 63 | } 64 | return { 65 | sender: resp.transaction.data.sender, 66 | gasData: resp.transaction.data.gasData, 67 | inputs: resp.transaction.data.transaction.inputs, 68 | txs: resp.transaction.data.transaction.transactions, 69 | }; 70 | } 71 | 72 | // === tx signing and submitting === 73 | 74 | /** 75 | * A function that can sign a `Transaction`. 76 | * 77 | * For apps that use `@mysten/dapp-kit` to sign with a Sui wallet: 78 | ``` 79 | const { mutateAsync: walletSignTx } = useSignTransaction(); 80 | const signTx: SignTx = async (tx) => { 81 | return walletSignTx({ transaction: tx }); 82 | }; 83 | ``` 84 | * For code that has direct access to the private key: 85 | ``` 86 | const secretKey = "suiprivkey1..."; 87 | const signer = pairFromSecretKey(secretKey) 88 | const signTx: SignTx = async (tx) => { 89 | tx.setSenderIfNotSet(signer.toSuiAddress()); 90 | const txBytes = await tx.build({ client: suiClient }); 91 | return signer.signTransaction(txBytes); 92 | }; 93 | ``` 94 | */ 95 | export type SignTx = (tx: Transaction) => Promise; 96 | 97 | /** 98 | * Create a `SignTx` function that uses a `Signer` to sign a `Transaction`. 99 | */ 100 | export function newSignTx(suiClient: SuiClient, signer: Signer): SignTx { 101 | return async (tx: Transaction) => { 102 | tx.setSenderIfNotSet(signer.toSuiAddress()); 103 | const txBytes = await tx.build({ client: suiClient }); 104 | return signer.signTransaction(txBytes); 105 | }; 106 | } 107 | 108 | /** 109 | * Options for `SuiClient.waitForTransaction()`. 110 | */ 111 | export type WaitForTxOptions = { 112 | pollInterval: number; 113 | timeout?: number; 114 | }; 115 | 116 | const DEFAULT_RESPONSE_OPTIONS: SuiTransactionBlockResponseOptions = { 117 | showEffects: true, 118 | showObjectChanges: true, 119 | }; 120 | 121 | const DEFAULT_WAIT_FOR_TX_OPTIONS: WaitForTxOptions = { 122 | pollInterval: 250, 123 | }; 124 | 125 | const SLEEP_MS_AFTER_FINALITY_ERROR = 1000; 126 | 127 | export type SignAndExecuteTx = ReturnType; 128 | 129 | /** 130 | * Create a function that signs and executes a `Transaction`. 131 | * TODO: add to README 132 | */ 133 | export function newSignAndExecuteTx({ 134 | suiClient, 135 | signTx, 136 | sender: _sender = undefined, 137 | txRespOptions: _txRespOptions = DEFAULT_RESPONSE_OPTIONS, 138 | waitForTxOptions: _waitForTxOptions = DEFAULT_WAIT_FOR_TX_OPTIONS, 139 | }: { 140 | suiClient: SuiClient; 141 | signTx: SignTx; 142 | sender?: string | undefined; 143 | txRespOptions?: SuiTransactionBlockResponseOptions; 144 | waitForTxOptions?: WaitForTxOptions | false; 145 | }) { 146 | return async ({ 147 | tx, 148 | sender = _sender, 149 | txRespOptions = _txRespOptions, 150 | waitForTxOptions = _waitForTxOptions, 151 | dryRun = false, 152 | }: { 153 | tx: Transaction; 154 | sender?: string; 155 | txRespOptions?: SuiTransactionBlockResponseOptions; 156 | waitForTxOptions?: WaitForTxOptions | false; 157 | dryRun?: boolean; 158 | }): Promise => { 159 | if (sender) { 160 | tx.setSenderIfNotSet(sender); 161 | } 162 | 163 | if (dryRun) { 164 | const dryRunRes = await suiClient.devInspectTransactionBlock({ 165 | sender: 166 | sender ?? "0x7777777777777777777777777777777777777777777777777777777777777777", 167 | transactionBlock: tx, 168 | }); 169 | if (dryRunRes.effects.status.status !== "success") { 170 | throw new Error(`devInspect failed: ${dryRunRes.effects.status.error}`); 171 | } 172 | return { digest: "", ...dryRunRes }; 173 | } 174 | 175 | const signedTx = await signTx(tx); 176 | 177 | let resp: SuiTransactionBlockResponse | null = null; 178 | while (!resp) { 179 | try { 180 | resp = await suiClient.executeTransactionBlock({ 181 | transactionBlock: signedTx.bytes, 182 | signature: signedTx.signature, 183 | options: txRespOptions, 184 | }); 185 | } catch (err) { 186 | // Prevent equivocation by retrying the same tx until it fails definitely. 187 | // If we were to submit a new tx, we risk locking objects until epoch end. 188 | const errStr = String(err); 189 | const errStrLower = errStr.toLowerCase(); 190 | if ( 191 | errStrLower.includes("finality") || 192 | errStrLower.includes("timeout") || 193 | errStrLower.includes("timed out") 194 | ) { 195 | await sleep(SLEEP_MS_AFTER_FINALITY_ERROR); 196 | } else { 197 | throw err; 198 | } 199 | } 200 | } 201 | 202 | if (resp.effects && resp.effects.status.status !== "success") { 203 | throw new Error(`transaction failed: ${resp.effects.status.error}`); 204 | } 205 | 206 | if (!waitForTxOptions) { 207 | return resp; 208 | } 209 | 210 | return suiClient.waitForTransaction({ 211 | digest: resp.digest, 212 | options: txRespOptions, 213 | ...waitForTxOptions, 214 | }); 215 | }; 216 | } 217 | 218 | // === sui::transfer module === 219 | 220 | /** 221 | * Build transactions for the `sui::transfer` module. 222 | */ 223 | export const TransferModule = { 224 | public_freeze_object( 225 | tx: Transaction, 226 | obj_type: string, 227 | obj: ObjectInput, 228 | ): TransactionResult { 229 | return tx.moveCall({ 230 | target: "0x2::transfer::public_freeze_object", 231 | typeArguments: [obj_type], 232 | arguments: [objectArg(tx, obj)], 233 | }); 234 | }, 235 | 236 | public_share_object( 237 | tx: Transaction, 238 | obj_type: string, 239 | obj: ObjectInput, 240 | ): TransactionResult { 241 | return tx.moveCall({ 242 | target: "0x2::transfer::public_share_object", 243 | typeArguments: [obj_type], 244 | arguments: [objectArg(tx, obj)], 245 | }); 246 | }, 247 | 248 | public_transfer( 249 | tx: Transaction, 250 | obj_type: string, 251 | obj: ObjectInput, 252 | recipient: string, 253 | ): TransactionResult { 254 | return tx.moveCall({ 255 | target: "0x2::transfer::public_transfer", 256 | typeArguments: [obj_type], 257 | arguments: [objectArg(tx, obj), tx.pure.address(recipient)], 258 | }); 259 | }, 260 | }; 261 | -------------------------------------------------------------------------------- /src/react/src/hooks.ts: -------------------------------------------------------------------------------- 1 | import type { PaginatedResponse } from "@polymedia/suitcase-core"; 2 | import { type RefObject, useEffect, useState } from "react"; 3 | 4 | export type UseFetchResult = ReturnType>; 5 | export type UseFetchAndLoadMoreResult = ReturnType< 6 | typeof useFetchAndLoadMore 7 | >; 8 | export type UseFetchAndPaginateResult = ReturnType< 9 | typeof useFetchAndPaginate 10 | >; 11 | 12 | /** 13 | * A hook that detects when a click or touch event occurs outside a DOM element. 14 | * 15 | * @param domElementRef A React ref object pointing to the target DOM element. 16 | * @param onClickOutside Function to call when a click or touch is detected outside the target element. 17 | */ 18 | export function useClickOutside( 19 | domElementRef: RefObject, 20 | onClickOutside: () => void, 21 | ): void { 22 | const handleClickOutside = (event: MouseEvent | TouchEvent) => { 23 | if ( 24 | domElementRef.current && 25 | event.target instanceof Node && 26 | !domElementRef.current.contains(event.target) 27 | ) { 28 | onClickOutside(); 29 | } 30 | }; 31 | 32 | useEffect(() => { 33 | document.addEventListener("mousedown", handleClickOutside); 34 | document.addEventListener("touchstart", handleClickOutside); 35 | return () => { 36 | document.removeEventListener("mousedown", handleClickOutside); 37 | document.removeEventListener("touchstart", handleClickOutside); 38 | }; 39 | }); 40 | } 41 | 42 | /** 43 | * A hook to handle data fetching. 44 | * 45 | * @template T The type of data returned by the fetch function 46 | * @param fetchFunction An async function that returns a `Promise` 47 | * @param dependencies An array of dependencies that trigger a re-fetch when changed 48 | * @returns An object containing: 49 | * - data: The fetched data 50 | * - err: Any error that occurred 51 | * - isLoading: Whether data is currently being fetched 52 | */ 53 | export function useFetch( 54 | fetchFunction: () => Promise, 55 | dependencies: unknown[] = [], 56 | ) { 57 | const [data, setData] = useState(undefined); 58 | const [err, setErr] = useState(null); 59 | const [isLoading, setIsLoading] = useState(true); 60 | 61 | useEffect(() => { 62 | fetchData(); 63 | }, dependencies); 64 | 65 | const fetchData = async () => { 66 | setIsLoading(true); 67 | setErr(null); 68 | setData(undefined); 69 | try { 70 | const result = await fetchFunction(); 71 | setData(result); 72 | } catch (err) { 73 | setErr(err instanceof Error ? err.message : "An unknown error occurred"); 74 | console.warn("[useFetch]", err); 75 | } finally { 76 | setIsLoading(false); 77 | } 78 | }; 79 | 80 | return { data, err, isLoading, refetch: fetchData }; 81 | } 82 | 83 | /** 84 | /** 85 | * A hook to handle data fetching and loading more data. 86 | * 87 | * @template T The type of data returned by the fetch function 88 | * @template C The type of cursor used to paginate through the data 89 | * @param fetchFunction An async function that returns a `Promise>` 90 | * @param dependencies An array of dependencies that trigger a re-fetch when changed 91 | * @returns An object containing: 92 | * - data: The fetched data 93 | * - err: Any error that occurred 94 | * - isLoading: Whether data is currently being fetched 95 | * - hasNextPage: Whether there is a next page available to fetch 96 | * - loadMore: A function to load more data 97 | */ 98 | export function useFetchAndLoadMore( 99 | fetchFunction: (cursor: C | undefined) => Promise>, 100 | dependencies: unknown[] = [], 101 | ) { 102 | const [data, setData] = useState([]); 103 | const [err, setErr] = useState(null); 104 | const [isLoading, setIsLoading] = useState(true); 105 | const [hasNextPage, setHasNextPage] = useState(true); 106 | const [nextCursor, setNextCursor] = useState(undefined); 107 | 108 | useEffect(() => { 109 | // reset state 110 | setData([]); 111 | setErr(null); 112 | setIsLoading(true); 113 | setHasNextPage(true); 114 | setNextCursor(undefined); 115 | // fetch initial data 116 | loadMore(); 117 | }, dependencies); 118 | 119 | const loadMore = async () => { 120 | if (!hasNextPage) return; 121 | 122 | setErr(null); 123 | setIsLoading(true); 124 | try { 125 | const response = await fetchFunction(nextCursor); 126 | setData((prevData) => [...prevData, ...response.data]); 127 | setHasNextPage(response.hasNextPage); 128 | setNextCursor(response.nextCursor); 129 | } catch (err) { 130 | setErr(err instanceof Error ? err.message : "Failed to load more data"); 131 | console.warn("[useFetchAndLoadMore]", err); 132 | } finally { 133 | setIsLoading(false); 134 | } 135 | }; 136 | 137 | return { data, err, isLoading, hasNextPage, loadMore }; 138 | } 139 | 140 | /** 141 | * A hook to handle data fetching and paginating through the results. 142 | * 143 | * @template T The type of data returned by the fetch function 144 | * @template C The type of cursor used to paginate through the data 145 | * @param fetchFunction An async function that returns a `Promise>` 146 | * @param dependencies An array of dependencies that trigger a re-fetch when changed 147 | * @returns An object containing the following properties: 148 | * - page: The current page of data 149 | * - err: Any error that occurred during fetching 150 | * - isLoading: Whether data is currently being fetched 151 | * - hasMultiplePages: Whether there are multiple pages of data 152 | * - isFirstPage: Whether the current page is the first page 153 | * - isLastPage: Whether the current page is the last fetched page 154 | * - hasNextPage: Whether there is a next page available to fetch 155 | * - goToNextPage: Function to navigate to the next page 156 | * - goToPreviousPage: Function to navigate to the previous page 157 | */ 158 | export function useFetchAndPaginate( 159 | fetchFunction: (cursor: C | undefined) => Promise>, 160 | dependencies: unknown[] = [], 161 | ) { 162 | const [pages, setPages] = useState([]); 163 | const [err, setErr] = useState(null); 164 | const [isLoading, setIsLoading] = useState(true); 165 | const [pageIndex, setPageIndex] = useState(-1); 166 | const [hasNextPage, setHasNextPage] = useState(true); 167 | const [nextCursor, setNextCursor] = useState(undefined); 168 | 169 | useEffect(() => { 170 | // reset state 171 | setPages([]); 172 | setErr(null); 173 | setIsLoading(true); 174 | setPageIndex(-1); 175 | setHasNextPage(true); 176 | setNextCursor(undefined); 177 | // fetch initial data 178 | goToNextPage(); 179 | }, dependencies); 180 | 181 | const goToNextPage = async () => { 182 | const isLastPage = pageIndex === pages.length - 1; 183 | const nextPageIndex = pageIndex + 1; 184 | 185 | if (isLastPage && !hasNextPage) return; // no more pages available 186 | if (!isLastPage) { 187 | // next page already fetched 188 | setPageIndex(nextPageIndex); 189 | return; 190 | } 191 | // fetch the next page 192 | setErr(null); 193 | setIsLoading(true); 194 | try { 195 | const response = await fetchFunction(nextCursor); 196 | setPages((prevPages) => [...prevPages, response.data]); 197 | setHasNextPage(response.hasNextPage); 198 | setNextCursor(response.nextCursor); 199 | setPageIndex(nextPageIndex); 200 | } catch (err) { 201 | setErr(err instanceof Error ? err.message : "Failed to load more data"); 202 | console.warn("[useFetchAndPaginate]", err); 203 | } finally { 204 | setIsLoading(false); 205 | } 206 | }; 207 | 208 | const goToPreviousPage = () => { 209 | if (pageIndex > 0) { 210 | setPageIndex(pageIndex - 1); 211 | } 212 | }; 213 | 214 | return { 215 | page: pages[pageIndex] ?? [], 216 | err, 217 | isLoading, 218 | hasMultiplePages: pages.length > 1 || (pages.length === 1 && hasNextPage), 219 | isFirstPage: pageIndex === 0, 220 | isLastPage: pageIndex === pages.length - 1, 221 | hasNextPage, 222 | goToNextPage, 223 | goToPreviousPage, 224 | }; 225 | } 226 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Polymedia Suitcase 2 | 3 | Sui utilities for TypeScript, Node, and React. 4 | 5 | ![Polymedia Suitcase](https://assets.polymedia.app/img/suitcase/open-graph.webp) 6 | 7 | # Core 8 | 9 | The `suitcase-core` package provides utilities for all TypeScript environments (browser, server, etc). 10 | 11 | - Installation: `pnpm add @polymedia/suitcase-core` 12 | - Source code: [src/core](./src/core) 13 | 14 | ## Addresses 15 | 16 | - `generateRandomAddress()` - Generate a random Sui address (for development only). 17 | - `removeAddressLeadingZeros()` - Remove leading zeros from a Sui address (lossless). 18 | - `shortenAddress()` - Abbreviate a Sui address for display purposes (lossy). 19 | - `validateAndNormalizeAddress()` - Validate a Sui address and return its normalized form, or `null` if invalid. 20 | 21 | ## APIs 22 | 23 | - `apiRequestIndexer()` - Make a request to the Indexer.xyz API (NFTs). 24 | 25 | ## Balances 26 | 27 | - `balanceToString()` - Convert a bigint to a string, scaled down to the specified decimals. 28 | - `stringToBalance()` - Convert a string to a bigint, scaled up to the specified decimals. 29 | - `formatBalance()` - Format a bigint into a readable string, scaled down to the specified decimals. 30 | - `formatNumber()` - Format a number into a readable string. 31 | 32 | ## Classes 33 | 34 | - `class SuiClientBase` - Abstract class for building Sui SDK clients. 35 | - `fetchAndParseObjs()` - Fetch and parse objects from the RPC and cache them. 36 | - `fetchAndParseTxs()` - Fetch and parse transactions from the RPC. 37 | - `signTx()` - Sign a transaction. 38 | - `executeTx()` - Execute a transaction. 39 | - `signAndExecuteTx()` - Sign and execute a transaction. 40 | 41 | - `class SuiEventFetcher` - Fetch Sui events and parse them into custom objects. 42 | - `fetchEvents()` - Fetch the latest events. Every time the function is called it looks 43 | for events that took place since the last call. 44 | 45 | - `class SuiMultiClient` - Make many RPC requests using multiple endpoints. 46 | - `executeInBatches()` - Execute `SuiClient` RPC operations in parallel using multiple endpoints. 47 | - `testEndpoints()` - Test the latency of various Sui RPC endpoints. 48 | 49 | ## Client 50 | 51 | - `devInspectAndGetExecutionResults()` - Call `SuiClient.devInspectTransactionBlock()` and return the execution results. 52 | - `devInspectAndGetReturnValues()` - Call `SuiClient.devInspectTransactionBlock()` and return the deserialized return values. 53 | - `fetchDynamicFields()` - Get dynamic object fields owned by an object. If limit is not specified, fetch all DOFs. 54 | - `getCoinOfValue()` - Get a `Coin` of a given value from the owner. Handles coin merging and splitting. 55 | - `getSuiObjectRef()` - Fetch the latest version of an object and return its `SuiObjectRef`. 56 | 57 | ## Coins 58 | 59 | - `class CoinMetaFetcher` - Fetch coin metadata from the RPC and cache it. 60 | - `getCoinMeta()` - Fetch metadata for a single coin. 61 | - `getCoinMetas()` - Fetch metadata for multiple coins. 62 | - `type CoinMeta` - Like `CoinMetadata` from `@mysten/sui`, but includes the coin `type`. 63 | 64 | ## Constants 65 | 66 | - `const MAX_U64` - The maximum value for a 64-bit unsigned integer. 67 | - `const NORMALIZED_0x0_ADDRESS` - The normalized 0x0 address (0x000…000). 68 | - `const NORMALIZED_SUI_TYPE` - The normalized SUI type (0x000…002::sui::SUI). 69 | - `const REGEX_ADDRESS` - Match a Sui address. 70 | - `const REGEX_ADDRESS_NORMALIZED` - Match a normalized Sui address. 71 | - `const REGEX_MODULE_NAME` - Match a Sui module name. 72 | - `const REGEX_STRUCT_NAME` - Match a Sui struct name. 73 | - `const REGEX_TYPE_BASIC` - Match a Sui type without generic parameters (e.g. `0x123::module::Struct`). 74 | - `const RPC_QUERY_MAX_RESULTS` - Maximum number of results returned by a single Sui RPC request. 75 | 76 | ## Errors 77 | 78 | - `anyToStr()` - Attempts to convert any kind of value into a readable string. 79 | - `parseMoveAbort()` - Parse a Move abort string into its different parts. 80 | - `class TxErrorParser` - Parse transaction errors and convert them into user-friendly messages. 81 | 82 | ## Faucet 83 | 84 | - `requestSuiFromFaucet()` - Get SUI from the faucet on localnet/devnet/testnet. 85 | 86 | ## Format 87 | 88 | - `formatBps()` - Return a human-readable string from a number of basis points. 89 | - `formatDate()` - Return a human-readable date string from a timestamp in milliseconds. 90 | - `formatDuration()` - Return a human-readable string from a number of milliseconds. 91 | - `formatTimeDiff()` - Return a human-readable string with the time difference between two timestamps. 92 | - `urlToDomain()` - Return the domain from a URL. 93 | - `shortenDigest()` - Return a shortened version of a transaction digest. 94 | 95 | ## Misc 96 | 97 | - `chunkArray()` - Split an array into multiple chunks of a certain size. 98 | - `chunkString()` - Split a string into multiple chunks of a certain size. 99 | - `makeRanges()` - Generate an array of ranges of a certain size between two numbers. 100 | - `sleep()` - Wait for a number of milliseconds. 101 | 102 | ## Keypairs 103 | 104 | - `pairFromSecretKey()` - Build a `Keypair` from a secret key string like `suiprivkey1...`. 105 | 106 | ## Objects 107 | 108 | - `type ObjectDisplay` - A Sui object display with common properties and arbitrary ones. 109 | - `objResToBcs()` - Validate a `SuiObjectResponse` and return its `.data.bcs.bcsBytes`. 110 | - `objResToContent()` - Validate a `SuiObjectResponse` and return its `.data.content`. 111 | - `objResToDisplay()` - Validate a `SuiObjectResponse` and return its `.data.display.data` or `null`. 112 | - `newEmptyDisplay()` - Create an `ObjectDisplay` object with all fields set to `null`. 113 | - `objResToFields()` - Validate a `SuiObjectResponse` and return its `.data.content.fields`. 114 | - `objResToId()` - Validate a `SuiObjectResponse` and return its `.data.objectId`. 115 | - `objResToOwner()` - Validate a `SuiObjectResponse` and return its owner: an address, object ID, "shared" or "immutable". 116 | - `objResToRef()` - Validate a `SuiObjectResponse` and return its `{.data.objectId, .data.digest, .data.version}`. 117 | 118 | ## RPCs 119 | 120 | - `const RPC_ENDPOINTS` - A list of public RPCs for Sui mainnet, testnet, and devnet. 121 | - `measureRpcLatency()` - Measure Sui RPC latency by making requests to various endpoints. 122 | - `newLowLatencySuiClient()` - Instantiate SuiClient using the RPC endpoint with the lowest latency. 123 | 124 | ## Transactions 125 | 126 | - `type NestedResult` - An item in the array returned by a `Transaction.moveCall()` call. 127 | - `type ObjectInput` - Either a `TransactionObjectInput` or a `SuiObjectRef`. 128 | - `type SignTx` - A function that can sign a `Transaction`. 129 | - `getArgVal()` - Get the value of a `SuiCallArg` (transaction input). If the argument is a pure value, return it. If the argument is an object, return its ID. 130 | - `newSignTx()` - Create a `SignTx` function that uses a `Signer` to sign a `Transaction`. 131 | - `newSignAndExecuteTx()` - Create a function that signs and executes a `Transaction`. 132 | - `objectArg()` - Transform an `ObjectInput` into an argument for `Transaction.moveCall()`. 133 | - `txResToData()` - Validate a `SuiTransactionBlockResponse` of the `ProgrammableTransaction` kind and return its `.transaction.data`. 134 | - `TransferModule` - Build transactions for the `sui::transfer` module. 135 | - `public_freeze_object()` 136 | - `public_share_object()` 137 | - `public_transfer()` 138 | 139 | ## Type guards 140 | 141 | ### ObjectOwner 142 | - `type OwnerKind` - An `ObjectOwner` of a specific kind. 143 | - `isOwnerKind()` - Type guard to check if an `ObjectOwner` is of a specific kind. 144 | 145 | ### SuiArgument 146 | - `type ArgKind` - A `SuiArgument` of a specific kind. 147 | - `isArgKind()` - Type guard to check if a `SuiArgument` is of a specific kind. 148 | 149 | ### SuiObjectChange 150 | - `type ObjChangeKind` - A `SuiObjectChange` of a specific kind. 151 | - `isObjChangeKind()` - Type guard to check if a `SuiObjectChange` is of a specific kind. 152 | 153 | ### SuiObjectRef 154 | - `isSuiObjectRef()` - Type guard to check if an object is a `SuiObjectRef`. 155 | 156 | ### SuiParsedData 157 | - `type ParsedDataKind` - A `SuiParsedData` of a specific kind. 158 | - `isParsedDataKind()` - Type guard to check if a `SuiParsedData` is of a specific kind. 159 | 160 | ### SuiTransaction 161 | - `type TxKind` - A `SuiTransaction` of a specific kind. 162 | - `isTxKind()` - Type guard to check if a `SuiTransaction` is of a specific kind. 163 | 164 | ## Types 165 | 166 | - `const NETWORK_NAMES` - `["mainnet", "testnet", "devnet", "localnet"]`. 167 | - `type NetworkName` - `"mainnet" | "testnet" | "devnet" | "localnet"`. 168 | - `type PaginatedResponse` - A paginated response from a Sui RPC call. 169 | - `type ReceivingRef` - The return type of `Transaction.receivingRef()`. 170 | 171 | ## URLs 172 | 173 | - `type SuiExplorerItem` - A Sui explorer item type (address/object/package/tx/coin). 174 | - `makePolymediaUrl()` - Build an explorer.polymedia.app URL. 175 | - `makeSuiscanUrl()` - Build a suiscan.xyz URL. 176 | - `makeSuivisionUrl()` - Build a suivision.xyz URL. 177 | 178 | # Node 179 | 180 | The `suitcase-node` package provides utilities for Node.js projects (command line tools, server side code, etc). 181 | 182 | - Installation: `pnpm add @polymedia/suitcase-node` 183 | - Source code: [src/node](./src/node) 184 | 185 | ## Sui 186 | 187 | - `getActiveAddress()` - Get the current active address (sui client active-address). 188 | - `getActiveKeypair()` - Build a `Ed25519Keypair` object for the current active address by loading the secret key from `~/.sui/sui_config/sui.keystore`. 189 | - `getActiveEnv()` - Get the active Sui environment from `sui client active-env`. 190 | - `setupSuiTransaction()` - Initialize objects to execute Sui transactions blocks using the current Sui active network and address. 191 | - `executeSuiTransaction()` - Execute a transaction block with `showEffects` and `showObjectChanges` set to `true`. 192 | 193 | ## Files 194 | 195 | - `fileExists()` - Check if a file exists in the filesystem. 196 | - `getFileName()` - Extract the file name from a module URL, without path or extension. 197 | - `readCsvFile()` - Read a CSV file and parse each line into an object. 198 | - `readJsonFile()` - Read a JSON file and parse its contents into an object. 199 | - `readTsvFile()` - Read a TSV file and parse each line into an object. 200 | - `writeCsvFile()` - Write objects into a CSV file. 201 | - `writeJsonFile()` - Write an object's JSON representation into a file. 202 | - `writeTextFile()` - Write a string into a file. 203 | - `writeTsvFile()` - Write objects into a TSV file. 204 | 205 | ## CLI 206 | 207 | - `parseArguments()` - Parse command line arguments and show usage instructions. 208 | - `promptUser()` - Display a query to the user and wait for their input. Return true if the user enters `y`. 209 | - `suppressSuiVersionMismatchWarnings()` - Suppress "Client/Server api version mismatch" warnings. 210 | 211 | # React 212 | 213 | The `suitcase-react` package provides components for React web apps. 214 | 215 | - Installation: `pnpm add @polymedia/suitcase-react` 216 | - Source code: [src/react](./src/react) 217 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. --------------------------------------------------------------------------------