better-near-auth
Version:
Sign in with NEAR (SIWN) plugin for Better Auth
470 lines (355 loc) • 16.5 kB
Markdown
---
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