| name | relay |
| description | Configure the gasless NEP-366 delegate action relayer in ephemeral or explicit mode, relay signed delegate actions on-chain, enforce contract whitelisting and gas/deposit limits, check relay status and history, and use the contract view endpoint. Load when setting up relayer config, debugging relay failures, or configuring RotatingKeyStore for high-throughput relay.
|
| metadata | {"type":"core","library":"better-near-auth","library_version":"1.7.3"} |
| sources | ["elliotBraem/better-near-auth:src/index.ts","elliotBraem/better-near-auth:src/utils.ts","elliotBraem/better-near-auth:src/types.ts","elliotBraem/better-near-auth:src/schema.ts","elliotBraem/better-near-auth:README.md","elliotBraem/better-near-auth:LLM.txt"] |
Better-Near-Auth — Gasless Relay
Built-in NEP-366 delegate action relayer that broadcasts signed transactions on behalf of authenticated users, paying gas from a relayer account. Supports ephemeral mode (auto-generated keypair) and explicit mode (named account with provided keys).
Setup
Ephemeral mode (zero-config)
import { siwn } from "better-near-auth";
export const auth = betterAuth({
plugins: [
siwn({
recipient: "myapp.com",
relayer: {},
}),
],
});
On first startup, the server logs the ephemeral account ID. Fund this account with NEAR to enable relay:
[siwn] Relayer created in EPHEMERAL mode: 7a3c4b5c... (mainnet)
[siwn] Fund this account with NEAR to enable gasless relay
[siwn] Private key is encrypted in DB — persists across restarts
The private key is encrypted with AES-256-GCM using BETTER_AUTH_SECRET as the KEK and stored in the relayerKey database table.
Explicit mode (production)
siwn({
recipient: "myapp.com",
relayer: {
accountId: "relayer.myapp.near",
privateKey: process.env.RELAYER_PRIVATE_KEY,
whitelistedContracts: ["myapp.near"],
maxGasPerTransaction: "300000000000000",
maxDepositPerTransaction: "0",
},
});
Rotating keys (high-throughput)
siwn({
recipient: "myapp.com",
relayer: {
accountId: "relayer.myapp.near",
privateKeys: [
process.env.RELAYER_KEY_1,
process.env.RELAYER_KEY_2,
process.env.RELAYER_KEY_3,
],
whitelistedContracts: ["myapp.near"],
},
});
Multiple keys cycle round-robin via RotatingKeyStore, eliminating nonce collisions for high-throughput relayers.
Core Patterns
Relay a delegate action from client to on-chain
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 and the receiverId. The builder must use .functionCall() — external code constructs the transaction object using near-kit's builder API. Do not use .send() directly.
Check relayer info and balance
const info = await authClient.near.getRelayerInfo();
console.log("Relayer:", info.data.accountId);
console.log("Mode:", info.data.mode);
console.log("Balance:", info.data.balance);
console.log("Enabled:", info.data.enabled);
Server-side contract view call
const result = await authClient.near.view({
contractId: "myapp.near",
methodName: "get_value",
args: { account_id: "alice.near" },
});
console.log("Result:", result.data.result);
View calls are read-only, authenticated, and executed server-side.
Relay Endpoints
| Method | Path | Description |
|---|
| POST | /near/relay | Relay a signed delegate action on-chain |
| GET | /near/relay-status/:txHash | Check relayed transaction status |
| POST | /near/relayer-info | Get relayer accountId, mode, balance |
| GET | /near/relay-history | List relayed transactions for current user |
| POST | /near/view | Server-side read-only contract call |
Relayer Configuration
| Option | Type | Default | Description |
|---|
accountId | string | — | Named relayer account (explicit mode) |
privateKey | string | — | Single private key, base64 ed25519:... (explicit mode) |
privateKeys | string[] | — | Multiple keys for RotatingKeyStore |
whitelistedContracts | string[] | — | Restrict relay to these contract IDs |
maxGasPerTransaction | string | — | Max gas per relayed transaction (yoctoNEAR) |
maxDepositPerTransaction | string | — | Max deposit per relayed transaction (yoctoNEAR) |
When accountId + privateKey/privateKeys are omitted → ephemeral mode (auto-generated keypair, encrypted in DB).
Encryption Details
- KEK derivation: HKDF-SHA256 with
BETTER_AUTH_SECRET, salt better-near-auth-relayer, info empty
- Encryption: AES-256-GCM with 12-byte random IV
- Storage:
encryptedPrivateKey (base64) + iv (base64) in relayerKey table
- Trust model: Same as Better Auth session tokens — DB access +
BETTER_AUTH_SECRET = full access
Common Mistakes
CRITICAL Not funding the ephemeral relayer account
Wrong:
siwn({
recipient: "myapp.com",
relayer: {},
});
Correct:
siwn({
recipient: "myapp.com",
relayer: {},
});
The ephemeral mode generates an implicit account (hex of public key) with zero balance. Without funding, every relay attempt fails with an insufficient balance error from the NEAR RPC. The server catches this at src/index.ts:952-958 and surfaces it as "Relay failed" — check server logs for the actual RPC error. Alternatively, use explicit mode with a pre-funded named account.
Source: src/index.ts:179-181, maintainer interview
CRITICAL Omitting whitelistedContracts in production
Wrong:
siwn({
recipient: "myapp.com",
relayer: {
accountId: "relayer.myapp.near",
privateKey: process.env.RELAYER_PRIVATE_KEY,
},
});
Correct:
siwn({
recipient: "myapp.com",
relayer: {
accountId: "relayer.myapp.near",
privateKey: process.env.RELAYER_PRIVATE_KEY,
whitelistedContracts: ["myapp.near"],
},
});
Without whitelistedContracts, any authenticated user can relay transactions to arbitrary contracts, spending the relayer's NEAR. Always restrict in production.
Source: src/index.ts:891-898, maintainer interview
HIGH Constructing transactions with wrong builder pattern
Wrong:
const result = await near.transaction(accountId)
.functionCall(receiverId, "method", args, { gas: Gas.Tgas(30) })
.send({ waitUntil: "EXECUTED" });
Correct:
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 using the near-kit TransactionBuilder with .delegate(), not .send() directly. The wallet signs a delegate action; the relayer submits it on-chain. For direct sends (user pays gas), call authClient.near.ensureConnected() first — wallet extensions may disconnect between sign-in and signing.
Source: src/client.ts:172-185, maintainer interview
See also: client/SKILL.md — client buildSignedDelegateAction API
HIGH Missing BETTER_AUTH_SECRET for ephemeral key encryption
Wrong:
siwn({
recipient: "myapp.com",
relayer: {},
});
Correct:
process.env.BETTER_AUTH_SECRET = "your-secure-secret";
siwn({
recipient: "myapp.com",
relayer: {},
});
The ephemeral relayer encrypts its private key using HKDF-SHA256 derived from BETTER_AUTH_SECRET. If missing, key derivation falls back to an empty string, which is insecure and may cause decryption failures on server restart.
Source: src/utils.ts:21-41, src/index.ts:134