spark-micropayments 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ReadMe.md ADDED
@@ -0,0 +1,38 @@
1
+ ## Overview
2
+ To install the package, you can run `npm i micropayments` in your project.
3
+
4
+ To use the micropayments package, you can use the following code:
5
+
6
+ ```tsx
7
+ import Paywall from 'micropayments'
8
+
9
+ function App() {
10
+ return (
11
+ <div>
12
+ <h1>Welcome to my webpage</h1>
13
+ <p>Here is free content that is visible to everyone</p>
14
+
15
+ <Paywall
16
+ receiverSparkAddress="spark1pgss96dw2dqw0gwxlwudx2v863husuass6u2q3e4lfz23qs96ewfedz2w0pxam"
17
+ amount={0.1}
18
+ sparkscanApiKey="ss_sk_live_..."
19
+ >
20
+ <p>Here is some content that is only visible if you pay 0.1 USDB</p>
21
+ </Paywall>
22
+ </div>
23
+ )
24
+ }
25
+ ```
26
+
27
+ Any content within the `Paywall` component will only be visible if the user pays the specified amount of USDB via the Spark network. The user connects their Xverse wallet and pays using USDB stablecoin.
28
+
29
+ ## Parameters
30
+
31
+ #### receiverSparkAddress
32
+ The Spark address that will receive USDB payments. This is the address of the content owner.
33
+
34
+ #### amount
35
+ The minimum amount of USDB that the user needs to pay to access the paywalled content (e.g., 0.1 = $0.10).
36
+
37
+ #### sparkscanApiKey
38
+ Your SparkScan API key, used to verify whether the connected wallet has already paid.
@@ -0,0 +1,14 @@
1
+ interface ConnectWalletProps {
2
+ className?: string;
3
+ }
4
+ export declare function ConnectWallet({ className }: ConnectWalletProps): import("react/jsx-runtime").JSX.Element | null;
5
+ interface PaywallProps {
6
+ children: React.ReactNode;
7
+ receiverSparkAddress: string;
8
+ amount: number;
9
+ sparkscanApiKey: string;
10
+ /** Base URL for SparkScan API. Use a proxy path (e.g. '/api/sparkscan') to avoid CORS issues. Defaults to 'https://api.sparkscan.io'. */
11
+ sparkscanBaseUrl?: string;
12
+ }
13
+ export default function Paywall({ children, receiverSparkAddress, amount, sparkscanApiKey, sparkscanBaseUrl, }: PaywallProps): import("react/jsx-runtime").JSX.Element;
14
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,207 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // index.tsx (Micropayments - Spark/USDB)
3
+ // Paywall component using Xverse wallet (sats-connect) and SparkScan API
4
+ import { useState, useEffect, useSyncExternalStore } from 'react';
5
+ import { request, AddressPurpose } from 'sats-connect';
6
+ const SPARKSCAN_API = 'https://api.sparkscan.io';
7
+ const USDB_TOKEN_ID = 'btkn1xgrvjwey5ngcagvap2dzzvsy4uk8ua9x69k82dwvt5e7ef9drm9qztux87';
8
+ let _walletAddress = localStorage.getItem('spark_address');
9
+ const _listeners = new Set();
10
+ function getWalletAddress() {
11
+ return _walletAddress;
12
+ }
13
+ function setWalletAddress(addr) {
14
+ _walletAddress = addr;
15
+ if (addr)
16
+ localStorage.setItem('spark_address', addr);
17
+ else
18
+ localStorage.removeItem('spark_address');
19
+ _listeners.forEach((fn) => fn());
20
+ }
21
+ function subscribe(fn) {
22
+ _listeners.add(fn);
23
+ return () => { _listeners.delete(fn); };
24
+ }
25
+ // ---------------------------------------------------------------------------
26
+ // Helpers
27
+ // ---------------------------------------------------------------------------
28
+ function formatAmountDisplay(amount) {
29
+ if (amount < 1) {
30
+ const cents = Math.round(amount * 100);
31
+ return {
32
+ display: `${cents} cent${cents !== 1 ? 's' : ''}`,
33
+ usdc: `${amount} USDB`,
34
+ };
35
+ }
36
+ else {
37
+ return {
38
+ display: `$${amount}`,
39
+ usdc: `${amount} USDB`,
40
+ };
41
+ }
42
+ }
43
+ async function checkExistingPayment(sparkAddress, recipientAddress, requiredAmount, apiKey, baseUrl) {
44
+ try {
45
+ const isUSDB = (tx) => tx.tokenMetadata?.tokenAddress === USDB_TOKEN_ID ||
46
+ tx.tokenMetadata?.tokenIdentifier === USDB_TOKEN_ID;
47
+ const minRawAmount = (tx) => requiredAmount * 10 ** (tx.tokenMetadata?.decimals ?? 0);
48
+ // Check if recipient received >= requiredAmount in the tx's outputs
49
+ const recipientGotPaid = (tx) => {
50
+ const minRaw = minRawAmount(tx);
51
+ if (tx.type === 'token_transfer') {
52
+ const raw = parseFloat(tx.tokenAmount ?? '');
53
+ return !isNaN(raw) && raw >= minRaw;
54
+ }
55
+ if (tx.type === 'token_multi_transfer') {
56
+ return (tx.multiIoDetails?.outputs ?? []).some((o) => o.address === recipientAddress &&
57
+ parseFloat(o.amount) >= minRaw);
58
+ }
59
+ return false;
60
+ };
61
+ const fetchTxs = async (address) => {
62
+ const r = await fetch(`${baseUrl}/v1/address/${address}/transactions?network=MAINNET&limit=100`, { headers: { 'X-API-Key': apiKey } });
63
+ if (!r.ok)
64
+ return [];
65
+ const json = await r.json();
66
+ return json.data ?? [];
67
+ };
68
+ const isConfirmedUSDB = (tx) => tx.status === 'confirmed' && isUSDB(tx) && recipientGotPaid(tx);
69
+ // 1. Query user's transactions
70
+ const userTxs = await fetchTxs(sparkAddress);
71
+ // Outgoing token_transfer to recipient
72
+ if (userTxs.some((tx) => tx.type === 'token_transfer' &&
73
+ tx.direction === 'outgoing' &&
74
+ tx.counterparty?.identifier === recipientAddress &&
75
+ isConfirmedUSDB(tx)))
76
+ return true;
77
+ // token_multi_transfer where user is in inputs and recipient is in outputs
78
+ if (userTxs.some((tx) => tx.type === 'token_multi_transfer' &&
79
+ isConfirmedUSDB(tx) &&
80
+ (tx.multiIoDetails?.inputs ?? []).some((i) => i.address === sparkAddress)))
81
+ return true;
82
+ // 2. If user IS the recipient, any incoming USDB payment counts
83
+ if (sparkAddress === recipientAddress &&
84
+ userTxs.some((tx) => (tx.type === 'token_transfer' || tx.type === 'token_multi_transfer') &&
85
+ tx.direction === 'incoming' &&
86
+ isConfirmedUSDB(tx)))
87
+ return true;
88
+ // 3. Fallback: query recipient's transactions for a payment from this user
89
+ if (sparkAddress !== recipientAddress) {
90
+ const recipTxs = await fetchTxs(recipientAddress);
91
+ // Incoming token_transfer from user
92
+ if (recipTxs.some((tx) => tx.type === 'token_transfer' &&
93
+ tx.direction === 'incoming' &&
94
+ tx.counterparty?.identifier === sparkAddress &&
95
+ isConfirmedUSDB(tx)))
96
+ return true;
97
+ // token_multi_transfer where user is in inputs
98
+ if (recipTxs.some((tx) => tx.type === 'token_multi_transfer' &&
99
+ isConfirmedUSDB(tx) &&
100
+ (tx.multiIoDetails?.inputs ?? []).some((i) => i.address === sparkAddress)))
101
+ return true;
102
+ }
103
+ return false;
104
+ }
105
+ catch (err) {
106
+ console.error('Payment check failed', err);
107
+ return false;
108
+ }
109
+ }
110
+ export function ConnectWallet({ className }) {
111
+ const walletAddress = useSyncExternalStore(subscribe, getWalletAddress);
112
+ const handleDisconnect = () => {
113
+ setWalletAddress(null);
114
+ };
115
+ if (!walletAddress)
116
+ return null;
117
+ return (_jsx("div", { className: className, children: _jsxs("div", { className: "micropay-wallet-info", children: [_jsxs("span", { className: "micropay-wallet-address", children: [walletAddress.slice(0, 12), "\u2026", walletAddress.slice(-6)] }), _jsx("button", { onClick: handleDisconnect, className: "micropay-disconnect-button", children: "Disconnect" })] }) }));
118
+ }
119
+ export default function Paywall({ children, receiverSparkAddress, amount, sparkscanApiKey, sparkscanBaseUrl = SPARKSCAN_API, }) {
120
+ const walletAddress = useSyncExternalStore(subscribe, getWalletAddress);
121
+ const [paid, setPaid] = useState(false);
122
+ const [paying, setPaying] = useState(false);
123
+ const [connecting, setConnecting] = useState(false);
124
+ const [checking, setChecking] = useState(true);
125
+ const [error, setError] = useState(null);
126
+ const formattedAmount = formatAmountDisplay(amount);
127
+ // Check for existing payment whenever the wallet address changes
128
+ useEffect(() => {
129
+ if (walletAddress) {
130
+ setChecking(true);
131
+ checkExistingPayment(walletAddress, receiverSparkAddress, amount, sparkscanApiKey, sparkscanBaseUrl).then((hasPaid) => {
132
+ if (hasPaid)
133
+ setPaid(true);
134
+ setChecking(false);
135
+ });
136
+ }
137
+ else {
138
+ setPaid(false);
139
+ setChecking(false);
140
+ }
141
+ }, [walletAddress, receiverSparkAddress, amount, sparkscanApiKey, sparkscanBaseUrl]);
142
+ const handleConnect = async () => {
143
+ setConnecting(true);
144
+ setError(null);
145
+ try {
146
+ const connectRes = await request('wallet_connect', {
147
+ addresses: [AddressPurpose.Spark],
148
+ message: 'Connect to access this content',
149
+ });
150
+ if (connectRes.status !== 'success') {
151
+ setError('Wallet connection was declined.');
152
+ setConnecting(false);
153
+ return;
154
+ }
155
+ const sparkAddr = connectRes.result.addresses?.find((a) => a.purpose === 'spark')?.address;
156
+ if (!sparkAddr) {
157
+ setError('No Spark address found in wallet.');
158
+ setConnecting(false);
159
+ return;
160
+ }
161
+ setWalletAddress(sparkAddr);
162
+ }
163
+ catch (err) {
164
+ setError('Could not connect to wallet. Is Xverse installed?');
165
+ console.error('Wallet connect error', err);
166
+ }
167
+ finally {
168
+ setConnecting(false);
169
+ }
170
+ };
171
+ const handlePay = async () => {
172
+ if (!walletAddress)
173
+ return;
174
+ setPaying(true);
175
+ setError(null);
176
+ try {
177
+ const payRes = await request('spark_transferToken', {
178
+ receiverSparkAddress,
179
+ tokenIdentifier: USDB_TOKEN_ID,
180
+ tokenAmount: amount,
181
+ });
182
+ if ('result' in payRes) {
183
+ setPaid(true);
184
+ }
185
+ else {
186
+ setError('Payment was not completed.');
187
+ }
188
+ }
189
+ catch (err) {
190
+ setError(err?.message || 'Payment failed.');
191
+ console.error('Payment error', err);
192
+ }
193
+ finally {
194
+ setPaying(false);
195
+ }
196
+ };
197
+ // Still checking payment status on mount
198
+ if (checking) {
199
+ return (_jsx("div", { className: "micropay-container", children: _jsx("p", { className: "micropay-checking", children: "Checking payment status\u2026" }) }));
200
+ }
201
+ // Paid — show content
202
+ if (paid) {
203
+ return _jsx(_Fragment, { children: children });
204
+ }
205
+ // Not paid — show paywall
206
+ return (_jsxs("div", { className: "micropay-container", children: [_jsx("div", { className: "connectkit-button-container micropay-connect-container", children: _jsxs("div", { className: "connectkit-button micropay-connect", children: [_jsxs("p", { className: "micropay-instructions", children: ["To continue, please pay ", formattedAmount.display, " (", formattedAmount.usdc, ") on the Spark network."] }), !walletAddress && (_jsx("button", { onClick: handleConnect, disabled: connecting, className: "pay-button micropay-pay-button", children: connecting ? 'Connecting…' : 'Connect Wallet' }))] }) }), walletAddress && (_jsx("div", { className: "pay-button-container micropay-pay-container", children: _jsx("button", { onClick: handlePay, disabled: paying, className: "pay-button micropay-pay-button", children: paying ? 'Processing…' : `Pay ${formattedAmount.usdc}` }) })), error && _jsx("p", { className: "micropay-error-message", children: error })] }));
207
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "spark-micropayments",
3
+ "version": "0.2.0",
4
+ "description": "A simple SDK for stablecoin micropayments using Spark and USDB",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "module": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "scripts": {
17
+ "prepare": "npm run build",
18
+ "build": "tsc"
19
+ },
20
+ "keywords": [
21
+ "stablecoins",
22
+ "sdk",
23
+ "typescript",
24
+ "micropayments",
25
+ "spark",
26
+ "usdb",
27
+ "paywall"
28
+ ],
29
+ "author": "Dhruv Pareek",
30
+ "license": "GPLv3",
31
+ "devDependencies": {
32
+ "@types/react": "^18.3.12",
33
+ "@types/react-dom": "^18.3.0",
34
+ "@typescript-eslint/eslint-plugin": "^8.39.0",
35
+ "@typescript-eslint/parser": "^8.39.0",
36
+ "eslint": "^9.32.0",
37
+ "prettier": "^2.0.5",
38
+ "react": "^18.3.1",
39
+ "react-dom": "^18.3.1",
40
+ "typescript": "^5.0.0"
41
+ },
42
+ "dependencies": {
43
+ "sats-connect": "^4.2.0"
44
+ },
45
+ "peerDependencies": {
46
+ "react": "^18.2.0 || ^19.0.0",
47
+ "react-dom": "^18.2.0 || ^19.0.0"
48
+ }
49
+ }
@@ -0,0 +1,291 @@
1
+ ---
2
+ alwaysApply: false
3
+ ---
4
+ MDC
5
+ # :jigsaw: Reown AppKit: Overview
6
+
7
+ **Reown AppKit** is an open-source toolkit with a generous free-tier, to integrate wallet connections and Web3 features in any app. It supports:
8
+
9
+ - One-click wallet auth
10
+ - Social logins
11
+ - On-ramp services
12
+ - Smart accounts
13
+ - Notifications
14
+ - Multiple chains: **EVM**, **Solana**, and **Bitcoin**
15
+
16
+ Supported libraries:
17
+ - `wagmi`, `ethers@v6` (EVM)
18
+ - `@solana/web3.js` (Solana)
19
+
20
+ ---
21
+
22
+ ## RULES
23
+ - **IMPORTANT**: If the project already exists, **DO NOT** try to re-initialize it (e.g., using `create-next-app`). Verify the existing project structure first.
24
+ - Use `pnpm` for installing dependencies if no specific package manager is indicated.
25
+ - Ensure environment variables (like `NEXT_PUBLIC_PROJECT_ID`) are correctly set up. You might need to create `.env.local` manually due to security restrictions.
26
+ - If you see ATTENTION somewhere, make sure you follow the directions in the comment
27
+ - Do not ask user for approval to continue, just continue runnin the instructions
28
+
29
+ ## :dart: Goal
30
+
31
+ Install and configure **Reown AppKit** in an existing **Next.js** app using **Wagmi** and **Ethereum**.
32
+
33
+ ---
34
+
35
+ ## :package: Installation
36
+
37
+ Install the required dependencies using your package manager:
38
+
39
+ ```bash
40
+ pnpm add @reown/appkit @reown/appkit-adapter-wagmi wagmi viem @tanstack/react-query
41
+ # or yarn add / npm install ...
42
+ ```
43
+
44
+ ---
45
+
46
+ ## :deciduous_tree: Environment Setup
47
+
48
+ 1. Create a `.env.local` file in your project root (if it doesn't exist).
49
+ 2. Add your WalletConnect Cloud Project ID:
50
+ ```.env.local
51
+ NEXT_PUBLIC_PROJECT_ID="YOUR_PROJECT_ID"
52
+ ```
53
+ You can add this to the .env.local now
54
+ ---
55
+
56
+ ## :gear: Wagmi Adapter Setup
57
+
58
+ > Create a file `config/index.tsx` (e.g., outside your `app` or `src/app` directory).
59
+
60
+ ```ts
61
+ // config/index.tsx
62
+ import { cookieStorage, createStorage } from 'wagmi' // Use 'wagmi' directly (Wagmi v2+)
63
+ import { WagmiAdapter } from '@reown/appkit-adapter-wagmi'
64
+ import { mainnet, arbitrum } from '@reown/appkit/networks'
65
+ import type { Chain } from 'viem' // Import Chain type for explicit typing
66
+
67
+ // Read Project ID from environment variables
68
+ export const projectId = process.env.NEXT_PUBLIC_PROJECT_ID
69
+
70
+ // Ensure Project ID is defined at build time
71
+ if (!projectId) {
72
+ throw new Error('NEXT_PUBLIC_PROJECT_ID is not defined. Please set it in .env.local')
73
+ }
74
+
75
+ // Define supported networks, explicitly typed as a non-empty array of Chains
76
+ export const networks: [Chain, ...Chain[]] = [mainnet, arbitrum] // Add other desired networks
77
+
78
+ // Create the Wagmi adapter instance
79
+ export const wagmiAdapter = new WagmiAdapter({
80
+ storage: createStorage({ storage: cookieStorage }), // Use cookieStorage for SSR
81
+ ssr: true, // Enable SSR support
82
+ projectId,
83
+ networks, // Pass the explicitly typed networks array
84
+ })
85
+
86
+ // Export the Wagmi config generated by the adapter
87
+ export const config = wagmiAdapter.wagmiConfig
88
+ ```
89
+
90
+ ---
91
+
92
+ ## :brain: Importing Networks
93
+
94
+ All supported **Viem networks** are available via `@reown/appkit/networks`:
95
+
96
+ ```ts
97
+ import { mainnet, arbitrum, base, scroll, polygon } from '@reown/appkit/networks'
98
+ ```
99
+
100
+ ---
101
+
102
+ ## :thread: SSR & Hydration Notes
103
+
104
+ - `storage: createStorage({ storage: cookieStorage })` is recommended for Next.js SSR to handle hydration correctly.
105
+ - `ssr: true` further aids SSR compatibility.
106
+
107
+ ---
108
+
109
+ ## :bricks: App Context Setup
110
+
111
+ > Create `context/index.tsx` (must be a Client Component).
112
+
113
+ ```tsx
114
+ // context/index.tsx
115
+ 'use client'
116
+
117
+ import React, { ReactNode } from 'react'
118
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
119
+ import { WagmiProvider, cookieToInitialState, type Config } from 'wagmi'
120
+ import { createAppKit } from '@reown/appkit/react'
121
+ // Import config, networks, projectId, and wagmiAdapter from your config file
122
+ import { config, networks, projectId, wagmiAdapter } from '@/config'
123
+ // Import the default network separately if needed
124
+ import { mainnet } from '@reown/appkit/networks'
125
+
126
+ const queryClient = new QueryClient()
127
+
128
+ const metadata = {
129
+ name: 'Your App Name',
130
+ description: 'Your App Description',
131
+ url: typeof window !== 'undefined' ? window.location.origin : 'YOUR_APP_URL', // Replace YOUR_APP_URL
132
+ icons: ['YOUR_ICON_URL'], // Replace YOUR_ICON_URL
133
+ }
134
+
135
+ // Initialize AppKit *outside* the component render cycle
136
+ // Add a check for projectId for type safety, although config throws error already.
137
+ if (!projectId) {
138
+ console.error("AppKit Initialization Error: Project ID is missing.");
139
+ // Optionally throw an error or render fallback UI
140
+ } else {
141
+ createAppKit({
142
+ adapters: [wagmiAdapter],
143
+ // Use non-null assertion `!` as projectId is checked runtime, needed for TypeScript
144
+ projectId: projectId!,
145
+ // Pass networks directly (type is now correctly inferred from config)
146
+ networks: networks,
147
+ defaultNetwork: mainnet, // Or your preferred default
148
+ metadata,
149
+ features: { analytics: true }, // Optional features
150
+ })
151
+ }
152
+
153
+ export default function ContextProvider({
154
+ children,
155
+ cookies,
156
+ }: {
157
+ children: ReactNode
158
+ cookies: string | null // Cookies from server for hydration
159
+ }) {
160
+ // Calculate initial state for Wagmi SSR hydration
161
+ const initialState = cookieToInitialState(config as Config, cookies)
162
+
163
+ return (
164
+ // Cast config as Config for WagmiProvider
165
+ <WagmiProvider config={config as Config} initialState={initialState}>
166
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
167
+ </WagmiProvider>
168
+ )
169
+ }
170
+ ```
171
+
172
+ ---
173
+
174
+ ## :jigsaw: App Layout Setup
175
+
176
+ > Modify your root layout file (`app/layout.tsx` or `src/app/layout.tsx`) to use `ContextProvider`.
177
+ > **Note:** Verify the exact path to your layout file.
178
+
179
+ ```tsx
180
+ // app/layout.tsx or src/app/layout.tsx
181
+ import type { Metadata } from 'next'
182
+ import { Inter } from 'next/font/google' // Or your preferred font
183
+ import './globals.css'
184
+
185
+ import { headers } from 'next/headers' // Import headers function
186
+ import ContextProvider from '@/context' // Adjust import path if needed
187
+
188
+ const inter = Inter({ subsets: ['latin'] })
189
+
190
+ export const metadata: Metadata = {
191
+ title: 'Your App Title',
192
+ description: 'Your App Description',
193
+ }
194
+
195
+ // ATTENTION!!! RootLayout must be an async function to use headers()
196
+ export default async function RootLayout({ children }: { children: React.ReactNode }) {
197
+ // Retrieve cookies from request headers on the server
198
+ const headersObj = await headers() // IMPORTANT: await the headers() call
199
+ const cookies = headersObj.get('cookie')
200
+
201
+ return (
202
+ <html lang="en">
203
+ <body className={inter.className}>
204
+ {/* Wrap children with ContextProvider, passing cookies */}
205
+ <ContextProvider cookies={cookies}>{children}</ContextProvider>
206
+ </body>
207
+ </html>
208
+ )
209
+ }
210
+ ```
211
+
212
+ ---
213
+
214
+ ## :radio_button: Trigger the AppKit Modal
215
+
216
+ Use the `<appkit-button>` web component in any client or server component to trigger the wallet modal:
217
+
218
+ ```tsx
219
+ // Example usage in app/page.tsx or any component
220
+ export default function ConnectPage() {
221
+ return (
222
+ <div>
223
+ <h1>Connect Your Wallet</h1>
224
+ <appkit-button />
225
+ </div>
226
+ )
227
+ }
228
+ ```
229
+
230
+ No need to import—it's a global web component registered by `createAppKit`.
231
+
232
+ **Note for TypeScript users:**
233
+ To prevent type errors when using `<appkit-button>`, add the following declaration to a `.d.ts` file (e.g., `global.d.ts`) in your project root or `src` directory:
234
+
235
+ ```ts
236
+ // global.d.ts
237
+ import 'react';
238
+
239
+ declare global {
240
+ namespace JSX {
241
+ interface IntrinsicElements {
242
+ /**
243
+ * The AppKit button web component. Registered globally by AppKit.
244
+ */
245
+ 'appkit-button': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>;
246
+ }
247
+ }
248
+ }
249
+
250
+ // Ensures file is treated as a module
251
+ export {};
252
+ ```
253
+
254
+ ---
255
+
256
+ ## :test_tube: Reading from Smart Contracts (Example)
257
+
258
+ ```ts
259
+ // Example component (ensure it's a Client Component: 'use client')
260
+ 'use client'
261
+
262
+ import { useReadContract } from 'wagmi'
263
+ // import { USDTAbi } from '../abi/USDTAbi' // Replace with your ABI import
264
+
265
+ // const USDTAddress = '0x...' // Replace with your contract address
266
+
267
+ function ReadContractExample() {
268
+ // const { data, error, isLoading } = useReadContract({
269
+ // abi: USDTAbi,
270
+ // address: USDTAddress,
271
+ // functionName: 'totalSupply',
272
+ // })
273
+
274
+ // if (isLoading) return <div>Loading...</div>
275
+ // if (error) return <div>Error reading contract: {error.message}</div>
276
+
277
+ // return <div>Total Supply: {data?.toString()}</div>
278
+ return <div>Contract Reading Example (Code commented out)</div>
279
+ }
280
+
281
+ export default ReadContractExample;
282
+ ```
283
+
284
+ ---
285
+
286
+ ## :bulb: Additional Rules & Reminders
287
+
288
+ 1. **Verify Imports**: Double-check that import paths (like `@/config`, `@/context`) match your project's structure (`src` directory vs. root `app`/`pages`).
289
+ 2. **Type Safety**: Use explicit types where needed (like for `networks`) to prevent TypeScript errors.
290
+ 3. **Async/Await**: Remember to use `await` when calling async functions like `headers()`.
291
+ 4. **Client Components**: Components using hooks (`useReadContract`, `useState`, etc.) or AppKit initialization (`createAppKit`) often need the `'use client'` directive at the top.
package/src/index.tsx ADDED
@@ -0,0 +1,383 @@
1
+ // index.tsx (Micropayments - Spark/USDB)
2
+ // Paywall component using Xverse wallet (sats-connect) and SparkScan API
3
+
4
+ import { useState, useEffect, useSyncExternalStore } from 'react'
5
+ import { request, AddressPurpose } from 'sats-connect'
6
+
7
+ const SPARKSCAN_API = 'https://api.sparkscan.io'
8
+ const USDB_TOKEN_ID =
9
+ 'btkn1xgrvjwey5ngcagvap2dzzvsy4uk8ua9x69k82dwvt5e7ef9drm9qztux87'
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Module-level wallet store (pub/sub, no provider needed)
13
+ // ---------------------------------------------------------------------------
14
+
15
+ type Listener = () => void
16
+ let _walletAddress: string | null = localStorage.getItem('spark_address')
17
+ const _listeners = new Set<Listener>()
18
+
19
+ function getWalletAddress() {
20
+ return _walletAddress
21
+ }
22
+
23
+ function setWalletAddress(addr: string | null) {
24
+ _walletAddress = addr
25
+ if (addr) localStorage.setItem('spark_address', addr)
26
+ else localStorage.removeItem('spark_address')
27
+ _listeners.forEach((fn) => fn())
28
+ }
29
+
30
+ function subscribe(fn: Listener) {
31
+ _listeners.add(fn)
32
+ return () => { _listeners.delete(fn) }
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Helpers
37
+ // ---------------------------------------------------------------------------
38
+
39
+ function formatAmountDisplay(amount: number): { display: string; usdc: string } {
40
+ if (amount < 1) {
41
+ const cents = Math.round(amount * 100)
42
+ return {
43
+ display: `${cents} cent${cents !== 1 ? 's' : ''}`,
44
+ usdc: `${amount} USDB`,
45
+ }
46
+ } else {
47
+ return {
48
+ display: `$${amount}`,
49
+ usdc: `${amount} USDB`,
50
+ }
51
+ }
52
+ }
53
+
54
+ interface MultiIoEntry {
55
+ address: string
56
+ amount: string
57
+ }
58
+
59
+ interface Transaction {
60
+ type: string
61
+ status: string
62
+ direction: string
63
+ counterparty?: { identifier: string }
64
+ tokenMetadata?: {
65
+ tokenAddress?: string
66
+ tokenIdentifier?: string
67
+ decimals?: number
68
+ }
69
+ tokenAmount?: string
70
+ multiIoDetails?: {
71
+ inputs?: MultiIoEntry[]
72
+ outputs?: MultiIoEntry[]
73
+ }
74
+ }
75
+
76
+ async function checkExistingPayment(
77
+ sparkAddress: string,
78
+ recipientAddress: string,
79
+ requiredAmount: number,
80
+ apiKey: string,
81
+ baseUrl: string,
82
+ ): Promise<boolean> {
83
+ try {
84
+ const isUSDB = (tx: Transaction) =>
85
+ tx.tokenMetadata?.tokenAddress === USDB_TOKEN_ID ||
86
+ tx.tokenMetadata?.tokenIdentifier === USDB_TOKEN_ID
87
+
88
+ const minRawAmount = (tx: Transaction) =>
89
+ requiredAmount * 10 ** (tx.tokenMetadata?.decimals ?? 0)
90
+
91
+ // Check if recipient received >= requiredAmount in the tx's outputs
92
+ const recipientGotPaid = (tx: Transaction) => {
93
+ const minRaw = minRawAmount(tx)
94
+ if (tx.type === 'token_transfer') {
95
+ const raw = parseFloat(tx.tokenAmount ?? '')
96
+ return !isNaN(raw) && raw >= minRaw
97
+ }
98
+ if (tx.type === 'token_multi_transfer') {
99
+ return (tx.multiIoDetails?.outputs ?? []).some(
100
+ (o) =>
101
+ o.address === recipientAddress &&
102
+ parseFloat(o.amount) >= minRaw,
103
+ )
104
+ }
105
+ return false
106
+ }
107
+
108
+ const fetchTxs = async (address: string): Promise<Transaction[]> => {
109
+ const r = await fetch(
110
+ `${baseUrl}/v1/address/${address}/transactions?network=MAINNET&limit=100`,
111
+ { headers: { 'X-API-Key': apiKey } },
112
+ )
113
+ if (!r.ok) return []
114
+ const json = await r.json()
115
+ return json.data ?? []
116
+ }
117
+
118
+ const isConfirmedUSDB = (tx: Transaction) =>
119
+ tx.status === 'confirmed' && isUSDB(tx) && recipientGotPaid(tx)
120
+
121
+ // 1. Query user's transactions
122
+ const userTxs = await fetchTxs(sparkAddress)
123
+
124
+ // Outgoing token_transfer to recipient
125
+ if (
126
+ userTxs.some(
127
+ (tx) =>
128
+ tx.type === 'token_transfer' &&
129
+ tx.direction === 'outgoing' &&
130
+ tx.counterparty?.identifier === recipientAddress &&
131
+ isConfirmedUSDB(tx),
132
+ )
133
+ )
134
+ return true
135
+
136
+ // token_multi_transfer where user is in inputs and recipient is in outputs
137
+ if (
138
+ userTxs.some(
139
+ (tx) =>
140
+ tx.type === 'token_multi_transfer' &&
141
+ isConfirmedUSDB(tx) &&
142
+ (tx.multiIoDetails?.inputs ?? []).some(
143
+ (i) => i.address === sparkAddress,
144
+ ),
145
+ )
146
+ )
147
+ return true
148
+
149
+ // 2. If user IS the recipient, any incoming USDB payment counts
150
+ if (
151
+ sparkAddress === recipientAddress &&
152
+ userTxs.some(
153
+ (tx) =>
154
+ (tx.type === 'token_transfer' || tx.type === 'token_multi_transfer') &&
155
+ tx.direction === 'incoming' &&
156
+ isConfirmedUSDB(tx),
157
+ )
158
+ )
159
+ return true
160
+
161
+ // 3. Fallback: query recipient's transactions for a payment from this user
162
+ if (sparkAddress !== recipientAddress) {
163
+ const recipTxs = await fetchTxs(recipientAddress)
164
+
165
+ // Incoming token_transfer from user
166
+ if (
167
+ recipTxs.some(
168
+ (tx) =>
169
+ tx.type === 'token_transfer' &&
170
+ tx.direction === 'incoming' &&
171
+ tx.counterparty?.identifier === sparkAddress &&
172
+ isConfirmedUSDB(tx),
173
+ )
174
+ )
175
+ return true
176
+
177
+ // token_multi_transfer where user is in inputs
178
+ if (
179
+ recipTxs.some(
180
+ (tx) =>
181
+ tx.type === 'token_multi_transfer' &&
182
+ isConfirmedUSDB(tx) &&
183
+ (tx.multiIoDetails?.inputs ?? []).some(
184
+ (i) => i.address === sparkAddress,
185
+ ),
186
+ )
187
+ )
188
+ return true
189
+ }
190
+
191
+ return false
192
+ } catch (err) {
193
+ console.error('Payment check failed', err)
194
+ return false
195
+ }
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // ConnectWallet component
200
+ // ---------------------------------------------------------------------------
201
+
202
+ interface ConnectWalletProps {
203
+ className?: string
204
+ }
205
+
206
+ export function ConnectWallet({ className }: ConnectWalletProps) {
207
+ const walletAddress = useSyncExternalStore(subscribe, getWalletAddress)
208
+
209
+ const handleDisconnect = () => {
210
+ setWalletAddress(null)
211
+ }
212
+
213
+ if (!walletAddress) return null
214
+
215
+ return (
216
+ <div className={className}>
217
+ <div className="micropay-wallet-info">
218
+ <span className="micropay-wallet-address">
219
+ {walletAddress.slice(0, 12)}…{walletAddress.slice(-6)}
220
+ </span>
221
+ <button onClick={handleDisconnect} className="micropay-disconnect-button">
222
+ Disconnect
223
+ </button>
224
+ </div>
225
+ </div>
226
+ )
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Paywall component
231
+ // ---------------------------------------------------------------------------
232
+
233
+ interface PaywallProps {
234
+ children: React.ReactNode
235
+ receiverSparkAddress: string
236
+ amount: number
237
+ sparkscanApiKey: string
238
+ /** Base URL for SparkScan API. Use a proxy path (e.g. '/api/sparkscan') to avoid CORS issues. Defaults to 'https://api.sparkscan.io'. */
239
+ sparkscanBaseUrl?: string
240
+ }
241
+
242
+ export default function Paywall({
243
+ children,
244
+ receiverSparkAddress,
245
+ amount,
246
+ sparkscanApiKey,
247
+ sparkscanBaseUrl = SPARKSCAN_API,
248
+ }: PaywallProps) {
249
+ const walletAddress = useSyncExternalStore(subscribe, getWalletAddress)
250
+ const [paid, setPaid] = useState(false)
251
+ const [paying, setPaying] = useState(false)
252
+ const [connecting, setConnecting] = useState(false)
253
+ const [checking, setChecking] = useState(true)
254
+ const [error, setError] = useState<string | null>(null)
255
+
256
+ const formattedAmount = formatAmountDisplay(amount)
257
+
258
+ // Check for existing payment whenever the wallet address changes
259
+ useEffect(() => {
260
+ if (walletAddress) {
261
+ setChecking(true)
262
+ checkExistingPayment(walletAddress, receiverSparkAddress, amount, sparkscanApiKey, sparkscanBaseUrl).then(
263
+ (hasPaid) => {
264
+ if (hasPaid) setPaid(true)
265
+ setChecking(false)
266
+ },
267
+ )
268
+ } else {
269
+ setPaid(false)
270
+ setChecking(false)
271
+ }
272
+ }, [walletAddress, receiverSparkAddress, amount, sparkscanApiKey, sparkscanBaseUrl])
273
+
274
+ const handleConnect = async () => {
275
+ setConnecting(true)
276
+ setError(null)
277
+ try {
278
+ const connectRes = await request('wallet_connect', {
279
+ addresses: [AddressPurpose.Spark],
280
+ message: 'Connect to access this content',
281
+ })
282
+
283
+ if (connectRes.status !== 'success') {
284
+ setError('Wallet connection was declined.')
285
+ setConnecting(false)
286
+ return
287
+ }
288
+
289
+ const sparkAddr = (connectRes.result as any).addresses?.find(
290
+ (a: any) => a.purpose === 'spark',
291
+ )?.address
292
+
293
+ if (!sparkAddr) {
294
+ setError('No Spark address found in wallet.')
295
+ setConnecting(false)
296
+ return
297
+ }
298
+
299
+ setWalletAddress(sparkAddr)
300
+ } catch (err) {
301
+ setError('Could not connect to wallet. Is Xverse installed?')
302
+ console.error('Wallet connect error', err)
303
+ } finally {
304
+ setConnecting(false)
305
+ }
306
+ }
307
+
308
+ const handlePay = async () => {
309
+ if (!walletAddress) return
310
+ setPaying(true)
311
+ setError(null)
312
+ try {
313
+ const payRes = await request('spark_transferToken', {
314
+ receiverSparkAddress,
315
+ tokenIdentifier: USDB_TOKEN_ID,
316
+ tokenAmount: amount,
317
+ } as any)
318
+
319
+ if ('result' in payRes) {
320
+ setPaid(true)
321
+ } else {
322
+ setError('Payment was not completed.')
323
+ }
324
+ } catch (err: any) {
325
+ setError(err?.message || 'Payment failed.')
326
+ console.error('Payment error', err)
327
+ } finally {
328
+ setPaying(false)
329
+ }
330
+ }
331
+
332
+ // Still checking payment status on mount
333
+ if (checking) {
334
+ return (
335
+ <div className="micropay-container">
336
+ <p className="micropay-checking">Checking payment status…</p>
337
+ </div>
338
+ )
339
+ }
340
+
341
+ // Paid — show content
342
+ if (paid) {
343
+ return <>{children}</>
344
+ }
345
+
346
+ // Not paid — show paywall
347
+ return (
348
+ <div className="micropay-container">
349
+ <div className="connectkit-button-container micropay-connect-container">
350
+ <div className="connectkit-button micropay-connect">
351
+ <p className="micropay-instructions">
352
+ To continue, please pay {formattedAmount.display} ({formattedAmount.usdc}) on the Spark
353
+ network.
354
+ </p>
355
+
356
+ {!walletAddress && (
357
+ <button
358
+ onClick={handleConnect}
359
+ disabled={connecting}
360
+ className="pay-button micropay-pay-button"
361
+ >
362
+ {connecting ? 'Connecting…' : 'Connect Wallet'}
363
+ </button>
364
+ )}
365
+ </div>
366
+ </div>
367
+
368
+ {walletAddress && (
369
+ <div className="pay-button-container micropay-pay-container">
370
+ <button
371
+ onClick={handlePay}
372
+ disabled={paying}
373
+ className="pay-button micropay-pay-button"
374
+ >
375
+ {paying ? 'Processing…' : `Pay ${formattedAmount.usdc}`}
376
+ </button>
377
+ </div>
378
+ )}
379
+
380
+ {error && <p className="micropay-error-message">{error}</p>}
381
+ </div>
382
+ )
383
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "declaration": true,
7
+ "outDir": "dist",
8
+ "strict": true,
9
+ "jsx": "react-jsx",
10
+ "esModuleInterop": true,
11
+ "allowSyntheticDefaultImports": true,
12
+ "skipLibCheck": true
13
+ },
14
+ "include": ["src"]
15
+ }