From 596caac82658df699dcd8dbce6d4e1eb18a194e7 Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Fri, 22 May 2026 13:51:29 +0300 Subject: [PATCH 1/4] Add node contract deploy example --- .../node-contract-deploy-example/.env.example | 3 + .../node-contract-deploy-example/README.md | 50 +++ .../artifacts/.gitignore | 2 + .../contracts/PublicMintERC20.sol | 67 ++++ .../deployErc20.ts | 309 ++++++++++++++++++ .../node-contract-deploy-example/package.json | 21 ++ .../tsconfig.json | 14 + package.json | 2 + pnpm-lock.yaml | 95 ++++++ 9 files changed, 563 insertions(+) create mode 100644 examples/node-contract-deploy-example/.env.example create mode 100644 examples/node-contract-deploy-example/README.md create mode 100644 examples/node-contract-deploy-example/artifacts/.gitignore create mode 100644 examples/node-contract-deploy-example/contracts/PublicMintERC20.sol create mode 100644 examples/node-contract-deploy-example/deployErc20.ts create mode 100644 examples/node-contract-deploy-example/package.json create mode 100644 examples/node-contract-deploy-example/tsconfig.json diff --git a/examples/node-contract-deploy-example/.env.example b/examples/node-contract-deploy-example/.env.example new file mode 100644 index 0000000..531431c --- /dev/null +++ b/examples/node-contract-deploy-example/.env.example @@ -0,0 +1,3 @@ +OMS_PUBLIC_API_KEY=your-public-api-key +OMS_PROJECT_ID=your-oms-project-id +# DEPLOY_SALT=0x0000000000000000000000000000000000000000000000000000000000000001 diff --git a/examples/node-contract-deploy-example/README.md b/examples/node-contract-deploy-example/README.md new file mode 100644 index 0000000..d2c8ad9 --- /dev/null +++ b/examples/node-contract-deploy-example/README.md @@ -0,0 +1,50 @@ +# Node Contract Deploy Example + +This example signs in with an OMS wallet, compiles a small Solidity ERC-20 with +a public `mint(address,uint256)` function, and submits a Polygon Amoy +deployment transaction through a deployer contract. + +The SDK wallet transaction API requires a `to` address, so this example uses the +ERC-2470 SingletonFactory rather than a direct EVM contract-creation transaction. +The factory uses CREATE2 at `0xce0042B868300000d44A59004Da54A005ffdcf9f` +and exposes: + +```solidity +function deploy(bytes initCode, bytes32 salt) external returns (address payable createdContract); +``` + +The script computes the contract address from the factory address, `DEPLOY_SALT`, +and the encoded init code before sending the transaction. You can override the +factory with `DEPLOYER_ADDRESS`, but the default works for Polygon Amoy. + +## Tooling Choice + +Use `solc` for this example's contract compilation and `viem` for deployment +calldata encoding. This keeps the example small and Node-native. Hardhat or +Foundry would be better once this grows into a larger contract project with +tests, scripts, and multiple contracts. + +## Run + +From the repository root: + +```bash +pnpm install +pnpm build +cp examples/node-contract-deploy-example/.env.example examples/node-contract-deploy-example/.env.local +# Fill OMS_PUBLIC_API_KEY and OMS_PROJECT_ID in .env.local +pnpm dev:node-contract-deploy-example +``` + +The script prompts for token name, symbol, and decimals after login. The default +answers are `WalletKit Dollar`, `WKUSD`, and `6`. + +Optionally set a deterministic CREATE2 salt: + +```bash +DEPLOY_SALT=0x0000000000000000000000000000000000000000000000000000000000000001 +``` + +Each deploy writes a timestamped text record under `artifacts/` with the token +metadata, computed contract address, transaction id, transaction hash, and +explorer links. Generated artifact files are ignored by git. diff --git a/examples/node-contract-deploy-example/artifacts/.gitignore b/examples/node-contract-deploy-example/artifacts/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/examples/node-contract-deploy-example/artifacts/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/examples/node-contract-deploy-example/contracts/PublicMintERC20.sol b/examples/node-contract-deploy-example/contracts/PublicMintERC20.sol new file mode 100644 index 0000000..a367bd2 --- /dev/null +++ b/examples/node-contract-deploy-example/contracts/PublicMintERC20.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +contract PublicMintERC20 { + string public name; + string public symbol; + uint8 public immutable decimals; + uint256 public totalSupply; + + mapping(address account => uint256) public balanceOf; + mapping(address owner => mapping(address spender => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor(string memory name_, string memory symbol_, uint8 decimals_) { + name = name_; + symbol = symbol_; + decimals = decimals_; + } + + function mint(address to, uint256 amount) external returns (bool) { + require(to != address(0), "ERC20: mint to zero address"); + + totalSupply += amount; + balanceOf[to] += amount; + + emit Transfer(address(0), to, amount); + return true; + } + + function approve(address spender, uint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + + function transfer(address to, uint256 amount) external returns (bool) { + _transfer(msg.sender, to, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + uint256 allowed = allowance[from][msg.sender]; + require(allowed >= amount, "ERC20: insufficient allowance"); + + if (allowed != type(uint256).max) { + allowance[from][msg.sender] = allowed - amount; + emit Approval(from, msg.sender, allowance[from][msg.sender]); + } + + _transfer(from, to, amount); + return true; + } + + function _transfer(address from, address to, uint256 amount) internal { + require(to != address(0), "ERC20: transfer to zero address"); + + uint256 balance = balanceOf[from]; + require(balance >= amount, "ERC20: insufficient balance"); + + balanceOf[from] = balance - amount; + balanceOf[to] += amount; + + emit Transfer(from, to, amount); + } +} diff --git a/examples/node-contract-deploy-example/deployErc20.ts b/examples/node-contract-deploy-example/deployErc20.ts new file mode 100644 index 0000000..340da46 --- /dev/null +++ b/examples/node-contract-deploy-example/deployErc20.ts @@ -0,0 +1,309 @@ +import {randomBytes} from "node:crypto"; +import {mkdirSync, readFileSync, writeFileSync} from "node:fs"; +import {dirname, join} from "node:path"; +import readline from "node:readline/promises"; +import {fileURLToPath} from "node:url"; +import {MemoryStorageManager, Networks, OMSClient} from "@0xsequence/typescript-sdk"; +import {config as loadDotenv} from "dotenv"; +import solc from "solc"; +import {encodeDeployData, getContractAddress, isAddress, parseAbi} from "viem"; +import type {Abi, Address, Hex} from "viem"; + +const exampleDir = dirname(fileURLToPath(import.meta.url)); +loadDotenv({path: join(exampleDir, ".env.local"), quiet: true}); +loadDotenv({path: join(exampleDir, ".env"), quiet: true}); + +const publicApiKey = requiredEnv("OMS_PUBLIC_API_KEY", process.env.OMS_PUBLIC_API_KEY); +const projectId = requiredEnv("OMS_PROJECT_ID", process.env.OMS_PROJECT_ID); +const defaultDeployerAddress = "0xce0042B868300000d44A59004Da54A005ffdcf9f" as const satisfies Address; +const deployerAddress = optionalAddress("DEPLOYER_ADDRESS", process.env.DEPLOYER_ADDRESS) ?? defaultDeployerAddress; + +const deployerAbi = parseAbi([ + "function deploy(bytes initCode, bytes32 salt) returns (address payable createdContract)", +]); + +type TokenConfig = { + name: string + symbol: string + decimals: number +} + +const defaultTokenConfig: TokenConfig = { + name: "WalletKit Dollar", + symbol: "WKUSD", + decimals: 6, +}; + +async function main() { + console.log("------------------------------------------------------------"); + console.log(" OMS wallet ERC-20 deploy example"); + console.log("------------------------------------------------------------"); + console.log("network :", `${Networks.amoy.displayName} (${Networks.amoy.id})`); + console.log("public API key :", mask(publicApiKey)); + console.log("deployer address :", deployerAddress); + console.log(); + + const client = new OMSClient({ + publicApiKey, + projectId, + storage: new MemoryStorageManager(), + }); + + const email = await prompt("Enter your email: "); + + console.log(); + console.log(`[auth] startEmailAuth("${email}")`); + await client.wallet.startEmailAuth({email}); + + const code = await prompt("Enter the code from your email: "); + + console.log(`[auth] completeEmailAuth("${mask(code)}")`); + const authResult = await client.wallet.completeEmailAuth({code}); + console.log(`[auth] logged in as ${authResult.walletAddress}`); + console.log(); + + const tokenConfig = await promptTokenConfig(); + const compiled = compilePublicMintErc20(); + const initCode = encodeDeployData({ + abi: compiled.abi, + bytecode: compiled.bytecode, + args: [tokenConfig.name, tokenConfig.symbol, tokenConfig.decimals], + }); + const salt = parseSalt(process.env.DEPLOY_SALT); + const contractAddress = getContractAddress({ + opcode: "CREATE2", + from: deployerAddress, + salt, + bytecode: initCode, + }); + + console.log("[token] name :", tokenConfig.name); + console.log("[token] symbol :", tokenConfig.symbol); + console.log("[token] decimals :", tokenConfig.decimals); + console.log("[compile] contract : PublicMintERC20"); + console.log("[compile] bytecode :", `${(compiled.bytecode.length - 2) / 2} bytes`); + console.log("[deploy] salt :", salt); + console.log("[deploy] contract :", contractAddress); + console.log("[deploy] init code :", `${(initCode.length - 2) / 2} bytes`); + console.log(); + + const tx = await client.wallet.sendTransaction({ + network: Networks.amoy, + to: deployerAddress, + abi: deployerAbi, + functionName: "deploy", + args: [initCode, salt], + statusPolling: { + timeoutMs: 120_000, + intervalMs: 2_000, + }, + }); + + const artifactPath = writeDeployArtifact({ + tokenConfig, + walletAddress: authResult.walletAddress, + deployerAddress, + contractAddress, + salt, + initCode, + tx, + }); + + console.log("[deploy] status :", tx.status); + console.log("[deploy] txn id :", tx.txnId); + console.log("[deploy] tx hash:", tx.txnHash ?? ""); + console.log("[deploy] contract:", contractAddress); + console.log("[deploy] contract explorer:", `${Networks.amoy.explorerUrl}/address/${contractAddress}`); + if (tx.txnHash) { + console.log("[deploy] tx explorer:", `${Networks.amoy.explorerUrl}/tx/${tx.txnHash}`); + } + console.log("[deploy] artifact:", artifactPath); +} + +type SolcOutput = { + contracts?: Record> + errors?: Array<{ + severity: "error" | "warning" | "info" + formattedMessage: string + }> +} + +function compilePublicMintErc20(): {abi: Abi; bytecode: Hex} { + const sourcePath = join(exampleDir, "contracts", "PublicMintERC20.sol"); + const source = readFileSync(sourcePath, "utf8"); + + const input = { + language: "Solidity", + sources: { + "PublicMintERC20.sol": { + content: source, + }, + }, + settings: { + optimizer: { + enabled: true, + runs: 200, + }, + outputSelection: { + "*": { + "*": ["abi", "evm.bytecode.object"], + }, + }, + }, + }; + + const output = JSON.parse(solc.compile(JSON.stringify(input))) as SolcOutput; + const errors = output.errors ?? []; + const fatalErrors = errors.filter(error => error.severity === "error"); + for (const error of errors.filter(error => error.severity !== "error")) { + console.warn(error.formattedMessage); + } + + if (fatalErrors.length > 0) { + throw new Error(fatalErrors.map(error => error.formattedMessage).join("\n")); + } + + const contract = output.contracts?.["PublicMintERC20.sol"]?.PublicMintERC20; + const bytecodeObject = contract?.evm?.bytecode?.object; + if (!contract || !bytecodeObject) { + throw new Error("Solidity compilation did not produce PublicMintERC20 bytecode"); + } + + return { + abi: contract.abi, + bytecode: `0x${bytecodeObject}`, + }; +} + +function parseSalt(value: string | undefined): Hex { + if (!value) { + return `0x${randomBytes(32).toString("hex")}`; + } + + if (!/^0x[0-9a-fA-F]{64}$/.test(value)) { + throw new Error("DEPLOY_SALT must be a 32-byte hex string"); + } + + return value as Hex; +} + +function parseDecimals(value: string): number { + const decimals = Number.parseInt(value, 10); + if (!Number.isInteger(decimals) || decimals < 0 || decimals > 255) { + throw new Error("Token decimals must be an integer between 0 and 255"); + } + return decimals; +} + +async function promptTokenConfig(): Promise { + console.log("[token] configure ERC-20 metadata"); + const name = await promptWithDefault("Token name", defaultTokenConfig.name); + const symbol = (await promptWithDefault("Token symbol", defaultTokenConfig.symbol)).toUpperCase(); + const decimals = parseDecimals( + await promptWithDefault("Token decimals", defaultTokenConfig.decimals.toString()), + ); + console.log(); + + return {name, symbol, decimals}; +} + +async function promptWithDefault(question: string, defaultValue: string): Promise { + const answer = await prompt(`${question} [${defaultValue}]: `); + return answer || defaultValue; +} + +function writeDeployArtifact(params: { + tokenConfig: TokenConfig + walletAddress: Address + deployerAddress: Address + contractAddress: Address + salt: Hex + initCode: Hex + tx: {txnId: string; status: string; txnHash?: string} +}): string { + const artifactsDir = join(exampleDir, "artifacts"); + mkdirSync(artifactsDir, {recursive: true}); + + const deployedAt = new Date().toISOString(); + const timestamp = deployedAt.replace(/[:.]/g, "-"); + const tokenSlug = slugify(params.tokenConfig.name); + const artifactPath = join(artifactsDir, `${timestamp}-${tokenSlug}.txt`); + const lines = [ + `timestamp: ${deployedAt}`, + `network: ${Networks.amoy.displayName} (${Networks.amoy.id})`, + `tokenName: ${params.tokenConfig.name}`, + `tokenSymbol: ${params.tokenConfig.symbol}`, + `tokenDecimals: ${params.tokenConfig.decimals}`, + `walletAddress: ${params.walletAddress}`, + `deployerAddress: ${params.deployerAddress}`, + `contractAddress: ${params.contractAddress}`, + `contractExplorerUrl: ${Networks.amoy.explorerUrl}/address/${params.contractAddress}`, + `salt: ${params.salt}`, + `initCodeBytes: ${(params.initCode.length - 2) / 2}`, + `txnId: ${params.tx.txnId}`, + `status: ${params.tx.status}`, + `txnHash: ${params.tx.txnHash ?? ""}`, + `txExplorerUrl: ${params.tx.txnHash ? `${Networks.amoy.explorerUrl}/tx/${params.tx.txnHash}` : ""}`, + ]; + + writeFileSync(artifactPath, `${lines.join("\n")}\n`, "utf8"); + return artifactPath; +} + +function slugify(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "token"; +} + +function requiredEnv(name: string, value: string | undefined): string { + if (!value) { + throw new Error(`Missing ${name}`); + } + return value; +} + +function requiredAddress(name: string, value: string | undefined): Address { + const address = requiredEnv(name, value); + if (!isAddress(address)) { + throw new Error(`${name} must be an EVM address`); + } + return address; +} + +function optionalAddress(name: string, value: string | undefined): Address | undefined { + if (!value) { + return undefined; + } + if (!isAddress(value)) { + throw new Error(`${name} must be an EVM address`); + } + return value; +} + +function mask(value: string | undefined): string { + if (!value) return ""; + if (value.length <= 8) return "***"; + return `${value.slice(0, 4)}...${value.slice(-4)}`; +} + +async function prompt(question: string): Promise { + const rl = readline.createInterface({input: process.stdin, output: process.stdout}); + const answer = await rl.question(question); + rl.close(); + return answer.trim(); +} + +main().catch(error => { + console.error("unhandled error:", error); + process.exit(1); +}); diff --git a/examples/node-contract-deploy-example/package.json b/examples/node-contract-deploy-example/package.json new file mode 100644 index 0000000..a1d6dd8 --- /dev/null +++ b/examples/node-contract-deploy-example/package.json @@ -0,0 +1,21 @@ +{ + "name": "node-contract-deploy-example", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx deployErc20.ts", + "build": "tsc --noEmit" + }, + "dependencies": { + "@0xsequence/typescript-sdk": "workspace:*", + "dotenv": "^17.4.2", + "solc": "^0.8.35", + "viem": "^2.48.4" + }, + "devDependencies": { + "@types/node": "^22.19.19", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/examples/node-contract-deploy-example/tsconfig.json b/examples/node-contract-deploy-example/tsconfig.json new file mode 100644 index 0000000..7fdee34 --- /dev/null +++ b/examples/node-contract-deploy-example/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true + }, + "include": ["*.ts"] +} diff --git a/package.json b/package.json index 17b8640..2c9eca3 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "build:example": "pnpm --filter react-example build", "dev:node-example": "pnpm --filter node-example dev", "build:node-example": "pnpm --filter node-example build", + "dev:node-contract-deploy-example": "pnpm --filter node-contract-deploy-example dev", + "build:node-contract-deploy-example": "pnpm --filter node-contract-deploy-example build", "test": "vitest run && pnpm test:types", "test:types": "tsc --noEmit --target es2020 --lib es2022,dom --module ES2020 --moduleResolution bundler --strict --esModuleInterop --skipLibCheck type-tests/oidcProviderTypes.ts", "test:watch": "vitest" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41423d0..d8bae33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,31 @@ importers: specifier: ^5.9.3 version: 5.9.3 + examples/node-contract-deploy-example: + dependencies: + '@0xsequence/typescript-sdk': + specifier: workspace:* + version: link:../.. + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + solc: + specifier: ^0.8.35 + version: 0.8.35 + viem: + specifier: ^2.48.4 + version: 2.48.4(typescript@5.9.3) + devDependencies: + '@types/node': + specifier: ^22.19.19 + version: 22.19.19 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + examples/react: dependencies: '@0xsequence/typescript-sdk': @@ -457,6 +482,13 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + command-exists@1.2.9: + resolution: {integrity: sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -498,6 +530,15 @@ packages: picomatch: optional: true + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -511,6 +552,9 @@ packages: peerDependencies: ws: '*' + js-sha3@0.8.0: + resolution: {integrity: sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -588,6 +632,10 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + memorystream@0.3.1: + resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} + engines: {node: '>= 0.10.0'} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -596,6 +644,10 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + os-tmpdir@1.0.2: + resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} + engines: {node: '>=0.10.0'} + ox@0.14.20: resolution: {integrity: sha512-rby38C3nDn8eQkf29Zgw4hkCZJ64Qqi0zRPWL8ENUQ7JVuoITqrVtwWQgM/He19SCMUEc7hS/Sjw0jIOSLJhOw==} peerDependencies: @@ -638,9 +690,18 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + solc@0.8.35: + resolution: {integrity: sha512-OaP/4zyoKRo2CjqZDxbtkeRlEo6MxP4FLCxntw1Agf9OSoecmwYKoFBSB34UcSKBFBucrTh3Mb0nRoJou62ibw==} + engines: {node: '>=12.0.0'} + hasBin: true + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -666,6 +727,10 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tmp@0.0.33: + resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} + engines: {node: '>=0.6.0'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1056,6 +1121,10 @@ snapshots: chai@6.2.2: {} + command-exists@1.2.9: {} + + commander@8.3.0: {} + convert-source-map@2.0.0: {} csstype@3.2.3: {} @@ -1107,6 +1176,8 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + follow-redirects@1.16.0: {} + fsevents@2.3.3: optional: true @@ -1118,6 +1189,8 @@ snapshots: dependencies: ws: 8.18.3 + js-sha3@0.8.0: {} + lightningcss-android-arm64@1.32.0: optional: true @@ -1171,10 +1244,14 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + memorystream@0.3.1: {} + nanoid@3.3.11: {} obug@2.1.1: {} + os-tmpdir@1.0.2: {} + ox@0.14.20(typescript@5.9.3): dependencies: '@adraffy/ens-normalize': 1.11.1 @@ -1234,8 +1311,22 @@ snapshots: scheduler@0.27.0: {} + semver@5.7.2: {} + siginfo@2.0.0: {} + solc@0.8.35: + dependencies: + command-exists: 1.2.9 + commander: 8.3.0 + follow-redirects: 1.16.0 + js-sha3: 0.8.0 + memorystream: 0.3.1 + semver: 5.7.2 + tmp: 0.0.33 + transitivePeerDependencies: + - debug + source-map-js@1.2.1: {} stackback@0.0.2: {} @@ -1253,6 +1344,10 @@ snapshots: tinyrainbow@3.1.0: {} + tmp@0.0.33: + dependencies: + os-tmpdir: 1.0.2 + tslib@2.8.1: optional: true From 63256522bc0940e261df99e88b6eb5376f351b51 Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Fri, 22 May 2026 13:51:56 +0300 Subject: [PATCH 2/4] Add React WKUSD ERC20 example --- examples/react/README.md | 7 +- examples/react/package.json | 3 +- examples/react/src/WalletKitDollarExample.tsx | 223 ++++++++++++++++++ examples/react/src/config.ts | 9 + examples/react/src/main.tsx | 32 ++- examples/react/src/styles.css | 108 +++++++++ examples/react/src/walletKitDollarContract.ts | 7 + pnpm-lock.yaml | 3 + 8 files changed, 378 insertions(+), 14 deletions(-) create mode 100644 examples/react/src/WalletKitDollarExample.tsx create mode 100644 examples/react/src/config.ts create mode 100644 examples/react/src/walletKitDollarContract.ts diff --git a/examples/react/README.md b/examples/react/README.md index ec9c8d0..f36f262 100644 --- a/examples/react/README.md +++ b/examples/react/README.md @@ -12,7 +12,7 @@ Run it from the repository root: pnpm install pnpm build cp examples/react/.env.example examples/react/.env.local -# Fill VITE_OMS_PUBLIC_API_KEY and VITE_OMS_PROJECT_ID in examples/react/.env.local +# Fill VITE_OMS_PUBLIC_API_KEY and VITE_OMS_PROJECT_ID pnpm dev:example ``` @@ -24,9 +24,12 @@ The example requires a public API key and project ID. Configure them locally bef ```bash cp examples/react/.env.example examples/react/.env.local -# Fill VITE_OMS_PUBLIC_API_KEY and VITE_OMS_PROJECT_ID in examples/react/.env.local +# Fill VITE_OMS_PUBLIC_API_KEY and VITE_OMS_PROJECT_ID ``` +The Amoy-only "ERC20 example" panel includes a WalletKit Dollar example using +the demo WKUSD contract deployed on Polygon Amoy. + Google/OIDC redirect sign-in uses the SDK default Google client id. Build it from the repository root: diff --git a/examples/react/package.json b/examples/react/package.json index ffb2870..97d9dd0 100644 --- a/examples/react/package.json +++ b/examples/react/package.json @@ -11,7 +11,8 @@ "dependencies": { "react": "^19.2.5", "react-dom": "^19.2.5", - "@0xsequence/typescript-sdk": "workspace:*" + "@0xsequence/typescript-sdk": "workspace:*", + "viem": "^2.48.4" }, "devDependencies": { "@types/react": "^19.2.14", diff --git a/examples/react/src/WalletKitDollarExample.tsx b/examples/react/src/WalletKitDollarExample.tsx new file mode 100644 index 0000000..6ec63a7 --- /dev/null +++ b/examples/react/src/WalletKitDollarExample.tsx @@ -0,0 +1,223 @@ +import { useEffect, useMemo, useState } from 'react' +import { Networks, OMSClient } from '@0xsequence/typescript-sdk' +import { createPublicClient, formatUnits, http, isAddress, parseUnits } from 'viem' +import type { Address } from 'viem' +import { PUBLIC_API_KEY, PROJECT_ID } from './config' +import { walletKitDollarAbi } from './walletKitDollarContract' + +const AMOY_RPC_URL = 'https://rpc-amoy.polygon.technology' +const WKUSD_CONTRACT_ADDRESS = '0x4Ef29925C9C72b860447A6DA628cc78f785b27b5' as const satisfies Address +const BURN_ADDRESS = '0x000000000000000000000000000000000000dEaD' as const satisfies Address +const WALLET_KIT_DOLLAR = { + name: 'WalletKit Dollar', + symbol: 'WKUSD', + decimals: 6, +} as const +const MINT_AMOUNT = 10n * 10n ** BigInt(WALLET_KIT_DOLLAR.decimals) + +export function WalletKitDollarExample() { + const [walletAddress, setWalletAddress] = useState('') + const [balance, setBalance] = useState(null) + const [to, setTo] = useState('') + const [amount, setAmount] = useState('1') + const [sendToBurn, setSendToBurn] = useState(false) + const [lastHash, setLastHash] = useState('') + const [lastExplorerUrl, setLastExplorerUrl] = useState('') + const [status, setStatus] = useState('') + const [isBusy, setIsBusy] = useState(false) + + const oms = useMemo(() => new OMSClient({ + publicApiKey: PUBLIC_API_KEY, + projectId: PROJECT_ID, + }), []) + const publicClient = useMemo(() => createPublicClient({ + transport: http(AMOY_RPC_URL), + }), []) + + useEffect(() => { + const restoredAddress = oms.wallet.walletAddress ?? '' + setWalletAddress(restoredAddress) + if (isAddress(restoredAddress)) { + void refreshBalance(restoredAddress) + } + }, [oms]) + + useEffect(() => { + if (sendToBurn) { + setTo(BURN_ADDRESS) + } + }, [sendToBurn]) + + async function run(label: string, action: () => Promise) { + setIsBusy(true) + setStatus(label) + try { + await action() + } catch (error) { + setStatus(error instanceof Error ? error.message : String(error)) + } finally { + setIsBusy(false) + } + } + + async function mint() { + await run('Minting WKUSD...', async () => { + const activeWallet = requireWalletAddress() + clearLastTransaction() + + const tx = await oms.wallet.sendTransaction({ + network: Networks.amoy, + to: WKUSD_CONTRACT_ADDRESS, + abi: walletKitDollarAbi, + functionName: 'mint', + args: [activeWallet, MINT_AMOUNT], + statusPolling: { + timeoutMs: 120_000, + intervalMs: 2_000, + }, + }) + + setLastHash(tx.txnHash ?? tx.txnId) + setLastExplorerUrl(tx.txnHash ? transactionExplorerUrl(tx.txnHash) : '') + await refreshBalance(activeWallet) + setStatus(`Minted 10 ${WALLET_KIT_DOLLAR.name}.`) + }) + } + + async function send() { + await run('Sending WKUSD...', async () => { + const activeWallet = requireWalletAddress() + const recipient = to.trim() + if (!isAddress(recipient)) { + throw new Error('Enter a valid recipient address.') + } + + const transferAmount = parseUnits(amount || '0', WALLET_KIT_DOLLAR.decimals) + if (transferAmount <= 0n) { + throw new Error('Send amount must be greater than zero.') + } + + clearLastTransaction() + + const tx = await oms.wallet.sendTransaction({ + network: Networks.amoy, + to: WKUSD_CONTRACT_ADDRESS, + abi: walletKitDollarAbi, + functionName: 'transfer', + args: [recipient as Address, transferAmount], + statusPolling: { + timeoutMs: 120_000, + intervalMs: 2_000, + }, + }) + + setLastHash(tx.txnHash ?? tx.txnId) + setLastExplorerUrl(tx.txnHash ? transactionExplorerUrl(tx.txnHash) : '') + await refreshBalance(activeWallet) + setStatus(`Sent ${amount} ${WALLET_KIT_DOLLAR.symbol}.`) + }) + } + + async function refreshBalance(address: Address) { + const nextBalance = await publicClient.readContract({ + address: WKUSD_CONTRACT_ADDRESS, + abi: walletKitDollarAbi, + functionName: 'balanceOf', + args: [address], + }) + setBalance(nextBalance) + } + + function requireWalletAddress(): Address { + const activeWallet = oms.wallet.walletAddress ?? walletAddress + if (!isAddress(activeWallet)) { + throw new Error('Active wallet address is not a valid EVM address.') + } + return activeWallet + } + + function clearLastTransaction() { + setLastHash('') + setLastExplorerUrl('') + } + + return ( +
+
+

