diff --git a/.changeset/bundler-cadence-json.md b/.changeset/bundler-cadence-json.md new file mode 100644 index 000000000..20878a49a --- /dev/null +++ b/.changeset/bundler-cadence-json.md @@ -0,0 +1,6 @@ +--- +"@onflow/fcl-bundle": minor +--- + +Add support for importing `.cdc` (Cadence) and `.json` files in bundled packages. Cadence files are imported as raw strings, enabling packages to include Cadence scripts as separate files rather than inline template strings. + diff --git a/package-lock.json b/package-lock.json index d153e940e..6076136db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8065,6 +8065,10 @@ "version": "0.0.11", "license": "Apache-2.0" }, + "node_modules/@onflow/payments": { + "resolved": "packages/payments", + "link": true + }, "node_modules/@onflow/protobuf": { "resolved": "packages/protobuf", "link": true @@ -10241,6 +10245,26 @@ } } }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@rollup/plugin-node-resolve": { "version": "15.3.0", "license": "MIT", @@ -11234,6 +11258,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.1.2", "license": "MIT", @@ -18215,7 +18249,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -28131,7 +28165,7 @@ }, "node_modules/safer-buffer": { "version": "2.1.2", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/sax": { @@ -32088,16 +32122,16 @@ }, "packages/fcl": { "name": "@onflow/fcl", - "version": "1.20.5", + "version": "1.20.6", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.25.7", "@onflow/config": "1.6.3", - "@onflow/fcl-core": "1.22.2", - "@onflow/fcl-wc": "6.0.10", + "@onflow/fcl-core": "1.22.3", + "@onflow/fcl-wc": "6.0.11", "@onflow/interaction": "0.0.11", "@onflow/rlp": "1.2.4", - "@onflow/sdk": "1.11.2", + "@onflow/sdk": "1.12.0", "@onflow/types": "1.5.0", "@onflow/util-actor": "1.3.5", "@onflow/util-address": "1.2.4", @@ -32137,6 +32171,7 @@ "@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-image": "^3.0.3", + "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.3.0", "@rollup/plugin-replace": "^6.0.1", "@rollup/plugin-terser": "^0.4.4", @@ -32163,7 +32198,7 @@ }, "packages/fcl-core": { "name": "@onflow/fcl-core", - "version": "1.22.2", + "version": "1.22.3", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.25.7", @@ -32171,8 +32206,8 @@ "@onflow/config": "1.6.3", "@onflow/interaction": "0.0.11", "@onflow/rlp": "1.2.4", - "@onflow/sdk": "1.11.2", - "@onflow/transport-http": "1.14.0", + "@onflow/sdk": "1.12.0", + "@onflow/transport-http": "1.15.0", "@onflow/types": "1.5.0", "@onflow/util-actor": "1.3.5", "@onflow/util-address": "1.2.4", @@ -32232,14 +32267,14 @@ }, "packages/fcl-ethereum-provider": { "name": "@onflow/fcl-ethereum-provider", - "version": "0.0.12", + "version": "0.0.13", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.25.7", "@ethersproject/bytes": "^5.7.0", "@ethersproject/hash": "^5.7.0", "@noble/hashes": "^1.7.1", - "@onflow/fcl-wc": "6.0.10", + "@onflow/fcl-wc": "6.0.11", "@onflow/rlp": "^1.2.4", "@walletconnect/ethereum-provider": "^2.20.2", "@walletconnect/jsonrpc-http-connection": "^1.0.8", @@ -32260,7 +32295,7 @@ "jest": "^29.7.0" }, "peerDependencies": { - "@onflow/fcl": "1.20.5" + "@onflow/fcl": "1.20.6" } }, "packages/fcl-ethereum-provider/node_modules/@scure/bip32": { @@ -32488,14 +32523,14 @@ }, "packages/fcl-rainbowkit-adapter": { "name": "@onflow/fcl-rainbowkit-adapter", - "version": "0.2.8", + "version": "0.2.9", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.25.7", "@ethersproject/bytes": "^5.7.0", "@ethersproject/hash": "^5.7.0", - "@onflow/fcl-ethereum-provider": "0.0.12", - "@onflow/fcl-wagmi-adapter": "0.0.12", + "@onflow/fcl-ethereum-provider": "0.0.13", + "@onflow/fcl-wagmi-adapter": "0.0.13", "@onflow/rlp": "^1.2.4", "@wagmi/core": "^2.16.3", "mipd": "^0.0.7", @@ -32516,7 +32551,7 @@ "jest": "^29.7.0" }, "peerDependencies": { - "@onflow/fcl": "1.20.5", + "@onflow/fcl": "1.20.6", "@rainbow-me/rainbowkit": "^2.2.3", "react": "17.x || 18.x || 19.x" } @@ -32533,15 +32568,15 @@ }, "packages/fcl-react-native": { "name": "@onflow/fcl-react-native", - "version": "1.13.5", + "version": "1.13.6", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.25.7", "@onflow/config": "1.6.3", - "@onflow/fcl-core": "1.22.2", + "@onflow/fcl-core": "1.22.3", "@onflow/interaction": "0.0.11", "@onflow/rlp": "1.2.4", - "@onflow/sdk": "1.11.2", + "@onflow/sdk": "1.12.0", "@onflow/types": "1.5.0", "@onflow/util-actor": "1.3.5", "@onflow/util-address": "1.2.4", @@ -32592,13 +32627,13 @@ }, "packages/fcl-wagmi-adapter": { "name": "@onflow/fcl-wagmi-adapter", - "version": "0.0.12", + "version": "0.0.13", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.25.7", "@ethersproject/bytes": "^5.7.0", "@ethersproject/hash": "^5.7.0", - "@onflow/fcl-ethereum-provider": "0.0.12", + "@onflow/fcl-ethereum-provider": "0.0.13", "@onflow/rlp": "^1.2.4", "viem": "^2.22.21" }, @@ -32614,13 +32649,13 @@ "jest": "^29.7.0" }, "peerDependencies": { - "@onflow/fcl": "1.20.5", + "@onflow/fcl": "1.20.6", "@wagmi/core": "^2.16.3" } }, "packages/fcl-wc": { "name": "@onflow/fcl-wc", - "version": "6.0.10", + "version": "6.0.11", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.25.7", @@ -32647,7 +32682,7 @@ "jest-preset-preact": "^4.1.1" }, "peerDependencies": { - "@onflow/fcl-core": "1.22.2" + "@onflow/fcl-core": "1.22.3" } }, "packages/fcl-wc/node_modules/@scure/bip32": { @@ -32875,6 +32910,56 @@ "node": ">=4.2.0" } }, + "packages/payments": { + "name": "@onflow/payments", + "version": "0.0.1", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.25.7" + }, + "devDependencies": { + "@babel/preset-typescript": "^7.25.7", + "@onflow/fcl": "*", + "@onflow/fcl-bundle": "1.7.1", + "@types/jest": "^29.5.13", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint": "^8.57.1", + "eslint-plugin-jsdoc": "^46.10.1", + "jest": "^29.7.0" + }, + "peerDependencies": { + "@onflow/fcl": ">=1.0.0" + }, + "peerDependenciesMeta": { + "@onflow/fcl": { + "optional": true + } + } + }, + "packages/payments-provider-relay": { + "name": "@onflow/payments-provider-relay", + "version": "0.0.1", + "extraneous": true, + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@onflow/payments": "0.0.1" + }, + "devDependencies": { + "@babel/preset-typescript": "^7.25.7", + "@onflow/fcl-bundle": "1.7.1", + "@types/jest": "^29.5.13", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint": "^8.57.1", + "eslint-plugin-jsdoc": "^46.10.1", + "jest": "^29.7.0" + }, + "peerDependencies": { + "@onflow/payments": "0.0.1" + } + }, "packages/protobuf": { "name": "@onflow/protobuf", "version": "1.3.2", @@ -32932,6 +33017,7 @@ "@headlessui/react": "^2.2.2", "@tanstack/react-query": "^5.67.3", "@testing-library/react": "^16.2.0", + "qrcode": "^1.5.3", "tailwind-merge": "^3.3.1" }, "devDependencies": { @@ -32942,6 +33028,7 @@ "@onflow/typedefs": "^1.8.0", "@testing-library/dom": "^10.4.0", "@types/jest": "^29.5.13", + "@types/qrcode": "^1.5.6", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@typescript-eslint/eslint-plugin": "^6.21.0", @@ -32982,13 +33069,13 @@ }, "packages/sdk": { "name": "@onflow/sdk", - "version": "1.11.2", + "version": "1.12.0", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.25.7", "@onflow/config": "1.6.3", "@onflow/rlp": "1.2.4", - "@onflow/transport-http": "1.14.0", + "@onflow/transport-http": "1.15.0", "@onflow/typedefs": "1.8.0", "@onflow/types": "1.5.0", "@onflow/util-actor": "1.3.5", @@ -33038,13 +33125,13 @@ }, "devDependencies": { "@onflow/fcl-bundle": "1.7.1", - "@onflow/sdk": "1.11.2", + "@onflow/sdk": "1.12.0", "jest": "^29.7.0" } }, "packages/transport-http": { "name": "@onflow/transport-http", - "version": "1.14.0", + "version": "1.15.0", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.25.7", @@ -33062,7 +33149,7 @@ "devDependencies": { "@onflow/fcl-bundle": "1.7.1", "@onflow/rlp": "1.2.4", - "@onflow/sdk": "1.11.2", + "@onflow/sdk": "1.12.0", "@onflow/types": "1.5.0", "jest": "^29.7.0", "jest-websocket-mock": "^2.5.0", diff --git a/packages/demo/src/components/component-cards/fund-card.tsx b/packages/demo/src/components/component-cards/fund-card.tsx new file mode 100644 index 000000000..866526b64 --- /dev/null +++ b/packages/demo/src/components/component-cards/fund-card.tsx @@ -0,0 +1,162 @@ +import {useState} from "react" +import {Fund, useFlowChainId} from "@onflow/react-sdk" +import {useDarkMode} from "../flow-provider-wrapper" +import {DemoCard, type PropDefinition} from "../ui/demo-card" +import {PlusGridIcon} from "../ui/plus-grid" + +const IMPLEMENTATION_CODE = `import { Fund } from "@onflow/react-sdk" + +// Basic usage - opens modal with funding options + + +// With custom variant +` + +const PROPS: PropDefinition[] = [ + { + name: "variant", + type: '"primary" | "secondary" | "outline" | "link"', + required: false, + description: "The visual style variant of the fund button", + defaultValue: '"primary"', + }, +] + +export function FundCard() { + const {darkMode} = useDarkMode() + const {data: chainId, isLoading} = useFlowChainId() + const [variant, setVariant] = useState< + "primary" | "secondary" | "outline" | "link" + >("primary") + + return ( + +
+
+
+ +

+ Unified Interface +

+

+ Single component for all funding +

+
+ +
+ +

+ Cross-VM Support +

+

+ Flow EVM & Cadence accounts +

+
+ +
+ +

+ Streamlined Flow +

+

+ Simple on-ramp experience +

+
+
+ +
+
+ {isLoading ? ( +
+
+
+ ) : ( +
+ +
+ )} +
+ +
+
+ +
+ {(["primary", "secondary", "outline", "link"] as const).map( + variantOption => ( + + ) + )} +
+
+
+
+
+
+ ) +} diff --git a/packages/demo/src/components/content-section.tsx b/packages/demo/src/components/content-section.tsx index 79f25c073..9d45959fb 100644 --- a/packages/demo/src/components/content-section.tsx +++ b/packages/demo/src/components/content-section.tsx @@ -30,6 +30,7 @@ import {ThemingCard} from "./advanced-cards/theming-card" // Import component cards import {ConnectCard} from "./component-cards/connect-card" import {ProfileCard} from "./component-cards/profile-card" +import {FundCard} from "./component-cards/fund-card" import {TransactionButtonCard} from "./component-cards/transaction-button-card" import {TransactionDialogCard} from "./component-cards/transaction-dialog-card" import {TransactionLinkCard} from "./component-cards/transaction-link-card" @@ -70,6 +71,7 @@ export function ContentSection() { + diff --git a/packages/demo/src/components/content-sidebar.tsx b/packages/demo/src/components/content-sidebar.tsx index 293307a4a..fc05594c7 100644 --- a/packages/demo/src/components/content-sidebar.tsx +++ b/packages/demo/src/components/content-sidebar.tsx @@ -30,6 +30,12 @@ const sidebarItems: SidebarItem[] = [ category: "components", description: "Standalone profile display", }, + { + id: "fund", + label: "Fund", + category: "components", + description: "Account funding component", + }, { id: "transactionbutton", label: "Transaction Button", diff --git a/packages/fcl-bundle/package.json b/packages/fcl-bundle/package.json index b20105627..b3c81a763 100644 --- a/packages/fcl-bundle/package.json +++ b/packages/fcl-bundle/package.json @@ -19,6 +19,7 @@ "@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-image": "^3.0.3", + "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.3.0", "@rollup/plugin-replace": "^6.0.1", "@rollup/plugin-terser": "^0.4.4", diff --git a/packages/fcl-bundle/src/build/get-input-options.js b/packages/fcl-bundle/src/build/get-input-options.js index 5f44d51da..64e64b532 100644 --- a/packages/fcl-bundle/src/build/get-input-options.js +++ b/packages/fcl-bundle/src/build/get-input-options.js @@ -12,9 +12,11 @@ const terser = require("@rollup/plugin-terser") const typescript = require("rollup-plugin-typescript2") const postcss = require("rollup-plugin-postcss") const imagePlugin = require("@rollup/plugin-image") +const json = require("@rollup/plugin-json") const {DEFAULT_EXTENSIONS} = require("@babel/core") const {getPackageRoot} = require("../util") const {preserveDirective} = require("rollup-preserve-directives") +const cadence = require("../plugins/cadence") const SUPPRESSED_WARNING_CODES = [ "MISSING_GLOBAL_NAME", @@ -68,6 +70,7 @@ module.exports = function getInputOptions(package, build) { ".mts", ".cts", ".svg", + ".cdc", ]) const postcssConfigPath = path.resolve(getPackageRoot(), "postcss.config.js") @@ -81,6 +84,8 @@ module.exports = function getInputOptions(package, build) { }, plugins: [ preserveDirective(), + cadence(), + json(), imagePlugin(), nodeResolve({ browser: true, diff --git a/packages/fcl-bundle/src/plugins/cadence.js b/packages/fcl-bundle/src/plugins/cadence.js new file mode 100644 index 000000000..f5defca6b --- /dev/null +++ b/packages/fcl-bundle/src/plugins/cadence.js @@ -0,0 +1,16 @@ +/** + * Rollup plugin to import .cdc (Cadence) files as raw strings + */ +module.exports = function cadence() { + return { + name: "cadence", + transform(code, id) { + if (!id.endsWith(".cdc")) return null + + return { + code: `export default ${JSON.stringify(code)};`, + map: null, + } + }, + } +} diff --git a/packages/fcl/README_SCOPED_CONFIG.md b/packages/fcl/README_SCOPED_CONFIG.md new file mode 100644 index 000000000..485f27e1d --- /dev/null +++ b/packages/fcl/README_SCOPED_CONFIG.md @@ -0,0 +1,526 @@ +# Scoped Configuration in FCL + +## Overview + +FCL now supports **scoped configuration** through the `createFlowClient()` API. This allows you to create isolated Flow client instances, each with their own configuration, storage, and state. This is a modern alternative to the traditional global configuration approach. + +### When to Use Scoped Config + +- **Multi-tenant applications**: Different users or organizations connecting to different Flow networks +- **Server-side rendering**: Each request needs its own isolated client instance +- **Testing**: Create mock clients with specific configurations +- **Multi-network support**: Connect to mainnet and testnet simultaneously +- **Modern applications**: Better isolation and type safety for new projects + +## Quick Start + +```typescript +import { createFlowClient } from "@onflow/fcl" + +// Create a Flow client with scoped configuration +const flowClient = createFlowClient({ + accessNodeUrl: "https://rest-testnet.onflow.org", + flowNetwork: "testnet", + appDetailTitle: "My Flow App", + appDetailIcon: "https://example.com/icon.png", + discoveryWallet: "https://fcl-discovery.onflow.org/testnet/authn", +}) + +// Authenticate a user +await flowClient.authenticate() + +// Query the blockchain +const result = await flowClient.query({ + cadence: `access(all) fun main(): UFix64 { return getCurrentBlock().timestamp }`, +}) + +// Send a transaction +const txId = await flowClient.mutate({ + cadence: ` + transaction { + execute { + log("Hello, Flow!") + } + } + `, +}) + +// Wait for transaction to be sealed +const status = await flowClient.tx(txId).onceSealed() +``` + +## Benefits of Scoped Config + +| Feature | Global Config | Scoped Config | +|---------|--------------|---------------| +| **Isolation** | Single global state | Per-client instance, fully isolated | +| **Multi-tenancy** | Not supported | Multiple clients with different configs | +| **Type Safety** | Runtime strings | TypeScript interfaces at compile time | +| **Testing** | Difficult to isolate | Easy to mock and test | +| **SSR** | Shared state issues | Each request gets own instance | +| **Concurrency** | Actor-based message queue | Direct synchronous access | + +## Configuration Options + +The `createFlowClient()` function accepts a configuration object with the following options: + +### Required Options + +```typescript +{ + accessNodeUrl: string // Flow Access Node API endpoint +} +``` + +### Network Configuration + +```typescript +{ + accessNodeUrl: string // Required - Flow Access Node API endpoint + flowNetwork: "mainnet" | "testnet" | "emulator" | string // Network identifier + flowJson?: FlowJSON // Optional - Contract addresses from flow.json +} +``` + +### Wallet & Discovery Configuration + +```typescript +{ + discoveryWallet?: string // Discovery service URL for wallet connection + discoveryWalletMethod?: string // Connection method (e.g., "IFRAME/RPC") + discoveryAuthnEndpoint?: string // Custom authentication endpoint + discoveryAuthnInclude?: string[] // Wallet providers to include +} +``` + +### WalletConnect Configuration + +```typescript +{ + walletconnectProjectId?: string // WalletConnect Cloud project ID + walletconnectDisableNotifications?: boolean // Disable WalletConnect notifications +} +``` + +### App Details + +These details are displayed to users during wallet connection: + +```typescript +{ + appDetailTitle?: string // Your app's name + appDetailIcon?: string // URL to your app's icon + appDetailDescription?: string // Short description of your app + appDetailUrl?: string // Your app's website URL +} +``` + +### Advanced Configuration + +```typescript +{ + storage?: Storage // Custom storage provider (defaults to localStorage) + transport?: Transport // Custom transport layer for network requests + computeLimit?: number // Default gas limit for transactions (default: 9999) + customResolver?: Resolver // Custom address resolver + customDecoders?: Decoder[] // Custom Cadence type decoders + serviceOpenIdScopes?: string[] // OpenID scopes for authentication +} +``` + +## Usage Examples + +### Basic Client Usage + +```typescript +import { createFlowClient } from "@onflow/fcl" + +const flowClient = createFlowClient({ + accessNodeUrl: "https://rest-testnet.onflow.org", + flowNetwork: "testnet", +}) + +// Get current user +const currentUser = flowClient.currentUser() + +// Subscribe to authentication state changes +currentUser.subscribe((user) => { + console.log("Current user:", user) +}) + +// Authenticate +await flowClient.authenticate() + +// Get user info +const user = currentUser.snapshot() +console.log("User address:", user.addr) +``` + +### Querying the Blockchain + +```typescript +// Simple query +const result = await flowClient.query({ + cadence: ` + access(all) fun main(): String { + return "Hello, Flow!" + } + `, +}) + +// Query with arguments +const balance = await flowClient.query({ + cadence: ` + access(all) fun main(address: Address): UFix64 { + return getAccount(address).balance + } + `, + args: (arg, t) => [arg("0x1234567890abcdef", t.Address)], +}) + +// Query with custom limit +const blockData = await flowClient.query({ + cadence: `access(all) fun main(): UInt64 { return getCurrentBlock().height }`, + limit: 1000, +}) +``` + +### Sending Transactions + +```typescript +// Send a transaction +const txId = await flowClient.mutate({ + cadence: ` + transaction(greeting: String) { + execute { + log(greeting) + } + } + `, + args: (arg, t) => [arg("Hello, Flow!", t.String)], + limit: 9999, +}) + +// Monitor transaction status +const unsub = flowClient.tx(txId).subscribe((txStatus) => { + console.log("Status:", txStatus.status) + + if (txStatus.status === 4) { + console.log("Transaction sealed!") + unsub() // Unsubscribe + } +}) + +// Or wait for specific status +await flowClient.tx(txId).onceSealed() +``` + +### Multiple Isolated Clients + +You can create multiple Flow clients, each with their own configuration: + +```typescript +import { createFlowClient } from "@onflow/fcl" + +// Mainnet client +const mainnetClient = createFlowClient({ + accessNodeUrl: "https://rest-mainnet.onflow.org", + flowNetwork: "mainnet", + appDetailTitle: "My App (Mainnet)", +}) + +// Testnet client +const testnetClient = createFlowClient({ + accessNodeUrl: "https://rest-testnet.onflow.org", + flowNetwork: "testnet", + appDetailTitle: "My App (Testnet)", +}) + +// Query both networks simultaneously +const [mainnetBlock, testnetBlock] = await Promise.all([ + mainnetClient.query({ + cadence: `access(all) fun main(): UInt64 { return getCurrentBlock().height }`, + }), + testnetClient.query({ + cadence: `access(all) fun main(): UInt64 { return getCurrentBlock().height }`, + }), +]) + +console.log("Mainnet block:", mainnetBlock) +console.log("Testnet block:", testnetBlock) +``` + +### Using with flow.json + +If you have a `flow.json` file with contract addresses, you can pass it to the client: + +```typescript +import flowJsonData from "./flow.json" + +const flowClient = createFlowClient({ + accessNodeUrl: "https://rest-testnet.onflow.org", + flowNetwork: "testnet", + flowJson: flowJsonData, +}) + +// Contract addresses will be automatically resolved +// based on the network configuration +``` + +### Custom Storage Provider + +By default, FCL uses `localStorage` for web environments. You can provide a custom storage implementation: + +```typescript +const customStorage = { + async getItem(key: string): Promise { + // Your custom storage logic + return await myDatabase.get(key) + }, + async setItem(key: string, value: string): Promise { + // Your custom storage logic + await myDatabase.set(key, value) + }, + async removeItem(key: string): Promise { + // Your custom storage logic + await myDatabase.delete(key) + }, +} + +const flowClient = createFlowClient({ + accessNodeUrl: "https://rest-testnet.onflow.org", + storage: customStorage, +}) +``` + +## Config Key Mapping + +When you pass typed parameters to `createFlowClient()`, they are internally mapped to config keys. Here's the mapping: + +| Typed Parameter | Internal Config Key | +|----------------|---------------------| +| `accessNodeUrl` | `accessNode.api` | +| `flowNetwork` | `flow.network` | +| `computeLimit` | `fcl.limit` | +| `discoveryWallet` | `discovery.wallet` | +| `discoveryWalletMethod` | `discovery.wallet.method` | +| `discoveryAuthnEndpoint` | `discovery.authn.endpoint` | +| `discoveryAuthnInclude` | `discovery.authn.include` | +| `appDetailTitle` | `app.detail.title` | +| `appDetailIcon` | `app.detail.icon` | +| `appDetailDescription` | `app.detail.description` | +| `appDetailUrl` | `app.detail.url` | +| `serviceOpenIdScopes` | `service.OpenID.scopes` | + +## Config Service API + +Each Flow client has an internal config service that manages its configuration. While typically you don't need to access this directly, it's available for advanced use cases. + +The config service provides the following methods: + +```typescript +interface ConfigService { + // Get a configuration value + get(key: string, defaultValue?: any): Promise + + // Set a configuration value + put(key: string, value: any): Promise | ConfigService + + // Update a configuration value with a function + update(key: string, updateFn: (oldValue: any) => any): Promise | ConfigService + + // Delete a configuration value + delete(key: string): Promise | ConfigService + + // Get all config values matching a pattern + where(pattern: RegExp): Promise> + + // Get the first defined value from a list of keys + first(keys: string[], defaultValue?: any): Promise | any + + // Subscribe to configuration changes + subscribe(callback: (config: Record | null) => void): () => void + + // Get all configuration values + all(): Promise> +} +``` + +## Migration from Global Config + +If you're currently using the global config approach, here's how to migrate to scoped config: + +### Before (Global Config) + +```typescript +import * as fcl from "@onflow/fcl" + +// Configure globally +fcl.config() + .put("accessNode.api", "https://rest-testnet.onflow.org") + .put("discovery.wallet", "https://fcl-discovery.onflow.org/testnet/authn") + .put("app.detail.title", "My App") + +// Use global functions +await fcl.authenticate() +const result = await fcl.query({ cadence: script }) +``` + +### After (Scoped Config) + +```typescript +import { createFlowClient } from "@onflow/fcl" + +// Create scoped client +const flowClient = createFlowClient({ + accessNodeUrl: "https://rest-testnet.onflow.org", + discoveryWallet: "https://fcl-discovery.onflow.org/testnet/authn", + appDetailTitle: "My App", +}) + +// Use client methods +await flowClient.authenticate() +const result = await flowClient.query({ cadence: script }) +``` + +### Key Differences + +1. **Import**: Import `createFlowClient` instead of using the global `fcl` namespace +2. **Configuration**: Pass all config as an object to `createFlowClient()` instead of using `fcl.config().put()` +3. **Method calls**: Use methods on the `flowClient` instance instead of global functions +4. **Isolation**: Each client instance is isolated; no shared global state + +### Gradual Migration + +Both approaches can coexist in the same application, allowing for gradual migration: + +```typescript +import * as fcl from "@onflow/fcl" +import { createFlowClient } from "@onflow/fcl" + +// Existing global config still works +fcl.config().put("accessNode.api", "https://rest-mainnet.onflow.org") + +// New scoped client for a specific feature +const testnetClient = createFlowClient({ + accessNodeUrl: "https://rest-testnet.onflow.org", +}) + +// Both work independently +await fcl.query({ cadence: mainnetScript }) // Uses global config +await testnetClient.query({ cadence: testnetScript }) // Uses scoped config +``` + +## Important Notes + +1. **Storage**: By default, scoped config uses `localStorage` for web environments. For Node.js or custom storage needs, provide a custom storage implementation. + +2. **WalletConnect**: The WalletConnect plugin currently loads into a global registry. This may affect isolation when using multiple clients with different WalletConnect configurations. + +3. **Backward Compatibility**: The global config approach is still supported and continues to work. Scoped config is recommended for new applications. + +4. **Type Safety**: Scoped config provides better type safety through TypeScript interfaces, catching configuration errors at compile time rather than runtime. + +5. **Testing**: Scoped config makes testing easier by allowing you to create isolated client instances with specific configurations for each test. + +6. **Performance**: Scoped config uses a simple `Map` for storage, providing synchronous access without the message-passing overhead of the global actor-based system. + +## Complete Example + +Here's a complete example demonstrating a real-world application: + +```typescript +import { createFlowClient } from "@onflow/fcl" + +// Create the Flow client +const flowClient = createFlowClient({ + accessNodeUrl: "https://rest-testnet.onflow.org", + flowNetwork: "testnet", + discoveryWallet: "https://fcl-discovery.onflow.org/testnet/authn", + walletconnectProjectId: "your-project-id", + appDetailTitle: "My Flow App", + appDetailIcon: "https://myapp.com/icon.png", + appDetailDescription: "A decentralized application on Flow", + appDetailUrl: "https://myapp.com", + computeLimit: 9999, +}) + +// Get current user service +const currentUser = flowClient.currentUser() + +// Subscribe to authentication state +currentUser.subscribe((user) => { + if (user.loggedIn) { + console.log("User logged in:", user.addr) + loadUserData(user.addr) + } else { + console.log("User logged out") + } +}) + +// Authentication functions +async function login() { + await flowClient.authenticate() +} + +async function logout() { + await flowClient.unauthenticate() +} + +// Query user's NFTs +async function getUserNFTs(address: string) { + return await flowClient.query({ + cadence: ` + import NonFungibleToken from 0xNFTAddress + + access(all) fun main(address: Address): [UInt64] { + let account = getAccount(address) + let collectionRef = account.getCapability(/public/NFTCollection) + .borrow<&{NonFungibleToken.CollectionPublic}>() + ?? panic("Could not borrow collection") + + return collectionRef.getIDs() + } + `, + args: (arg, t) => [arg(address, t.Address)], + }) +} + +// Transfer an NFT +async function transferNFT(recipient: string, nftId: number) { + const txId = await flowClient.mutate({ + cadence: ` + import NonFungibleToken from 0xNFTAddress + + transaction(recipient: Address, nftId: UInt64) { + execute { + // Transfer logic here + } + } + `, + args: (arg, t) => [ + arg(recipient, t.Address), + arg(nftId.toString(), t.UInt64), + ], + limit: 9999, + }) + + // Wait for transaction to be sealed + const result = await flowClient.tx(txId).onceSealed() + + if (result.statusCode === 0) { + console.log("NFT transferred successfully!") + } else { + console.error("Transfer failed:", result.errorMessage) + } + + return result +} +``` + +## Additional Resources + +- [FCL Documentation](https://developers.flow.com/tools/fcl-js) +- [Flow Access API](https://developers.flow.com/http-api) +- [Cadence Language Reference](https://developers.flow.com/cadence/language) +- [Flow Developer Portal](https://developers.flow.com/) diff --git a/packages/payments/.cursorignore b/packages/payments/.cursorignore new file mode 100644 index 000000000..2e369f983 --- /dev/null +++ b/packages/payments/.cursorignore @@ -0,0 +1,6 @@ +# flow +emulator-account.pkey +.env + +# Pay attention to imports directory +!imports \ No newline at end of file diff --git a/packages/payments/.gitignore b/packages/payments/.gitignore new file mode 100644 index 000000000..4be6b1ae3 --- /dev/null +++ b/packages/payments/.gitignore @@ -0,0 +1,4 @@ +# flow +emulator-account.pkey +imports +.env \ No newline at end of file diff --git a/packages/payments/README.md b/packages/payments/README.md new file mode 100644 index 000000000..192ce1dd7 --- /dev/null +++ b/packages/payments/README.md @@ -0,0 +1,115 @@ +# @onflow/payments + +Minimal, framework‑agnostic Payments core for Flow apps. Provides types and a client for creating funding sessions via pluggable providers. + +## Install + +```bash +npm i @onflow/payments +``` + +## Usage + +```ts +import {createPaymentsClient} from "@onflow/payments" +import {createFlowClientCore} from "@onflow/fcl-core" + +// Import a provider (e.g., from a separate package or future built-in) +import {relayProvider} from "@onflow/payments/providers" + +const flowClient = createFlowClientCore({ + accessNodeUrl: "https://rest-mainnet.onflow.org", + computeLimit: 100, + storage: localStorage, + platform: "web", +}) + +const client = createPaymentsClient({ + providers: [relayProvider()], + flowClient, +}) + +const session = await client.createSession({ + kind: "crypto", + destination: "eip155:747:0xRecipient", // CAIP-10 format + currency: "USDC", // Symbol, EVM address, or Cadence vault identifier + amount: "100.0", // Human-readable decimal format + sourceChain: "eip155:1", // CAIP-2: source chain + sourceCurrency: "USDC", +}) + +// session.instructions contains provider-specific funding instructions +console.log(session.instructions.address) // e.g., deposit address +``` + +## Core Concepts + +### Funding Intent + +Describes what the user wants to fund: + +```ts +interface CryptoFundingIntent { + kind: "crypto" + destination: string // CAIP-10 account identifier + currency: string // Token symbol, EVM address, or Cadence vault ID + amount?: string // Human-readable amount (e.g., "100.50") + sourceChain: string // CAIP-2 chain identifier + sourceCurrency: string // Source token identifier +} +``` + +### Funding Session + +Returned by providers with instructions for completing the funding: + +```ts +interface CryptoFundingSession { + id: string + providerId: string + kind: "crypto" + instructions: { + address: string // Where to send funds + memo?: string // Optional memo/tag + } +} +``` + +### Funding Provider + +Pluggable interface for different funding mechanisms: + +```ts +interface FundingProvider { + id: string + getCapabilities(): Promise + startSession(intent: FundingIntent): Promise +} +``` + +## Token Formats + +The client accepts multiple token identifier formats: + +**1. Symbols:** +```ts +currency: "USDC" +``` + +**2. EVM Addresses (0x + 40 hex):** +```ts +currency: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" +``` + +**3. Cadence Vault Identifiers:** +```ts +currency: "A.1654653399040a61.FlowToken.Vault" +``` + +When you provide a **Cadence vault identifier**, the client queries the **Flow EVM Bridge** to automatically convert the vault ID to the corresponding EVM address before passing to providers. + +## Development + +```bash +npm test +``` diff --git a/packages/payments/cadence/scripts/get-evm-address-from-vault.cdc b/packages/payments/cadence/scripts/get-evm-address-from-vault.cdc new file mode 100644 index 000000000..2d1baced5 --- /dev/null +++ b/packages/payments/cadence/scripts/get-evm-address-from-vault.cdc @@ -0,0 +1,14 @@ +import "EVM" +import "FlowEVMBridgeConfig" + +access(all) fun main(vaultIdentifier: String): String? { + let vaultType = CompositeType(vaultIdentifier) + ?? panic("Could not construct type from identifier: ".concat(vaultIdentifier)) + + if let evmAddress = FlowEVMBridgeConfig.getEVMAddressAssociated(with: vaultType) { + return evmAddress.toString() + } + + return nil +} + diff --git a/packages/payments/cadence/scripts/get-token-decimals.cdc b/packages/payments/cadence/scripts/get-token-decimals.cdc new file mode 100644 index 000000000..eef6a422d --- /dev/null +++ b/packages/payments/cadence/scripts/get-token-decimals.cdc @@ -0,0 +1,8 @@ +import "EVM" +import "FlowEVMBridgeUtils" + +access(all) fun main(evmAddressHex: String): UInt8 { + let evmAddress = EVM.addressFromString(evmAddressHex) + return FlowEVMBridgeUtils.getTokenDecimals(evmContractAddress: evmAddress) +} + diff --git a/packages/payments/cadence/scripts/get-vault-type-from-evm.cdc b/packages/payments/cadence/scripts/get-vault-type-from-evm.cdc new file mode 100644 index 000000000..7df541554 --- /dev/null +++ b/packages/payments/cadence/scripts/get-vault-type-from-evm.cdc @@ -0,0 +1,13 @@ +import "EVM" +import "FlowEVMBridgeConfig" + +access(all) fun main(evmAddressHex: String): String? { + let evmAddress = EVM.addressFromString(evmAddressHex) + + if let vaultType = FlowEVMBridgeConfig.getTypeAssociated(with: evmAddress) { + return vaultType.identifier + } + + return nil +} + diff --git a/packages/payments/flow.json b/packages/payments/flow.json new file mode 100644 index 000000000..70208fef9 --- /dev/null +++ b/packages/payments/flow.json @@ -0,0 +1,123 @@ +{ + "dependencies": { + "Burner": { + "source": "mainnet://f233dcee88fe0abe.Burner", + "hash": "71af18e227984cd434a3ad00bb2f3618b76482842bae920ee55662c37c8bf331", + "aliases": { + "emulator": "f8d6e0586b0a20c7", + "mainnet": "f233dcee88fe0abe", + "testnet": "9a0766d93b6608b7" + } + }, + "CrossVMMetadataViews": { + "source": "mainnet://1d7e57aa55817448.CrossVMMetadataViews", + "hash": "7e79b77b87c750de5b126ebd6fca517c2b905ac7f01c0428e9f3f82838c7f524", + "aliases": { + "emulator": "f8d6e0586b0a20c7", + "mainnet": "1d7e57aa55817448", + "testnet": "631e88ae7f1d7c20" + } + }, + "CrossVMNFT": { + "source": "mainnet://1e4aa0b87d10b141.CrossVMNFT", + "hash": "8fe69f487164caffedab68b52a584fa7aa4d54a0061f4f211998c73a619fbea5", + "aliases": { + "mainnet": "1e4aa0b87d10b141", + "testnet": "dfc20aee650fcbdf" + } + }, + "EVM": { + "source": "mainnet://e467b9dd11fa00df.EVM", + "hash": "2a4782c7459dc5b72c034f67c8dd1beac6bb9b29104772a3e6eb6850718bb3b4", + "aliases": { + "emulator": "f8d6e0586b0a20c7", + "mainnet": "e467b9dd11fa00df", + "testnet": "8c5303eaa26202d6" + } + }, + "FlowEVMBridgeConfig": { + "source": "mainnet://1e4aa0b87d10b141.FlowEVMBridgeConfig", + "hash": "3c09f74467f22dac7bc02b2fdf462213b2f8ddfb513cd890ad0c2a7016507be3", + "aliases": { + "mainnet": "1e4aa0b87d10b141", + "testnet": "dfc20aee650fcbdf" + } + }, + "FlowEVMBridgeUtils": { + "source": "mainnet://1e4aa0b87d10b141.FlowEVMBridgeUtils", + "hash": "634ed6dde03eb8f027368aa7861889ce1f5099160903493a7a39a86c9afea14b", + "aliases": { + "mainnet": "1e4aa0b87d10b141", + "testnet": "dfc20aee650fcbdf" + } + }, + "FlowStorageFees": { + "source": "mainnet://e467b9dd11fa00df.FlowStorageFees", + "hash": "a92c26fb2ea59725441fa703aa4cd811e0fc56ac73d649a8e12c1e72b67a8473", + "aliases": { + "emulator": "f8d6e0586b0a20c7", + "mainnet": "e467b9dd11fa00df", + "testnet": "8c5303eaa26202d6" + } + }, + "FlowToken": { + "source": "mainnet://1654653399040a61.FlowToken", + "hash": "f82389e2412624ffa439836b00b42e6605b0c00802a4e485bc95b8930a7eac38", + "aliases": { + "emulator": "0ae53cb6e3f42a79", + "mainnet": "1654653399040a61", + "testnet": "7e60df042a9c0868" + } + }, + "FungibleToken": { + "source": "mainnet://f233dcee88fe0abe.FungibleToken", + "hash": "4b74edfe7d7ddfa70b703c14aa731a0b2e7ce016ce54d998bfd861ada4d240f6", + "aliases": { + "emulator": "ee82856bf20e2aa6", + "mainnet": "f233dcee88fe0abe", + "testnet": "9a0766d93b6608b7" + } + }, + "FungibleTokenMetadataViews": { + "source": "mainnet://f233dcee88fe0abe.FungibleTokenMetadataViews", + "hash": "70477f80fd7678466c224507e9689f68f72a9e697128d5ea54d19961ec856b3c", + "aliases": { + "emulator": "ee82856bf20e2aa6", + "mainnet": "f233dcee88fe0abe", + "testnet": "9a0766d93b6608b7" + } + }, + "MetadataViews": { + "source": "mainnet://1d7e57aa55817448.MetadataViews", + "hash": "b290b7906d901882b4b62e596225fb2f10defb5eaaab4a09368f3aee0e9c18b1", + "aliases": { + "emulator": "f8d6e0586b0a20c7", + "mainnet": "1d7e57aa55817448", + "testnet": "631e88ae7f1d7c20" + } + }, + "NonFungibleToken": { + "source": "mainnet://1d7e57aa55817448.NonFungibleToken", + "hash": "a258de1abddcdb50afc929e74aca87161d0083588f6abf2b369672e64cf4a403", + "aliases": { + "emulator": "f8d6e0586b0a20c7", + "mainnet": "1d7e57aa55817448", + "testnet": "631e88ae7f1d7c20" + } + }, + "ViewResolver": { + "source": "mainnet://1d7e57aa55817448.ViewResolver", + "hash": "374a1994046bac9f6228b4843cb32393ef40554df9bd9907a702d098a2987bde", + "aliases": { + "emulator": "f8d6e0586b0a20c7", + "mainnet": "1d7e57aa55817448", + "testnet": "631e88ae7f1d7c20" + } + } + }, + "networks": { + "emulator": "127.0.0.1:3569", + "mainnet": "access.mainnet.nodes.onflow.org:9000", + "testnet": "access.devnet.nodes.onflow.org:9000" + } +} diff --git a/packages/payments/jest-cdc-transform.js b/packages/payments/jest-cdc-transform.js new file mode 100644 index 000000000..b2861d9b0 --- /dev/null +++ b/packages/payments/jest-cdc-transform.js @@ -0,0 +1,8 @@ +// Jest transform for .cdc files - returns content as string +module.exports = { + process(sourceText) { + return { + code: `module.exports = ${JSON.stringify(sourceText)};`, + } + }, +} diff --git a/packages/payments/jest-json-transform.js b/packages/payments/jest-json-transform.js new file mode 100644 index 000000000..79589ab30 --- /dev/null +++ b/packages/payments/jest-json-transform.js @@ -0,0 +1,9 @@ +// Jest transform for .json files - returns parsed JSON + +module.exports = { + process(sourceText, sourcePath) { + return { + code: `module.exports = ${sourceText};`, + } + }, +} diff --git a/packages/payments/jest.config.js b/packages/payments/jest.config.js new file mode 100644 index 000000000..cdc161de9 --- /dev/null +++ b/packages/payments/jest.config.js @@ -0,0 +1,17 @@ +module.exports = { + testEnvironment: "node", + transform: { + "^.+\\.ts$": [ + "ts-jest", + { + tsconfig: { + esModuleInterop: true, + }, + }, + ], + "^.+\\.cdc$": "/jest-cdc-transform.js", + }, + moduleNameMapper: { + "^.+\\.json$": "/jest-json-transform.js", + }, +} diff --git a/packages/payments/package.json b/packages/payments/package.json new file mode 100644 index 000000000..2040ece77 --- /dev/null +++ b/packages/payments/package.json @@ -0,0 +1,49 @@ +{ + "name": "@onflow/payments", + "version": "0.0.1", + "description": "Flow JS SDK — Payments core (funding sessions)", + "license": "Apache-2.0", + "author": "Flow Foundation", + "homepage": "https://flow.com", + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/onflow/fcl-js.git" + }, + "bugs": { + "url": "https://github.com/onflow/fcl-js/issues" + }, + "source": "src/index.ts", + "main": "dist/payments.js", + "module": "dist/payments.module.js", + "unpkg": "dist/payments.umd.js", + "types": "types/index.d.ts", + "scripts": { + "prepublishOnly": "npm test && npm run build", + "test": "jest", + "build": "fcl-bundle", + "test:watch": "jest --watch", + "start": "fcl-bundle --watch" + }, + "peerDependencies": { + "@onflow/fcl-core": ">=1.0.0" + }, + "peerDependenciesMeta": { + "@onflow/fcl-core": { + "optional": true + } + }, + "devDependencies": { + "@babel/preset-typescript": "^7.25.7", + "@onflow/fcl-core": "*", + "@onflow/fcl-bundle": "1.7.1", + "@types/jest": "^29.5.13", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint": "^8.57.1", + "eslint-plugin-jsdoc": "^46.10.1", + "jest": "^29.7.0" + }, + "dependencies": { + "@babel/runtime": "^7.25.7" + } +} diff --git a/packages/payments/src/bridge-service.ts b/packages/payments/src/bridge-service.ts new file mode 100644 index 000000000..5b24ac7fa --- /dev/null +++ b/packages/payments/src/bridge-service.ts @@ -0,0 +1,75 @@ +/** + * Bridge service - converts between Cadence vault identifiers and EVM addresses + * using the Flow EVM Bridge registry + */ + +import type {createFlowClientCore} from "@onflow/fcl-core" +import {getContracts} from "@onflow/config" +import flowJSON from "../flow.json" + +import GET_EVM_ADDRESS_SCRIPT from "../cadence/scripts/get-evm-address-from-vault.cdc" +import GET_VAULT_TYPE_SCRIPT from "../cadence/scripts/get-vault-type-from-evm.cdc" +import GET_TOKEN_DECIMALS_SCRIPT from "../cadence/scripts/get-token-decimals.cdc" + +type FlowNetwork = "emulator" | "testnet" | "mainnet" + +interface BridgeQueryOptions { + flowClient: ReturnType +} + +/** Resolve `import "ContractName"` syntax using our bundled flow.json */ +async function resolveCadence( + flowClient: ReturnType, + cadence: string +): Promise { + const chainId = await flowClient.getChainId() + const n = chainId.replace(/^flow-/, "").toLowerCase() + const network: FlowNetwork = n === "local" ? "emulator" : (n as FlowNetwork) + + const contracts = getContracts(flowJSON, network) + return cadence.replace(/import\s+"(\w+)"/g, (match, name) => + contracts[name] ? `import ${name} from 0x${contracts[name]}` : match + ) +} + +/** + * Query the bridge to get the EVM address for a Cadence vault identifier + */ +export async function getEvmAddressFromVaultType({ + flowClient, + vaultIdentifier, +}: BridgeQueryOptions & {vaultIdentifier: string}): Promise { + const result = await flowClient.query({ + cadence: await resolveCadence(flowClient, GET_EVM_ADDRESS_SCRIPT), + args: (arg: any, t: any) => [arg(vaultIdentifier, t.String)], + }) + return result || null +} + +/** + * Query the bridge to get the Cadence vault type for an EVM address + */ +export async function getVaultTypeFromEvmAddress({ + flowClient, + evmAddress, +}: BridgeQueryOptions & {evmAddress: string}): Promise { + const result = await flowClient.query({ + cadence: await resolveCadence(flowClient, GET_VAULT_TYPE_SCRIPT), + args: (arg: any, t: any) => [arg(evmAddress, t.String)], + }) + return result || null +} + +/** + * Query the bridge to get the token decimals for an EVM address + */ +export async function getTokenDecimals({ + flowClient, + evmAddress, +}: BridgeQueryOptions & {evmAddress: string}): Promise { + const result = await flowClient.query({ + cadence: await resolveCadence(flowClient, GET_TOKEN_DECIMALS_SCRIPT), + args: (arg: any, t: any) => [arg(evmAddress, t.String)], + }) + return Number(result) +} diff --git a/packages/payments/src/cadence.d.ts b/packages/payments/src/cadence.d.ts new file mode 100644 index 000000000..5b2c43114 --- /dev/null +++ b/packages/payments/src/cadence.d.ts @@ -0,0 +1,4 @@ +declare module "*.cdc" { + const content: string + export default content +} diff --git a/packages/payments/src/client.test.ts b/packages/payments/src/client.test.ts new file mode 100644 index 000000000..c0e5387a4 --- /dev/null +++ b/packages/payments/src/client.test.ts @@ -0,0 +1,300 @@ +import {createPaymentsClient} from "./client" +import {FundingProvider, FundingIntent, FundingSession} from "./types" + +describe("createPaymentsClient", () => { + const mockFlowClient = { + getChainId: jest.fn().mockResolvedValue("mainnet"), + query: jest.fn().mockResolvedValue(null), + } as any + + it("should create a client with createSession method", () => { + const mockProvider: FundingProvider = { + id: "mock", + getCapabilities: jest.fn().mockResolvedValue([{type: "crypto"}]), + startSession: jest.fn(), + } + + const client = createPaymentsClient({ + providers: [mockProvider], + flowClient: mockFlowClient, + }) + expect(client.createSession).toBeInstanceOf(Function) + }) + + it("should call first provider's startSession", async () => { + const mockSession: FundingSession = { + id: "test-123", + providerId: "mock", + kind: "crypto", + instructions: {address: "0x123"}, + } + + const mockProvider: FundingProvider = { + id: "mock", + getCapabilities: jest.fn().mockResolvedValue([{type: "crypto"}]), + startSession: jest.fn().mockResolvedValue(mockSession), + } + + const client = createPaymentsClient({ + providers: [mockProvider], + flowClient: mockFlowClient, + }) + const intent: FundingIntent = { + kind: "crypto", + destination: "eip155:1:0x123", + currency: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + sourceChain: "eip155:1", + sourceCurrency: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + } + + const result = await client.createSession(intent) + + expect(mockProvider.startSession).toHaveBeenCalledWith(intent) + expect(result).toEqual(mockSession) + }) + + it("should try next provider if first fails", async () => { + const mockSession: FundingSession = { + id: "test-456", + providerId: "provider2", + kind: "crypto", + instructions: {address: "0x456"}, + } + + const failingProvider: FundingProvider = { + id: "failing", + getCapabilities: jest.fn().mockResolvedValue([{type: "crypto"}]), + startSession: jest.fn().mockRejectedValue(new Error("Provider 1 failed")), + } + + const workingProvider: FundingProvider = { + id: "provider2", + getCapabilities: jest.fn().mockResolvedValue([{type: "crypto"}]), + startSession: jest.fn().mockResolvedValue(mockSession), + } + + const client = createPaymentsClient({ + providers: [failingProvider, workingProvider], + flowClient: mockFlowClient, + }) + const intent: FundingIntent = { + kind: "crypto", + destination: "eip155:1:0x123", + currency: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + sourceChain: "eip155:1", + sourceCurrency: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + } + + const result = await client.createSession(intent) + + expect(failingProvider.startSession).toHaveBeenCalled() + expect(workingProvider.startSession).toHaveBeenCalled() + expect(result).toEqual(mockSession) + }) + + it("should throw if all providers fail", async () => { + const provider1: FundingProvider = { + id: "provider1", + getCapabilities: jest.fn().mockResolvedValue([{type: "crypto"}]), + startSession: jest.fn().mockRejectedValue(new Error("Error 1")), + } + + const provider2: FundingProvider = { + id: "provider2", + getCapabilities: jest.fn().mockResolvedValue([{type: "crypto"}]), + startSession: jest.fn().mockRejectedValue(new Error("Error 2")), + } + + const client = createPaymentsClient({ + providers: [provider1, provider2], + flowClient: mockFlowClient, + }) + const intent: FundingIntent = { + kind: "crypto", + destination: "eip155:1:0x123", + currency: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + sourceChain: "eip155:1", + sourceCurrency: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + } + + await expect(client.createSession(intent)).rejects.toThrow( + "Failed to create session: no provider could handle the request" + ) + }) + + it("should throw if no providers are given", async () => { + const client = createPaymentsClient({ + providers: [], + flowClient: mockFlowClient, + }) + const intent: FundingIntent = { + kind: "crypto", + destination: "eip155:1:0x123", + currency: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + sourceChain: "eip155:1", + sourceCurrency: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + } + + await expect(client.createSession(intent)).rejects.toThrow( + "Failed to create session: no provider could handle the request" + ) + }) + + describe("provider selection behavior", () => { + it("should use first provider when multiple support the same intent", async () => { + const session1: FundingSession = { + id: "provider1-session", + providerId: "provider1", + kind: "crypto", + instructions: {address: "0x111"}, + } + + const session2: FundingSession = { + id: "provider2-session", + providerId: "provider2", + kind: "crypto", + instructions: {address: "0x222"}, + } + + const provider1: FundingProvider = { + id: "provider1", + getCapabilities: jest.fn().mockResolvedValue([{type: "crypto"}]), + startSession: jest.fn().mockResolvedValue(session1), + } + + const provider2: FundingProvider = { + id: "provider2", + getCapabilities: jest.fn().mockResolvedValue([{type: "crypto"}]), + startSession: jest.fn().mockResolvedValue(session2), + } + + const client = createPaymentsClient({ + providers: [provider1, provider2], + flowClient: mockFlowClient, + }) + const intent: FundingIntent = { + kind: "crypto", + destination: "eip155:1:0x123", + currency: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + sourceChain: "eip155:1", + sourceCurrency: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + } + + const result = await client.createSession(intent) + + expect(result).toEqual(session1) + expect(provider1.startSession).toHaveBeenCalledTimes(1) + expect(provider2.startSession).not.toHaveBeenCalled() + }) + + it("should handle fiat intents with fiat provider", async () => { + const fiatSession: FundingSession = { + id: "fiat-123", + providerId: "moonpay", + kind: "fiat", + instructions: {url: "https://buy.moonpay.com/..."}, + } + + const fiatProvider: FundingProvider = { + id: "moonpay", + getCapabilities: jest.fn().mockResolvedValue([{type: "fiat"}]), + startSession: jest.fn().mockResolvedValue(fiatSession), + } + + const client = createPaymentsClient({ + providers: [fiatProvider], + flowClient: mockFlowClient, + }) + const intent: FundingIntent = { + kind: "fiat", + destination: "eip155:1:0x123", + currency: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + paymentType: "card", + } + + const result = await client.createSession(intent) + + expect(result).toEqual(fiatSession) + expect(result.kind).toBe("fiat") + if (result.kind === "fiat") { + expect(result.instructions.url).toBeTruthy() + } + }) + + it("should not maintain state between calls", async () => { + const session1: FundingSession = { + id: "session-1", + providerId: "provider", + kind: "crypto", + instructions: {address: "0x111"}, + } + + const session2: FundingSession = { + id: "session-2", + providerId: "provider", + kind: "crypto", + instructions: {address: "0x222"}, + } + + const mockProvider: FundingProvider = { + id: "provider", + getCapabilities: jest.fn().mockResolvedValue([{type: "crypto"}]), + startSession: jest + .fn() + .mockResolvedValueOnce(session1) + .mockResolvedValueOnce(session2), + } + + const client = createPaymentsClient({ + providers: [mockProvider], + flowClient: mockFlowClient, + }) + const intent: FundingIntent = { + kind: "crypto", + destination: "eip155:1:0x123", + currency: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + sourceChain: "eip155:1", + sourceCurrency: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + } + + const result1 = await client.createSession(intent) + const result2 = await client.createSession(intent) + + expect(result1.id).toBe("session-1") + expect(result2.id).toBe("session-2") + expect(mockProvider.startSession).toHaveBeenCalledTimes(2) + }) + + it("should pass intent through unchanged to provider", async () => { + const mockSession: FundingSession = { + id: "test", + providerId: "mock", + kind: "crypto", + instructions: {address: "0x123"}, + } + + const mockProvider: FundingProvider = { + id: "mock", + getCapabilities: jest.fn().mockResolvedValue([{type: "crypto"}]), + startSession: jest.fn().mockResolvedValue(mockSession), + } + + const client = createPaymentsClient({ + providers: [mockProvider], + flowClient: mockFlowClient, + }) + const intent: FundingIntent = { + kind: "crypto", + destination: "eip155:747:0xRecipient", + currency: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + amount: "1000000", + sourceChain: "eip155:1", + sourceCurrency: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + } + + await client.createSession(intent) + + expect(mockProvider.startSession).toHaveBeenCalledWith(intent) + }) + }) +}) diff --git a/packages/payments/src/client.ts b/packages/payments/src/client.ts new file mode 100644 index 000000000..59fd6ba18 --- /dev/null +++ b/packages/payments/src/client.ts @@ -0,0 +1,128 @@ +import type {FundingIntent, FundingSession, FundingProvider} from "./types" +import type {createFlowClientCore} from "@onflow/fcl-core" +import {ADDRESS_PATTERN} from "./constants" +import {getEvmAddressFromVaultType} from "./bridge-service" + +/** + * Client for creating funding sessions + */ +export interface PaymentsClient { + /** + * Create a new funding session based on the provided intent + * @param intent - The funding intent describing what the user wants to do + * @returns Promise resolving to a funding session with instructions + */ + createSession(intent: FundingIntent): Promise +} + +/** + * Configuration for creating a payments client + */ +export interface PaymentsClientConfig { + /** Array of funding providers to use (in priority order) */ + providers: FundingProvider[] + /** Flow client (FCL Core or SDK) for Cadence vault ID conversion */ + flowClient: ReturnType +} + +/** + * Check if a currency identifier is a Cadence vault identifier + */ +function isCadenceVaultIdentifier(currency: string): boolean { + return ADDRESS_PATTERN.CADENCE_VAULT.test(currency) +} + +/** + * Convert Cadence vault identifiers to EVM addresses in a funding intent + */ +async function convertCadenceCurrencies( + intent: FundingIntent, + flowClient: ReturnType +): Promise { + if (!isCadenceVaultIdentifier(intent.currency)) { + return intent + } + + const evmAddress = await getEvmAddressFromVaultType({ + flowClient, + vaultIdentifier: intent.currency, + }) + if (!evmAddress) { + throw new Error( + `Cadence vault type "${intent.currency}" is not bridged to EVM. ` + + `Make sure the token is onboarded to the Flow EVM Bridge.` + ) + } + return {...intent, currency: evmAddress} +} + +/** + * Create a new payments client + * + * @param config - Configuration for the payments client + * @returns A payments client instance + * + * @example + * ```typescript + * import {createPaymentsClient, relayProvider} from "@onflow/payments" + * import {createFlowClientCore} from "@onflow/fcl-core" + * + * const flowClient = createFlowClientCore({...}) + * const client = createPaymentsClient({ + * providers: [relayProvider()], + * flowClient, + * }) + * + * // Using EVM addresses + * const session = await client.createSession({ + * kind: "crypto", + * destination: "eip155:747:0xRecipient", + * currency: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", // USDC on Flow EVM + * amount: "100.0", + * sourceChain: "eip155:1", + * sourceCurrency: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // USDC on Ethereum + * }) + * + * // Or using Cadence vault identifiers (auto-converted to EVM) + * const session2 = await client.createSession({ + * kind: "crypto", + * destination: "eip155:747:0xRecipient", + * currency: "A.1654653399040a61.FlowToken.Vault", + * amount: "10.0", + * sourceChain: "eip155:1", + * sourceCurrency: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + * }) + * ``` + */ +export function createPaymentsClient( + config: PaymentsClientConfig +): PaymentsClient { + return { + async createSession(intent) { + // Convert Cadence vault identifiers to EVM addresses + const processedIntent = await convertCadenceCurrencies( + intent, + config.flowClient + ) + + const providerErrors: {id?: string; error: any}[] = [] + for (const provider of config.providers) { + try { + return await provider.startSession(processedIntent) + } catch (err) { + providerErrors.push({ + id: provider.id, + error: err instanceof Error ? err.message : String(err), + }) + continue + } + } + const errorDetails = providerErrors + .map((e, idx) => `Provider ${e.id ?? idx}: ${e.error}`) + .join("; ") + throw new Error( + `Failed to create session: no provider could handle the request. Errors: ${errorDetails}` + ) + }, + } +} diff --git a/packages/payments/src/constants.ts b/packages/payments/src/constants.ts new file mode 100644 index 000000000..7188c1a3e --- /dev/null +++ b/packages/payments/src/constants.ts @@ -0,0 +1,59 @@ +/** + * Constants used throughout the payments package + */ + +/** + * Virtual machine types supported by the Flow blockchain + */ +export const VM = { + EVM: "evm", + CADENCE: "cadence", +} as const + +export type VM = (typeof VM)[keyof typeof VM] + +/** + * Flow network identifiers + */ +export const NETWORK = { + MAINNET: "mainnet", + TESTNET: "testnet", + LOCAL: "local", +} as const + +export type Network = (typeof NETWORK)[keyof typeof NETWORK] + +/** + * Funding method types + */ +export const FUNDING_KIND = { + CRYPTO: "crypto", + FIAT: "fiat", +} as const + +export type FundingKind = (typeof FUNDING_KIND)[keyof typeof FUNDING_KIND] + +/** + * CAIP identifier formats + * @see https://github.com/ChainAgnostic/CAIPs + */ +export const CAIP = { + /** CAIP-2: Blockchain ID Specification (namespace:chainId) */ + CHAIN_ID_REGEX: /^[a-z]+:\d+$/, + /** CAIP-10: Account ID Specification (namespace:chainId:address) */ + ACCOUNT_ID_REGEX: /^[a-z]+:\d+:0x[a-fA-F0-9]+$/, + /** EIP-155 namespace for Ethereum-compatible chains */ + EIP155_NAMESPACE: "eip155", +} as const + +/** + * Address format patterns + */ +export const ADDRESS_PATTERN = { + /** EVM address format: 0x followed by 40 hex characters */ + EVM: /^0x[a-fA-F0-9]{40}$/, + /** Cadence address format: 0x followed by 16 hex characters */ + CADENCE: /^0x[a-fA-F0-9]{16}$/, + /** Cadence vault identifier format: A.{address}.{contract}.Vault */ + CADENCE_VAULT: /^A\.[a-fA-F0-9]+\.[A-Za-z0-9_]+\.Vault$/, +} as const diff --git a/packages/payments/src/index.ts b/packages/payments/src/index.ts new file mode 100644 index 000000000..a6c2bf68d --- /dev/null +++ b/packages/payments/src/index.ts @@ -0,0 +1,4 @@ +export * from "./types" +export * from "./client" +export * from "./providers" +export * from "./constants" diff --git a/packages/payments/src/providers/index.ts b/packages/payments/src/providers/index.ts new file mode 100644 index 000000000..b13e6cd6e --- /dev/null +++ b/packages/payments/src/providers/index.ts @@ -0,0 +1,5 @@ +// Provider implementations are added in separate PRs +// e.g., export {relayProvider} from "./relay" + +// Placeholder export - providers will be added here +export {} diff --git a/packages/payments/src/types.ts b/packages/payments/src/types.ts new file mode 100644 index 000000000..4573b691c --- /dev/null +++ b/packages/payments/src/types.ts @@ -0,0 +1,145 @@ +/** + * Base intent for funding a Flow account + */ +export interface BaseFundingIntent { + /** Type discriminator for the funding method */ + kind: string + /** Destination address in CAIP-10 format: `namespace:chainId:address` (e.g., `"eip155:747:0x..."`) */ + destination: string + /** Token identifier - EVM address (`"0xa0b8..."`) or Cadence vault identifier (`"A.xxx.Token.Vault"`) */ + currency: string + /** Amount in human-readable decimal format (e.g., `"1.5"` for 1.5 tokens). Provider converts to appropriate format (base units for EVM, UFix64 for Cadence). */ + amount?: string +} + +/** + * Intent to fund an account using crypto (cross-chain bridge) + */ +export interface CryptoFundingIntent extends BaseFundingIntent { + kind: "crypto" + /** Source blockchain in CAIP-2 format: `namespace:chainId` (e.g., `"eip155:1"` for Ethereum mainnet) */ + sourceChain: string + /** Source token identifier - EVM address on the source chain */ + sourceCurrency: string +} + +/** + * Intent to fund an account using fiat currency (credit card, bank transfer, etc.) + */ +export interface FiatFundingIntent extends BaseFundingIntent { + kind: "fiat" + /** Payment method type (e.g., `"card"`, `"bank_transfer"`) */ + paymentType: string +} + +/** + * Union type representing all possible funding intents + */ +export type FundingIntent = CryptoFundingIntent | FiatFundingIntent + +/** + * Base session returned by a funding provider + */ +export interface BaseSession { + /** Unique session identifier */ + id: string + /** ID of the provider that created this session */ + providerId: string + /** Type discriminator for the funding method */ + kind: "crypto" | "fiat" +} + +/** + * Session for crypto funding with deposit address instructions + */ +export interface CryptoFundingSession extends BaseSession { + kind: "crypto" + /** Instructions for the user to complete the funding */ + instructions: { + /** Deposit address where user should send funds */ + address: string + /** Optional memo/tag for the deposit */ + memo?: string + } +} + +/** + * Session for fiat funding with payment URL + */ +export interface FiatFundingSession extends BaseSession { + kind: "fiat" + /** Instructions for the user to complete the funding */ + instructions: { + /** URL to the payment page */ + url: string + /** Optional provider name for display purposes */ + providerName?: string + } +} + +/** + * Union type representing all possible funding sessions + */ +export type FundingSession = CryptoFundingSession | FiatFundingSession + +import type {VM} from "./constants" + +/** + * Base capabilities supported by a funding provider + */ +export interface BaseProviderCapability { + /** List of supported token identifiers (EVM addresses or Cadence vault IDs) */ + currencies?: string[] + /** Minimum funding amount in human-readable format */ + minAmount?: string + /** Maximum funding amount in human-readable format */ + maxAmount?: string +} + +/** + * Capabilities for a crypto funding provider + */ +export interface CryptoProviderCapability extends BaseProviderCapability { + /** Type discriminator */ + type: "crypto" + /** List of supported source chains in CAIP-2 format (e.g., `["eip155:1", "eip155:137"]`) */ + sourceChains?: string[] + /** List of supported source currencies */ + sourceCurrencies?: string[] +} + +/** + * Capabilities for a fiat funding provider + */ +export interface FiatProviderCapability extends BaseProviderCapability { + /** Type discriminator */ + type: "fiat" + /** List of supported payment methods (e.g., `["card", "bank_transfer"]`) */ + paymentTypes?: string[] +} + +/** + * Union type representing all provider capability types + */ +export type ProviderCapability = + | CryptoProviderCapability + | FiatProviderCapability + +/** + * Interface that all funding providers must implement + */ +export interface FundingProvider { + /** Unique provider identifier */ + id: string + /** + * Get the capabilities supported by this provider + * @returns Promise resolving to an array of capability objects + */ + getCapabilities(): Promise + /** + * Start a new funding session + * @param intent - The funding intent describing what the user wants to do + * @returns Promise resolving to a funding session with instructions + */ + startSession(intent: FundingIntent): Promise +} diff --git a/packages/payments/tsconfig.json b/packages/payments/tsconfig.json new file mode 100644 index 000000000..520b8dcd8 --- /dev/null +++ b/packages/payments/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig", + "include": ["src/**/*", "src/**/*.d.ts", "cadence/**/*", "flow.json"], + "compilerOptions": { + "declarationDir": "types", + "rootDir": ".", + "resolveJsonModule": true + } +} diff --git a/packages/react-sdk/package.json b/packages/react-sdk/package.json index 750059502..8f7b065e5 100644 --- a/packages/react-sdk/package.json +++ b/packages/react-sdk/package.json @@ -33,6 +33,7 @@ "@onflow/typedefs": "^1.8.0", "@testing-library/dom": "^10.4.0", "@types/jest": "^29.5.13", + "@types/qrcode": "^1.5.6", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@typescript-eslint/eslint-plugin": "^6.21.0", @@ -50,6 +51,7 @@ "@headlessui/react": "^2.2.2", "@tanstack/react-query": "^5.67.3", "@testing-library/react": "^16.2.0", + "qrcode": "^1.5.3", "tailwind-merge": "^3.3.1" }, "peerDependencies": { diff --git a/packages/react-sdk/src/components/Fund.tsx b/packages/react-sdk/src/components/Fund.tsx new file mode 100644 index 000000000..becc2ba95 --- /dev/null +++ b/packages/react-sdk/src/components/Fund.tsx @@ -0,0 +1,38 @@ +import React, {useState} from "react" +import {Button, ButtonProps} from "./internal/Button" +import {Dialog} from "./internal/Dialog" +import {StyleWrapper} from "./internal/StyleWrapper" +import {FundContent} from "./FundContent" + +interface FundProps { + variant?: ButtonProps["variant"] +} + +export const Fund: React.FC = ({variant = "primary"}) => { + const [open, setOpen] = useState(false) + + const handleButtonClick = () => { + setOpen(true) + } + + return ( + <> + + + + setOpen(false)} + className="flow-max-w-md" + > + + + + ) +} diff --git a/packages/react-sdk/src/components/FundContent.tsx b/packages/react-sdk/src/components/FundContent.tsx new file mode 100644 index 000000000..6b5e58220 --- /dev/null +++ b/packages/react-sdk/src/components/FundContent.tsx @@ -0,0 +1,174 @@ +import React, {useState} from "react" +import {Tab, TabGroup, TabList, TabPanels} from "./internal/Tabs" +import {TabPanel} from "@headlessui/react" +import {Input} from "./internal/Input" +import {Button} from "./internal/Button" +import { + Listbox, + ListboxButton, + ListboxOption, + ListboxOptions, +} from "./internal/Listbox" +import {QRCode} from "./internal/QRCode" +import {Address} from "./internal/Address" + +const tokens = [ + {id: 1, name: "USDC"}, + {id: 2, name: "FLOW"}, +] + +const chains = [ + {id: 1, name: "Flow"}, + {id: 2, name: "Ethereum"}, +] + +const PLACEHOLDER_ADDRESS = "0x1a2b3c4d5e6f7890abcdef1234567890" + +export const FundContent: React.FC = () => { + const [amount, setAmount] = useState("") + const [selectedToken, setSelectedToken] = useState(tokens[0]) + const [selectedChain, setSelectedChain] = useState(chains[0]) + + return ( +
+

