UNPKG

better-near-auth

Version:

Sign in with NEAR (SIWN) plugin for Better Auth

470 lines (355 loc) 16.5 kB
--- name: client description: > Set up the siwnClient plugin for Better Auth client, configure NEAR wallet connection via NearConnect, use authClient.near actions for sign-in, profile lookup, account management, delegate action building with TransactionBuilder, and relay submission. Load when implementing NEAR wallet sign-in on the client, using authClient.near.* methods, or building delegate actions for gasless relay. metadata: type: core library: better-near-auth library_version: "1.7.3" sources: - "elliotBraem/better-near-auth:src/client.ts" - "elliotBraem/better-near-auth:src/types.ts" - "elliotBraem/better-near-auth:README.md" - "elliotBraem/better-near-auth:LLM.txt" --- # Better-Near-Auth — Client Integration Client-side plugin for NEAR wallet authentication and gasless relay. Connects to NEAR wallets via NearConnect, provides `authClient.near.*` actions for sign-in, account management, profile lookup, and delegate action building. ## SSR Behavior `siwnClient()` is SSR-safe. Wallet resources (`NearConnector`, `Near`, event listeners) are lazily initialized on first client-side access — they do not run at construction time. On the server: - `getAccountId()` returns `null` (no session restore yet) - `getState()` returns `null` - `isWalletConnected()` returns `false` - `buildSignedDelegateAction`, `signWithWallet`, `ensureConnected` throw "Wallet not initialized — this operation requires a browser environment" - `near.client` getter throws on access - HTTP-only methods (`nonce`, `verify`, `view`, `relayTransaction`, etc.) work normally via `$fetch` ## Setup ### Simple app (module singleton) ```typescript import { createAuthClient } from "better-auth/client"; import { siwnClient } from "better-near-auth/client"; export const authClient = createAuthClient({ plugins: [ siwnClient({ recipient: "myapp.com", networkId: "mainnet", }), ], }); ``` ### SSR app (router context) For TanStack Router with server rendering, create the auth client once in your router setup and access via context: ```typescript // auth.ts import { createAuthClient as createBetterAuthClient } from "better-auth/react"; import { siwnClient } from "better-near-auth/client"; import { useRouter } from "@tanstack/react-router"; export function createAuthClient() { return createBetterAuthClient({ plugins: [siwnClient({ recipient: getAccount(), networkId: getNetworkId() })], }); } export type AuthClient = ReturnType<typeof createAuthClient>; export function useAuthClient(): AuthClient { return useRouter().options.context.authClient; } // hydrate.tsx — create once, put in router context const { router } = createRouter({ context: { authClient: createAuthClient() }, }); ``` A module-level singleton is shared across SSR requests, causing data leaks. Router context gives one client per request on server, one per app on client. The `recipient` must match the server's `siwn()` recipient exactly. `networkId` defaults to `"mainnet"`. ## Core Patterns ### Sign in with NEAR wallet ```typescript await authClient.signIn.near({ onSuccess: () => { console.log("Signed in!"); }, onError: (error) => { console.error("Sign in failed:", error.message); }, }); ``` `signIn.near()` handles the full flow: wallet connection → NEP-413 message signing → server verification → session creation. It automatically detects wallet capabilities and uses single-step or two-step flow. ### Build and relay a delegate action ```typescript import { Gas } from "near-kit"; const payload = await authClient.near.buildSignedDelegateAction( "myapp.near", (builder, receiverId) => builder.functionCall(receiverId, "some_method", { key: "value" }, { gas: Gas.Tgas(30), attachedDeposit: BigInt(0), }) ); const result = await authClient.near.relayTransaction({ payload }); console.log("Tx hash:", result.data.txHash); const status = await authClient.near.getRelayStatus(result.data.txHash); console.log("Status:", status.data.status); ``` The `buildSignedDelegateAction` callback receives a `TransactionBuilder` from near-kit and the `receiverId`. Use `builder.functionCall()` to construct actions. The builder calls `.delegate()` internally and returns a base64 payload string. `buildSignedDelegateAction` automatically calls `ensureConnected()` if the wallet is disconnected, prompting the user to reconnect. ### Direct send (user pays gas) ```typescript // Must ensure wallet is connected before .send() await authClient.near.ensureConnected(); return authClient.near.client .transaction(accountId) .functionCall(contractId, "method", args, { gas: Gas.Tgas(30), attachedDeposit: BigInt(0), }) .send({ waitUntil: "FINAL" }); ``` Wallet extensions (Meteor, HERE) may disconnect after the initial sign-in popup. Always call `ensureConnected()` before `.send()` — it opens a reconnection prompt if needed. `buildSignedDelegateAction` does this automatically, but direct `.send()` does not. ### Wallet state and profile management ```typescript // Get current account (persists across wallet disconnects) const accountId = authClient.near.getAccountId(); // string | null // Check if signing operations are available const canSign = authClient.near.isWalletConnected(); // boolean // Prompt wallet reconnection if disconnected const connected = await authClient.near.ensureConnected(); // Promise<boolean> // Get full wallet state const state = authClient.near.getState(); // { accountId, publicKey, networkId } | null // Get profile const profile = await authClient.near.getProfile(); const aliceProfile = await authClient.near.getProfile("alice.near"); // Disconnect wallet await authClient.near.disconnect(); ``` When the wallet disconnects externally (user signs out from wallet UI), `nearState` preserves `accountId` but clears `publicKey`. Use `isWalletConnected()` to check if signing operations are available, and `getAccountId()` for display purposes (works even when disconnected). ### Sub-account creation Create named sub-accounts under a parent account (e.g. `myapp.parent.near`). The relayer pays gas for the creation transaction. ```typescript // 1. Check availability before creating const { data: availability } = await authClient.near.checkSubAccountAvailability({ subAccountName: "myapp", }); if (!availability.available) { console.log("Not available:", availability.reason); // reason: "taken" | "invalid" | "too-long" | "not-configured" return; } // 2. Create sub-account with the user's public key for full access const { data: result } = await authClient.near.createSubAccount({ subAccountName: "myapp", publicKey: "ed25519:...", }); console.log(result.accountId); // myapp.parent.near ``` The client does lightweight validation before calling the server: `checkSubAccountAvailability` returns `{ available: false, reason: "invalid" }` immediately for names failing the regex/length check, skipping the server round-trip. The server must have `subAccount` configured (see siwn skill) and a named relayer account. ### Account linking and contract view calls ```typescript // Link another NEAR account to current session await authClient.near.link({ onSuccess: () => console.log("Linked"), onError: (err) => console.error(err), }); // Unlink await authClient.near.unlink({ accountId: "alice.near" }); // List linked accounts const { data } = await authClient.near.listAccounts(); // Server-side read-only contract call const result = await authClient.near.view({ contractId: "myapp.near", methodName: "get_value", args: { account_id: "alice.near" }, }); ``` ## Client Actions Reference ### authClient.near | Method | Returns | Description | | ------ | ------- | ----------- | | `nonce(params)` | `Promise<Response<NonceResponse>>` | Request nonce from server | | `verify(params)` | `Promise<Response<VerifyResponse>>` | Verify NEP-413 signature | | `getProfile(accountId?)` | `Promise<Response<Profile>>` | Get NEAR profile | | `view(params)` | `Promise<Response<ViewResponse>>` | Server-side contract view call | | `getAccountId()` | `string \| null` | Currently connected account ID (persists across disconnects) | | `getState()` | `{ accountId, publicKey, networkId } \| null` | Wallet state | | `isWalletConnected()` | `boolean` | Whether wallet is actively connected | | `ensureConnected()` | `Promise<boolean>` | Reconnect wallet if disconnected | | `disconnect()` | `Promise<void>` | Disconnect wallet | | `link(callbacks?)` | `Promise<void>` | Link NEAR account to session | | `unlink(params)` | `Promise<Response>` | Unlink NEAR account | | `listAccounts()` | `Promise<Response>` | List linked NEAR accounts | | `setPrimaryAccount(params)` | `Promise<Response<SetPrimaryAccountResponse>>` | Set primary linked NEAR account | | `createSubAccount(params)` | `Promise<Response<CreateSubAccountResponse>>` | Create a sub-account | | `checkSubAccountAvailability(params)` | `Promise<Response<CheckSubAccountAvailabilityResponse>>` | Check if a sub-account name is available | | `buildSignedDelegateAction(receiverId, buildActions)` | `Promise<string>` | Build + sign delegate action, returns base64 payload | | `relayTransaction({ payload })` | `Promise<Response<RelayResponse>>` | Submit delegate action to relayer | | `getRelayStatus(txHash)` | `Promise<Response<RelayStatusResponse>>` | Check relayed tx status | | `getRelayerInfo()` | `Promise<Response<RelayerInfo>>` | Get relayer info and balance | | `relayHistory()` | `Promise<Response<RelayHistoryResponse>>` | List relayed transactions | | `setNetwork(network)` | `void` | Switch active network (mainnet/testnet) | | `getNetwork()` | `"mainnet" \| "testnet"` | Get currently active network | | `getSupportedNetworks()` | `("mainnet" \| "testnet")[]` | List supported networks | | `getRecipient(network?)` | `string` | Get configured recipient for a network | | `client` | `Near` | Access near-kit Near instance (throws on server) | ### authClient.signIn | Method | Description | | ------ | ----------- | | `near(callbacks?)` | Connect wallet, sign message, verify — single popup | ### Callback Interface ```typescript interface AuthCallbacks { onSuccess?: () => void; onError?: (error: Error & { status?: number; code?: string }) => void; } ``` ## Common Mistakes ### CRITICAL Creating multiple siwnClient instances Wrong: ```typescript // Factory that creates new client each call function getAuthClient(config) { return createAuthClient({ plugins: [siwnClient({ recipient: getAccount(config) })], }); } // Each call = new nearState atom = wallet state lost ``` Correct: ```typescript // Single module-level instance (simple apps) export const authClient = createAuthClient({ plugins: [siwnClient({ recipient: "myapp.near" })], }); // OR: router context singleton (SSR apps) export function createAuthClient() { return createBetterAuthClient({ plugins: [siwnClient({ recipient: getAccount() })], }); } // Create once in router setup, access via context ``` `siwnClient()` creates stateful singletons: a `nearState` atom, a `walletConnected` atom, a `NearConnector` with event listeners, and a `Near` instance. Multiple instances means wallet sign-in populates one atom while your app reads from another. Always create exactly one `siwnClient()` per app lifecycle. Source: src/client.ts:64-72 ### CRITICAL Recipient mismatch between server and client Wrong: ```typescript // Server siwn({ recipient: "myapp.near" }); // Client siwnClient({ recipient: "myapp.com" }); ``` Correct: ```typescript // Server siwn({ recipient: "myapp.com" }); // Client siwnClient({ recipient: "myapp.com" }); ``` The client embeds the recipient in the signed message. The server verifies against its own recipient. A mismatch causes the verify endpoint to reject the signature with "Unauthorized: Invalid signature" — which is confusing because the signature is valid, just for a different recipient. Source: src/client.ts:108, src/index.ts:225 See also: siwn/SKILL.md — server plugin recipient configuration ### HIGH Using near.client.send() without ensuring wallet connection Wrong: ```typescript authClient.near.client.transaction(accountId) .functionCall(contract, "method", args, opts) .send(); // fails if wallet disconnected after sign-in ``` Correct: ```typescript await authClient.near.ensureConnected(); // reconnects wallet if needed authClient.near.client.transaction(accountId) .functionCall(contract, "method", args, opts) .send(); ``` Wallet extensions (Meteor, HERE) may disconnect after the initial sign-in popup. `ensureConnected()` opens a reconnection prompt if needed. `buildSignedDelegateAction` calls this automatically, but direct `.send()` does not. Source: src/client.ts:249-253, src/client.ts:310-317 ### HIGH Constructing transactions with wrong builder pattern Wrong: ```typescript // Wrong: trying to use .send() directly instead of delegate flow const result = await near.transaction(accountId) .functionCall(receiverId, "method", args, { gas: Gas.Tgas(30) }) .send({ waitUntil: "EXECUTED" }); ``` Correct: ```typescript // Correct: use buildSignedDelegateAction + relayTransaction const payload = await authClient.near.buildSignedDelegateAction( "myapp.near", (builder, receiverId) => builder.functionCall(receiverId, "method", args, { gas: Gas.Tgas(30), attachedDeposit: BigInt(0), }) ); const result = await authClient.near.relayTransaction({ payload }); ``` Delegate actions must be built through `buildSignedDelegateAction`, which uses near-kit's `TransactionBuilder.delegate()` method. Direct `.send()` broadcasts from the client account and requires the user to pay gas — defeating the gasless relay purpose. Source: src/client.ts:240-260 See also: relay/SKILL.md — relay endpoint validation and whitelisting ### MEDIUM Using authClient.near.verify directly instead of signIn.near Wrong: ```typescript // Manually calling verify without proper wallet signing flow await authClient.near.verify({ signedMessage: someMessage, message: "Sign in to myapp.com", recipient: "myapp.com", nonce: someNonce, accountId: "alice.near", }); ``` Correct: ```typescript // Use the single-step sign-in method that handles the full flow await authClient.signIn.near({ onSuccess: () => { /* signed in */ }, onError: (error) => { console.error(error); }, }); ``` `signIn.near()` handles wallet connection, NEP-413 message signing, nonce management, and server verification in a single call. Calling `verify` directly requires manually constructing all parameters and does not handle wallet connection. Source: src/client.ts:402-437 ### MEDIUM Not subscribing to wallet disconnect events Wrong: ```typescript // Read account ID once — never updates const accountId = authClient.near.getAccountId(); ``` Correct: ```typescript // Subscribe to nearState for reactive updates import { useStore } from "nanostores/react"; const { nearState } = authClient.$store; const state = useStore(nearState); // state preserves accountId when wallet disconnects // publicKey becomes null, walletConnected becomes false // Or check signing availability: const canSign = authClient.near.isWalletConnected(); ``` When the wallet disconnects externally (user signs out from wallet UI), `nearState` preserves `accountId` but clears `publicKey` and sets `walletConnected` to false. Use `isWalletConnected()` to check if signing operations are available, and `getAccountId()` for display purposes (works even when disconnected). Source: src/client.ts:65-66, src/client.ts:102-108 ### MEDIUM Creating sub-accounts without checking availability first Wrong: ```typescript await authClient.near.createSubAccount({ subAccountName: "myapp", publicKey: "ed25519:...", }); // Server throws 409 "Account already exists" if taken ``` Correct: ```typescript const { data } = await authClient.near.checkSubAccountAvailability({ subAccountName: "myapp", }); if (!data.available) { console.log("Sub-account not available:", data.reason); return; } await authClient.near.createSubAccount({ subAccountName: "myapp", publicKey: "ed25519:...", }); ``` Always check availability first to avoid unnecessary server round-trips and 409 CONFLICT errors. The availability check is cheap (regex + length check client-side, then RPC account lookup server-side). Source: src/client.ts:537-548, src/index.ts:1406-1412