WalletKit Dollar

+ {WALLET_KIT_DOLLAR.symbol} +
+
+ Your Balance + {formatTokenBalance(balance)} +
+ +
+ + + +
+ + {lastHash && ( +
+

+ Transaction hash + {lastHash} +

+ {lastExplorerUrl && ( + + View on explorer + + )} +
+ )} + {status && {status}} +
+ ) +} + +function transactionExplorerUrl(txnHash: string): string { + return `${Networks.amoy.explorerUrl}/tx/${txnHash}` +} + +function formatTokenBalance(value: bigint | null): string { + if (value === null) return 'Loading...' + return `${formatUnits(value, WALLET_KIT_DOLLAR.decimals)} ${WALLET_KIT_DOLLAR.symbol}` +} diff --git a/examples/react/src/config.ts b/examples/react/src/config.ts new file mode 100644 index 0000000..ea003e9 --- /dev/null +++ b/examples/react/src/config.ts @@ -0,0 +1,9 @@ +export const PUBLIC_API_KEY = requiredEnv('VITE_OMS_PUBLIC_API_KEY', import.meta.env.VITE_OMS_PUBLIC_API_KEY) +export const PROJECT_ID = requiredEnv('VITE_OMS_PROJECT_ID', import.meta.env.VITE_OMS_PROJECT_ID) + +function requiredEnv(name: string, value: string | undefined): string { + if (!value) { + throw new Error(`Missing ${name}. Copy examples/react/.env.example to examples/react/.env.local and set it.`) + } + return value +} diff --git a/examples/react/src/main.tsx b/examples/react/src/main.tsx index b2bcb21..2097bb6 100644 --- a/examples/react/src/main.tsx +++ b/examples/react/src/main.tsx @@ -13,6 +13,8 @@ import { type WalletActivationResult, } from '@0xsequence/typescript-sdk' import './styles.css' +import { PROJECT_ID, PUBLIC_API_KEY } from './config' +import { WalletKitDollarExample } from './WalletKitDollarExample' type Step = 'email' | 'code' | 'wallet-selection' | 'wallet' type FeeSelectionController = { @@ -22,17 +24,8 @@ type FeeSelectionController = { const DEFAULT_MESSAGE = 'test' const DEFAULT_TX_TO = '0xE5E8B483FfC05967FcFed58cc98D053265af6D99' -const PUBLIC_API_KEY = requiredEnv('VITE_OMS_PUBLIC_API_KEY', import.meta.env.VITE_OMS_PUBLIC_API_KEY) -const PROJECT_ID = requiredEnv('VITE_OMS_PROJECT_ID', import.meta.env.VITE_OMS_PROJECT_ID) const MANUAL_WALLET_SELECTION_KEY = 'oms-demo-manual-wallet-selection' -function requiredEnv(name: string, value: string | undefined): string { - if (!value) { - throw new Error(`Missing ${name}. Copy examples/react/.env.example to examples/react/.env.local and set it.`) - } - return value -} - function App() { const [step, setStep] = useState('email') const [email, setEmail] = useState('') @@ -475,7 +468,12 @@ function App() { - {lastSignature && {lastSignature}} + {lastSignature && ( +

+ Signature + {lastSignature} +

+ )}
@@ -524,7 +522,10 @@ function App() { )} {lastTransactionHash && (
- {lastTransactionHash} +

+ Transaction hash + {lastTransactionHash} +

{lastTransactionExplorerUrl && ( + {selectedNetwork.id === Networks.amoy.id && ( +
+ ERC20 example +
+ +
+
+ )} + diff --git a/examples/react/src/styles.css b/examples/react/src/styles.css index 2e959a8..bd854e2 100644 --- a/examples/react/src/styles.css +++ b/examples/react/src/styles.css @@ -158,6 +158,74 @@ button:disabled { background: #a7b1c2; } +.burn-button { + position: relative; + overflow: hidden; + isolation: isolate; + background: #1d4ed8; + transition: + background-color 420ms ease, + box-shadow 420ms ease; +} + +.burn-button::before { + content: ""; + position: absolute; + inset: 0; + z-index: 0; + background: + radial-gradient(circle at 18% 50%, rgb(255 218 121 / 34%), transparent 24%), + linear-gradient(110deg, #7f1d1d 0%, #dc2626 38%, #f97316 72%, #facc15 100%); + transform: translateX(-108%) skewX(-10deg); + transform-origin: left center; + transition: transform 520ms cubic-bezier(0.2, 0.85, 0.22, 1); +} + +.burn-button::after { + content: ""; + position: absolute; + inset: 0; + z-index: 0; + background: linear-gradient(105deg, transparent 0 38%, rgb(255 255 255 / 24%) 48%, transparent 60% 100%); + opacity: 0; + transform: translateX(-120%); + transition: + opacity 220ms ease, + transform 620ms cubic-bezier(0.2, 0.85, 0.22, 1); +} + +.burn-button .button-label { + position: relative; + z-index: 1; +} + +.burn-button-active::before { + transform: translateX(0); +} + +.burn-button-active::after { + opacity: 1; + transform: translateX(120%); + transition-delay: 120ms; +} + +.burn-button-active { + box-shadow: 0 8px 18px rgb(180 35 24 / 22%); +} + +.burn-button:disabled { + box-shadow: none; +} + +.burn-button:disabled::before, +.burn-button:disabled::after { + opacity: 0; +} + +.burn-button:disabled::before { + transform: translateX(-102%); +} + .actions { display: grid; grid-template-columns: 1fr auto; @@ -230,6 +298,33 @@ button:disabled { padding: 0 16px 16px; } +.example-block { + display: grid; + gap: 12px; +} + +.balance-panel { + display: grid; + gap: 4px; + padding: 12px; + border: 1px solid #e1e6ee; + border-radius: 6px; + background: #ffffff; +} + +.balance-panel span { + color: #5f6c7b; + font-size: 12px; + font-weight: 700; +} + +.balance-panel strong { + color: #1f2937; + font-size: 18px; + line-height: 1.25; + overflow-wrap: anywhere; +} + .network-tool { gap: 8px; } @@ -409,16 +504,29 @@ output { } .result { + display: grid; + gap: 4px; min-width: 0; max-height: 120px; overflow: auto; overflow-wrap: anywhere; + margin: 0; padding: 10px 12px; border-radius: 6px; background: #101828; color: #dbeafe; } +.result-label { + color: #98a2b3; + font-size: 12px; + font-weight: 800; +} + +.result-value { + color: #7dd3fc; +} + .result-block { display: grid; gap: 8px; diff --git a/examples/react/src/walletKitDollarContract.ts b/examples/react/src/walletKitDollarContract.ts new file mode 100644 index 0000000..03c7ef6 --- /dev/null +++ b/examples/react/src/walletKitDollarContract.ts @@ -0,0 +1,7 @@ +import { parseAbi } from 'viem' + +export const walletKitDollarAbi = parseAbi([ + 'function balanceOf(address account) view returns (uint256)', + 'function mint(address to, uint256 amount) returns (bool)', + 'function transfer(address to, uint256 amount) returns (bool)', +]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8bae33..cb6d695 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: react-dom: specifier: ^19.2.5 version: 19.2.5(react@19.2.5) + viem: + specifier: ^2.48.4 + version: 2.48.4(typescript@5.9.3) devDependencies: '@types/react': specifier: ^19.2.14 From 33e3cffbb87d9133f81149b27cbe7945b5404ff4 Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Fri, 22 May 2026 15:42:52 +0300 Subject: [PATCH 3/4] Clarify transaction result labels --- examples/react/src/WalletKitDollarExample.tsx | 2 +- examples/react/src/main.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/react/src/WalletKitDollarExample.tsx b/examples/react/src/WalletKitDollarExample.tsx index 6ec63a7..f49ef99 100644 --- a/examples/react/src/WalletKitDollarExample.tsx +++ b/examples/react/src/WalletKitDollarExample.tsx @@ -194,7 +194,7 @@ export function WalletKitDollarExample() { {lastHash && (

- Transaction hash + {lastExplorerUrl ? 'Transaction hash' : 'Transaction ID'} {lastHash}

{lastExplorerUrl && ( diff --git a/examples/react/src/main.tsx b/examples/react/src/main.tsx index 2097bb6..8b00f56 100644 --- a/examples/react/src/main.tsx +++ b/examples/react/src/main.tsx @@ -523,7 +523,9 @@ function App() { {lastTransactionHash && (

- Transaction hash + + {lastTransactionExplorerUrl ? 'Transaction hash' : 'Transaction ID'} + {lastTransactionHash}

{lastTransactionExplorerUrl && ( From bcb64b6f96b9ea8bf4b803e59d9a2a54bea65180 Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Fri, 22 May 2026 15:52:12 +0300 Subject: [PATCH 4/4] Reuse React demo OMS client --- examples/react/src/WalletKitDollarExample.tsx | 10 +++------- examples/react/src/main.tsx | 11 ++--------- examples/react/src/omsClient.ts | 7 +++++++ 3 files changed, 12 insertions(+), 16 deletions(-) create mode 100644 examples/react/src/omsClient.ts diff --git a/examples/react/src/WalletKitDollarExample.tsx b/examples/react/src/WalletKitDollarExample.tsx index f49ef99..fe53a01 100644 --- a/examples/react/src/WalletKitDollarExample.tsx +++ b/examples/react/src/WalletKitDollarExample.tsx @@ -1,8 +1,8 @@ import { useEffect, useMemo, useState } from 'react' -import { Networks, OMSClient } from '@0xsequence/typescript-sdk' +import { Networks } from '@0xsequence/typescript-sdk' import { createPublicClient, formatUnits, http, isAddress, parseUnits } from 'viem' import type { Address } from 'viem' -import { PUBLIC_API_KEY, PROJECT_ID } from './config' +import { oms } from './omsClient' import { walletKitDollarAbi } from './walletKitDollarContract' const AMOY_RPC_URL = 'https://rpc-amoy.polygon.technology' @@ -26,10 +26,6 @@ export function WalletKitDollarExample() { const [status, setStatus] = useState('') const [isBusy, setIsBusy] = useState(false) - const oms = useMemo(() => new OMSClient({ - publicApiKey: PUBLIC_API_KEY, - projectId: PROJECT_ID, - }), []) const publicClient = useMemo(() => createPublicClient({ transport: http(AMOY_RPC_URL), }), []) @@ -40,7 +36,7 @@ export function WalletKitDollarExample() { if (isAddress(restoredAddress)) { void refreshBalance(restoredAddress) } - }, [oms]) + }, []) useEffect(() => { if (sendToBurn) { diff --git a/examples/react/src/main.tsx b/examples/react/src/main.tsx index 8b00f56..49d0b7d 100644 --- a/examples/react/src/main.tsx +++ b/examples/react/src/main.tsx @@ -1,8 +1,7 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { createRoot } from 'react-dom/client' import { Networks, - OMSClient, supportedNetworks, type FeeOptionSelection, type FeeOptionWithBalance, @@ -13,7 +12,7 @@ import { type WalletActivationResult, } from '@0xsequence/typescript-sdk' import './styles.css' -import { PROJECT_ID, PUBLIC_API_KEY } from './config' +import { oms } from './omsClient' import { WalletKitDollarExample } from './WalletKitDollarExample' type Step = 'email' | 'code' | 'wallet-selection' | 'wallet' @@ -49,12 +48,6 @@ function App() { const oidcCallbackStarted = useRef(false) const feeSelection = useRef(null) - const oms = useMemo(() => { - return new OMSClient({ - publicApiKey: PUBLIC_API_KEY, - projectId: PROJECT_ID, - }) - }, []) const selectedNetwork = supportedNetworks.find(network => network.id === selectedNetworkId) ?? Networks.amoy const session = oms.wallet.session diff --git a/examples/react/src/omsClient.ts b/examples/react/src/omsClient.ts new file mode 100644 index 0000000..26b9fe0 --- /dev/null +++ b/examples/react/src/omsClient.ts @@ -0,0 +1,7 @@ +import { OMSClient } from '@0xsequence/typescript-sdk' +import { PROJECT_ID, PUBLIC_API_KEY } from './config' + +export const oms = new OMSClient({ + publicApiKey: PUBLIC_API_KEY, + projectId: PROJECT_ID, +})