+ Fund Your Account +

+ + + + + {({selected}) => ( + <> + Credit Card + {selected && ( +
+ )} + + )} + + + {({selected}) => ( + <> + Crypto Transfer + {selected && ( +
+ )} + + )} + + + + +
+
+ +
+ setAmount(e.target.value)} + className="flow-flex-1 flow-text-xl flow-font-medium" + /> + + USD + +
+
+

+ ≈{" "} + + 0 FLOW + +

+
+
+ +
+
+ +
+
+
+ + + {({open}) => ( +
+ {selectedToken.name} + {open && ( + + {tokens.map(token => ( + + {token.name} + + ))} + + )} +
+ )} +
+
+
+ + + {({open}) => ( +
+ {selectedChain.name} + {open && ( + + {chains.map(chain => ( + + {chain.name} + + ))} + + )} +
+ )} +
+
+
+ +
+ +
+ +
+
+
+
+ +
+ ) +} diff --git a/packages/react-sdk/src/components/index.ts b/packages/react-sdk/src/components/index.ts index 3a01018ed..426557f28 100644 --- a/packages/react-sdk/src/components/index.ts +++ b/packages/react-sdk/src/components/index.ts @@ -5,3 +5,5 @@ export {TransactionLink} from "./TransactionLink" export {TransactionButton} from "./TransactionButton" export {NftCard, type NftCardAction} from "./NftCard" export {ScheduledTransactionList} from "./ScheduledTransactionList" +export {Fund} from "./Fund" +export {FundContent} from "./FundContent" diff --git a/packages/react-sdk/src/components/internal/Address.tsx b/packages/react-sdk/src/components/internal/Address.tsx new file mode 100644 index 000000000..53b0832e5 --- /dev/null +++ b/packages/react-sdk/src/components/internal/Address.tsx @@ -0,0 +1,58 @@ +import React, {useState} from "react" +import {Button} from "./Button" + +export interface AddressProps { + address: string + label?: string +} + +export const Address: React.FC = ({ + address, + label = "Address", +}) => { + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + try { + if (typeof window !== "undefined") { + const nav = window.navigator as Navigator & { + clipboard?: Clipboard + } + if (nav.clipboard) { + await nav.clipboard.writeText(address) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + } + } catch (err) { + console.error("Failed to copy address:", err) + } + } + + return ( +
+ + {label} + +
+ + {address} + + +
+
+ ) +} diff --git a/packages/react-sdk/src/components/internal/Input.tsx b/packages/react-sdk/src/components/internal/Input.tsx new file mode 100644 index 000000000..61e49f7be --- /dev/null +++ b/packages/react-sdk/src/components/internal/Input.tsx @@ -0,0 +1,24 @@ +import React from "react" +import {useDarkMode} from "../../provider/DarkModeProvider" +import {twMerge} from "tailwind-merge" + +export interface InputProps extends React.ComponentProps<"input"> {} + +export const Input: React.FC = ({className, ...props}) => { + const {isDark} = useDarkMode() + return ( + + ) +} diff --git a/packages/react-sdk/src/components/internal/Listbox.tsx b/packages/react-sdk/src/components/internal/Listbox.tsx new file mode 100644 index 000000000..a0d005a71 --- /dev/null +++ b/packages/react-sdk/src/components/internal/Listbox.tsx @@ -0,0 +1,107 @@ +import React from "react" +import { + Listbox as HeadlessListbox, + ListboxButton as HeadlessListboxButton, + ListboxOption as HeadlessListboxOption, + ListboxOptions as HeadlessListboxOptions, +} from "@headlessui/react" +import {twMerge} from "tailwind-merge" + +export interface ListboxProps + extends Omit< + React.ComponentProps, + "value" | "onChange" + > { + value: T + onChange: (value: T) => void +} + +export function Listbox({className, ...props}: ListboxProps) { + return ( + + ) +} + +export interface ListboxButtonProps + extends React.ComponentProps {} + +export const ListboxButton: React.FC = ({ + className, + children, + ...props +}) => { + return ( + + {children} + + ) +} + +export interface ListboxOptionsProps + extends React.ComponentProps {} + +export const ListboxOptions: React.FC = ({ + className, + ...props +}) => { + return ( + + ) +} + +export interface ListboxOptionProps + extends React.ComponentProps { + value: T +} + +export function ListboxOption({ + className, + children, + ...props +}: ListboxOptionProps) { + return ( + + {children} + + ) +} diff --git a/packages/react-sdk/src/components/internal/QRCode.tsx b/packages/react-sdk/src/components/internal/QRCode.tsx new file mode 100644 index 000000000..cd21d950c --- /dev/null +++ b/packages/react-sdk/src/components/internal/QRCode.tsx @@ -0,0 +1,104 @@ +import React, {useMemo, Fragment} from "react" +import QRCodeLib from "qrcode" + +export interface QRCodeProps { + value: string + size?: number +} + +export const QRCode: React.FC = ({value, size = 200}) => { + const {modules, length, findingPatternPositions} = useMemo(() => { + if (!value) { + return {modules: [], length: 0, findingPatternPositions: []} + } + + const qr = QRCodeLib.create(value, { + errorCorrectionLevel: "H", + }) + const modules1D = Array.from(qr.modules.data) + const modules: boolean[][] = [] + const length = qr.modules.size + + // Check if the module is part of the finder pattern + const isFindingPattern = (x: number, y: number) => + (x < 8 && (y < 8 || y >= length - 8)) || (x >= length - 8 && y < 8) + + for (let i = 0; i < length; i++) { + modules.push( + [...modules1D.slice(i * length, (i + 1) * length)].map((bit, j) => { + return bit === 1 && !isFindingPattern(i, j) + }) + ) + } + + // Positions of the finder patterns used for custom rendering + const findingPatternPositions = [ + [0, 0], + [0, length - 7], + [length - 7, 0], + ] + + return {modules, length, findingPatternPositions} + }, [value]) + + if (!value || length === 0) { + return null + } + + return ( +
+
+ + + {modules.map((row, y) => + row.map((cell, x) => + cell ? ( + + ) : null + ) + )} + + {findingPatternPositions.map(([x, y]) => { + return ( + + + + + + + + ) + })} + + +
+
+ ) +} diff --git a/packages/react-sdk/src/components/internal/Tabs.tsx b/packages/react-sdk/src/components/internal/Tabs.tsx new file mode 100644 index 000000000..c6b51e14e --- /dev/null +++ b/packages/react-sdk/src/components/internal/Tabs.tsx @@ -0,0 +1,76 @@ +import React from "react" +import { + Tab as HeadlessTab, + TabGroup as HeadlessTabGroup, + TabList as HeadlessTabList, + TabPanel as HeadlessTabPanel, + TabPanels as HeadlessTabPanels, +} from "@headlessui/react" +import {useDarkMode} from "../../provider/DarkModeProvider" +import {twMerge} from "tailwind-merge" + +export interface TabGroupProps + extends React.ComponentProps {} + +export const TabGroup: React.FC = ({className, ...props}) => { + const {isDark} = useDarkMode() + return ( + + ) +} + +export interface TabListProps + extends React.ComponentProps {} + +export const TabList: React.FC = ({className, ...props}) => { + return ( + + ) +} + +export interface TabProps extends React.ComponentProps {} + +export const Tab: React.FC = ({className, ...props}) => { + return ( + + ) +} + +export interface TabPanelsProps + extends React.ComponentProps {} + +export const TabPanels: React.FC = ({className, ...props}) => { + return +} + +export interface TabPanelProps + extends React.ComponentProps {} + +export const TabPanel: React.FC = ({className, ...props}) => { + return ( + + ) +}