Server integration
Complete the React quickstart first. This guide adds a separate backend. Native transfers do not need it.
Server verification (optional)
Section titled “Server verification (optional)”The native transfer above does not need your own proposal backend. For server-side sign-in verification, create server/pouchi.ts from the example below. It implements GET /api/pouchi/me. Set POUCHI_API_URL, POUCHI_TENANT_ID and POUCHI_APP_ORIGIN on the server, install tsx and Node types, and run it separately:
npm install --save-dev tsx @types/nodeexport POUCHI_API_URL=YOUR_POUCHI_API_URLexport POUCHI_TENANT_ID=YOUR_POUCHI_APP_IDexport POUCHI_APP_ORIGIN=http://localhost:5173npx tsx server/pouchi.tstsx does not load .env.local automatically. Set these server variables in that terminal first. POUCHI_APP_ORIGIN is the exact browser origin, such as http://localhost:5173.
import { createServer } from "node:http";import { createNcWaasServerClient } from "@shard-lab/nc-waas-react/server";import type { TransactionProposalInput } from "@shard-lab/nc-waas-react";import { decodeFunctionData, encodeFunctionData, isAddress, parseAbi } from "viem";
function required(name: string): string { const value = process.env[name]?.trim(); if (!value) throw new Error(`Set ${name} on the server.`); return value;}function address(name: string): `0x${string}` { const value = required(name); if (!isAddress(value)) throw new Error(`${name} must be an EVM address.`); return value;}const tenantId = required("POUCHI_TENANT_ID");const origin = required("POUCHI_APP_ORIGIN");if (new URL(origin).origin !== origin) throw new Error("POUCHI_APP_ORIGIN must be an exact origin.");const pouchi = createNcWaasServerClient({ apiUrl: required("POUCHI_API_URL"), tenantId, // Optional for /me. Both are required for /propose (owner-intents:create). credentialId: process.env.POUCHI_CREDENTIAL_ID, clientSecret: process.env.POUCHI_CLIENT_SECRET});const transferAbi = parseAbi(["function transfer(address to, uint256 amount) returns (bool)"]);
export async function currentUser(authorization: string | undefined) { if (!authorization?.startsWith("Bearer ")) throw new Error("Sign in first."); const claims = await pouchi.verifyAuthToken(authorization.slice(7)); if (claims.chainId !== 450815) throw new Error("Expected Maroo Testnet."); return { userId: claims.userId, walletAddress: claims.walletAddress, chainId: claims.chainId };}
// Optional ERC-20 example. Native transfers use the SDK's direct path, not this hook.export async function propose(body: unknown) { required("POUCHI_CREDENTIAL_ID"); required("POUCHI_CLIENT_SECRET"); const tokenContract = address("POUCHI_TOKEN_CONTRACT"); const recipient = address("POUCHI_TEST_RECIPIENT"); const rawAmount = required("POUCHI_TOKEN_AMOUNT"); if (!/^[1-9][0-9]*$/.test(rawAmount)) throw new Error("POUCHI_TOKEN_AMOUNT must be positive base units."); const amount = BigInt(rawAmount); if (!body || typeof body !== "object") throw new Error("Invalid proposal."); const input = body as Partial<TransactionProposalInput>; if (typeof input.accessToken !== "string" || input.tenantId !== tenantId || input.chainId !== 450815 || typeof input.to !== "string" || input.to.toLowerCase() !== tokenContract.toLowerCase() || input.value !== "0" || typeof input.data !== "string" || !/^0x[0-9a-fA-F]+$/.test(input.data)) { throw new Error("Proposal is outside this example's limits."); } const claims = await pouchi.verifyAuthToken(input.accessToken); if (claims.walletId !== input.walletId || claims.chainId !== 450815 || typeof input.walletAddress !== "string" || claims.walletAddress.toLowerCase() !== input.walletAddress.toLowerCase()) throw new Error("Wallet mismatch."); const data = input.data as `0x${string}`; const call = decodeFunctionData({ abi: transferAbi, data }); const expectedData = encodeFunctionData({ abi: transferAbi, functionName: "transfer", args: [recipient, amount] }); if (call.functionName !== "transfer" || call.args[0].toLowerCase() !== recipient.toLowerCase() || call.args[1] !== amount || data.toLowerCase() !== expectedData.toLowerCase()) throw new Error("Transfer not allowed."); return pouchi.proposeTransaction({ walletId: claims.walletId, to: tokenContract, value: "0", data: expectedData, title: "Test token transfer", requester: { appName: "My App", origin } });}
const server = createServer(async (request, response) => { response.setHeader("content-type", "application/json"); response.setHeader("cache-control", "no-store"); if (request.url === "/api/pouchi/me" && request.method === "GET") { try { response.writeHead(200).end(JSON.stringify(await currentUser(request.headers.authorization))); } catch { response.writeHead(401).end(JSON.stringify({ error: "Sign in required." })); } return; } if (request.url !== "/api/pouchi/propose") { response.writeHead(404).end('{}'); return; } if (request.method !== "POST") { response.setHeader("allow", "POST"); response.writeHead(405).end('{}'); return; } if (request.headers["content-type"]?.split(";")[0] !== "application/json") { response.writeHead(415).end('{}'); return; } try { let body = ""; for await (const chunk of request) { body += chunk.toString(); if (Buffer.byteLength(body) > 16_384) { response.writeHead(413).end('{}'); return; } } response.writeHead(200).end(JSON.stringify(await propose(JSON.parse(body)))); } catch { // Do not log request bodies, tokens, credentials or upstream error details. response.writeHead(400).end(JSON.stringify({ error: "Proposal rejected." })); }});server.listen(31082, "127.0.0.1");Configure a development proxy in vite.config.ts so /api reaches the loopback server. Restart Vite after this change.
import { defineConfig } from "vite";import react from "@vitejs/plugin-react";import { pouchiWasm } from "./pouchi-vite";export default defineConfig({ plugins: [react(), pouchiWasm()], server: { proxy: { "/api": "http://127.0.0.1:31082" } }});Create src/VerifyUser.tsx from this component. In App.tsx, import { VerifyUser } from "./VerifyUser" and add <VerifyUser /> below the wallet address. Sign in, then press Verify user on server. The backend returns the verified wallet address and chain; tokens are never shown.
import { useState } from "react";import { usePrivy } from "@shard-lab/nc-waas-react";
export function VerifyUser() { const { authenticated, getAccessToken } = usePrivy(); const [pending, setPending] = useState(false); const [message, setMessage] = useState(""); const verify = async () => { setPending(true); setMessage(""); try { const token = await getAccessToken(); if (!token) throw new Error("Sign in first."); const response = await fetch("/api/pouchi/me", { headers: { Authorization: `Bearer ${token}` } }); if (!response.ok) throw new Error("Server verification failed."); const user = await response.json(); setMessage(`Verified wallet: ${user.walletAddress} (chain ${user.chainId})`); } catch (cause) { setMessage(cause instanceof Error ? cause.message : "Verification failed."); } finally { setPending(false); } }; return <section> <button disabled={!authenticated || pending} onClick={() => void verify()}> {pending ? "Verifying..." : "Verify user on server"} </button> <p role="status">{message}</p> </section>;}Server-approved contract calls (advanced)
Section titled “Server-approved contract calls (advanced)”config.transactions.propose applies to general contract calls. Native transfers and ERC-20 sends recognized as SDK-supported token transfers bypass this hook. It cannot enforce server authorization for every transfer. Use a test ERC-20 contract distinct from the supported transfer token for this wallet. The optional POST /api/pouchi/propose route is disabled until you provide an ERC-20 contract, recipient, exact amount and a server Client Credential authorized for proposal creation. The server only accepts that exact encoded transfer with zero native value, verifies the wallet owner and Maroo chain, then requests user approval.
import type { NcWaasConfig, TransactionProposer } from "@shard-lab/nc-waas-react";
const propose: TransactionProposer = async (input) => { const response = await fetch("/api/pouchi/propose", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input) }); if (!response.ok) throw new Error("The server rejected the proposal."); const result: unknown = await response.json(); if (!result || typeof result !== "object" || !("signingRequestId" in result) || typeof result.signingRequestId !== "string") throw new Error("Invalid proposal response."); return { signingRequestId: result.signingRequestId };};
export const transactionConfig: Pick<NcWaasConfig, "transactions"> = { transactions: { propose }};Import transactionConfig from ./proposer and merge it into the provider’s existing config. Without this merge, the hook is not connected:
config={{ loginMethods: ["email"], defaultChain: marooTestnet, transactions: transactionConfig.transactions}}This is a configured contract-call example, not a generic payment backend. Validate your own purchase and authorization rules before using another contract. A clientSecret stays on the server and never in a VITE_ variable. JWT verification alone does not require a Client Credential.
Set server-only POUCHI_CREDENTIAL_ID, POUCHI_CLIENT_SECRET, POUCHI_TOKEN_CONTRACT, POUCHI_TEST_RECIPIENT and POUCHI_TOKEN_AMOUNT. The amount is a positive integer in token base units. The wallet needs that token balance, and the app needs gas sponsorship and budget. Call the same configured contract, recipient and amount from an event handler in the signed-in component. First supply validated public addresses and the amount:
import { encodeFunctionData, parseAbi } from "viem";
// sendTransaction comes from useSendTransaction() in the component.await sendTransaction({ to: tokenContract, value: 0n, data: encodeFunctionData({ abi: parseAbi(["function transfer(address to, uint256 amount) returns (bool)"]), functionName: "transfer", args: [testRecipient, tokenAmount] })});Confirm a request to /api/pouchi/propose in the browser Network panel, then check the successful chain receipt after approval. Without that request, you have not verified the server path. This is an advanced configured example requiring your own contract and credentials, not a runtime-verified walkthrough.