better-near-auth
Version:
Sign in with NEAR (SIWN) plugin for Better Auth
1,515 lines • 53.3 kB
JavaScript
import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
import { setSessionCookie } from "better-auth/cookies";
import { InMemoryKeyStore, Near, RotatingKeyStore, decodeSignedDelegateAction, generateKey, generateNonce, parseKey, verifyNep413Signature } from "near-kit";
import { base58, hex } from "@scure/base";
import z$1, { z } from "zod";
import { AccountIdSchema } from "near-kit/schemas";
//#region src/profile.ts
const FALLBACK_URL = "https://ipfs.near.social/ipfs/bafkreidn5fb2oygegqaldx7ycdmhu4owcrmoxd7ekbzfmeakkobz2ja7qy";
function getNetworkFromAccountId(accountId) {
return accountId.endsWith(".testnet") ? "testnet" : "mainnet";
}
function getImageUrl(image, fallback) {
if (image?.url) return image.url;
if (image?.ipfs_cid) return `https://ipfs.near.social/ipfs/${image.ipfs_cid}`;
return fallback || FALLBACK_URL;
}
async function defaultGetProfile(accountId, apiKey) {
const network = getNetworkFromAccountId(accountId);
try {
const kvUrl = network === "testnet" ? "https://kv.test.fastnear.com" : "https://kv.main.fastnear.com";
const effectiveApiKey = apiKey || process.env.FASTNEAR_API_KEY;
const headers = { "Content-Type": "application/json" };
if (effectiveApiKey) headers["Authorization"] = `Bearer ${effectiveApiKey}`;
const response = await fetch(`${kvUrl}/v0/latest/social.near/${accountId}/profile/**`, { headers });
if (response.ok) {
const entry = (await response.json())?.entries?.[0];
if (entry?.value) try {
const profile = typeof entry.value === "string" ? JSON.parse(entry.value) : entry.value;
if (profile?.name || profile?.description || profile?.image) return {
name: profile.name,
description: profile.description,
image: profile.image,
backgroundImage: profile.backgroundImage,
linktree: profile.linktree
};
} catch {}
}
const apiBase = {
mainnet: "https://api.near.social",
testnet: "https://test.api.near.social"
}[network];
const keys = [`${accountId}/profile/**`];
const fallbackResponse = await fetch(`${apiBase}/get`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ keys })
});
if (!fallbackResponse.ok) throw new Error(`HTTP error! status: ${fallbackResponse.status}`);
const profile = (await fallbackResponse.json())?.[accountId]?.profile;
if (profile) return {
name: profile.name,
description: profile.description,
image: profile.image,
backgroundImage: profile.backgroundImage,
linktree: profile.linktree
};
return null;
} catch (error) {
return null;
}
}
//#endregion
//#region src/schema.ts
const schema = {
nearAccount: { fields: {
userId: {
type: "string",
references: {
model: "user",
field: "id"
},
required: true
},
accountId: {
type: "string",
required: true
},
network: {
type: "string",
required: true
},
publicKey: {
type: "string",
required: true
},
isPrimary: {
type: "boolean",
defaultValue: false
},
createdAt: {
type: "date",
required: true
}
} },
relayedTransaction: { fields: {
userId: {
type: "string",
references: {
model: "user",
field: "id"
}
},
txHash: {
type: "string",
required: true
},
senderId: {
type: "string",
required: true
},
receiverId: {
type: "string",
required: true
},
network: {
type: "string",
required: true
},
status: {
type: "string",
required: true
},
gasUsed: { type: "string" },
createdAt: {
type: "date",
required: true
},
updatedAt: { type: "date" }
} },
relayerKey: { fields: {
accountId: {
type: "string",
required: true
},
encryptedPrivateKey: {
type: "string",
required: true
},
iv: {
type: "string",
required: true
},
publicKey: {
type: "string",
required: true
},
network: {
type: "string",
required: true
},
createdAt: {
type: "date",
required: true
},
lastUsedAt: { type: "date" }
} }
};
//#endregion
//#region src/types.ts
const socialImageSchema = z.object({
url: z.string().optional(),
ipfs_cid: z.string().optional()
});
const profileSchema = z.object({
name: z.string().optional(),
description: z.string().optional(),
image: socialImageSchema.optional(),
backgroundImage: socialImageSchema.optional(),
linktree: z.record(z.string(), z.string()).optional()
});
const signedMessageSchema = z.object({
accountId: z.string(),
publicKey: z.string(),
signature: z.string(),
state: z.string().optional()
});
const LinkAccountRequest = z.object({
signedMessage: signedMessageSchema,
message: z.string(),
recipient: z.string(),
nonce: z.string(),
accountId: AccountIdSchema,
callbackUrl: z.string().optional()
});
const SetPrimaryAccountRequest = z.object({
accountId: AccountIdSchema,
network: z.enum(["mainnet", "testnet"]).optional()
});
const NonceRequest = z.object({
accountId: AccountIdSchema,
networkId: z.union([z.literal("mainnet"), z.literal("testnet")])
});
const VerifyRequest = z.object({
signedMessage: signedMessageSchema,
message: z.string(),
recipient: z.string(),
nonce: z.string(),
accountId: AccountIdSchema,
callbackUrl: z.string().optional()
});
const RelayRequest = z.object({ payload: z.string() });
const RelayResponse = z.object({
txHash: z.string(),
status: z.enum([
"pending",
"completed",
"failed"
])
});
const RelayStatusResponse = z.object({
status: z.enum([
"pending",
"completed",
"failed"
]),
gasUsed: z.string().optional(),
outcome: z.unknown().optional()
});
const ViewContractRequest = z.object({
contractId: z.string(),
methodName: z.string(),
args: z.record(z.string(), z.any()).optional()
});
const NonceResponse = z.object({ nonce: z.string() });
const VerifyResponse = z.object({
token: z.string(),
success: z.literal(true),
user: z.object({
id: z.string(),
accountId: AccountIdSchema,
network: z.union([z.literal("mainnet"), z.literal("testnet")])
})
});
const ProfileResponse = profileSchema.nullable();
const ViewContractResponse = z.object({ result: z.unknown() });
const ProfileRequest = z.object({ accountId: AccountIdSchema.optional() });
const RelayedTransactionSchema = z.object({
id: z.string(),
userId: z.string(),
txHash: z.string(),
senderId: z.string(),
receiverId: z.string(),
network: z.string(),
status: z.string(),
gasUsed: z.string().optional(),
createdAt: z.string(),
updatedAt: z.string().optional()
});
const RelayHistoryResponse = z.object({ transactions: z.array(RelayedTransactionSchema) });
const GetRelayerInfoRequest = z.object({ network: z.enum(["mainnet", "testnet"]).optional() });
const SUB_ACCOUNT_LABEL_REGEX = /^([a-z\d]+[-_])*[a-z\d]+$/;
const SubAccountNameSchema = z.string().min(2, "Sub-account name must be at least 2 characters").max(64, "Sub-account name must be at most 64 characters").regex(SUB_ACCOUNT_LABEL_REGEX, "Sub-account name must contain only lowercase alphanumeric characters, hyphens, and underscores (no leading/trailing hyphens or underscores)");
const CreateSubAccountRequest = z.object({
subAccountName: SubAccountNameSchema,
network: z.enum(["mainnet", "testnet"]).optional(),
publicKey: z.string()
});
const CreateSubAccountResponse = z.object({
success: z.literal(true),
accountId: z.string(),
network: z.enum(["mainnet", "testnet"]),
publicKey: z.string(),
message: z.string()
});
const CheckSubAccountAvailabilityRequest = z.object({
subAccountName: SubAccountNameSchema,
network: z.enum(["mainnet", "testnet"]).optional()
});
const CheckSubAccountAvailabilityResponse = z.object({
available: z.boolean(),
accountId: z.string(),
parentAccount: z.string().optional(),
reason: z.enum([
"taken",
"invalid",
"too-long",
"not-configured"
]).optional()
});
//#endregion
//#region src/utils.ts
function bytesToBase64(bytes) {
return btoa(String.fromCharCode(...bytes));
}
function base64ToBytes(base64) {
return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
}
function bytesToHex(bytes) {
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
}
async function deriveAesKey(secret) {
const keyMaterial = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HKDF" }, false, ["deriveKey"]);
return crypto.subtle.deriveKey({
name: "HKDF",
hash: "SHA-256",
salt: new TextEncoder().encode("better-near-auth-relayer"),
info: new Uint8Array(0)
}, keyMaterial, {
name: "AES-GCM",
length: 256
}, false, ["encrypt", "decrypt"]);
}
async function encryptPrivateKey(privateKey, secret) {
const aesKey = await deriveAesKey(secret);
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt({
name: "AES-GCM",
iv
}, aesKey, privateKey);
return {
encrypted: bytesToBase64(new Uint8Array(encrypted)),
iv: bytesToBase64(iv)
};
}
async function decryptPrivateKey(encrypted, iv, secret) {
const aesKey = await deriveAesKey(secret);
const decrypted = await crypto.subtle.decrypt({
name: "AES-GCM",
iv: base64ToBytes(iv)
}, aesKey, base64ToBytes(encrypted));
return new Uint8Array(decrypted);
}
//#endregion
//#region src/index.ts
async function hashNonce(nonce) {
const data = new TextEncoder().encode(hex.encode(nonce));
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
return hex.encode(new Uint8Array(hashBuffer));
}
function deriveEmail(accountId, recipient) {
if (accountId.endsWith(".near")) {
const localPart = accountId.slice(0, -5);
if (!localPart.includes(".")) return `${localPart}@near.email`;
}
return `temp-${crypto.randomUUID().slice(0, 8)}@${recipient}`;
}
function nearAccountKey(account) {
return `${account.accountId}:${account.network}`;
}
function getCreatedAtTime(account) {
return account.createdAt instanceof Date ? account.createdAt.getTime() : new Date(account.createdAt).getTime();
}
function buildListAccountsResponse(nearAccounts) {
const activeAccount = nearAccounts.find((account) => account.isPrimary) ?? nearAccounts[0] ?? null;
const activeKey = activeAccount ? nearAccountKey(activeAccount) : null;
const accounts = nearAccounts.map((account) => {
const isActive = activeKey === nearAccountKey(account);
return {
...account,
providerId: "siwn",
isActive,
isAvailable: !isActive
};
}).sort((a, b) => {
if (a.isActive !== b.isActive) return a.isActive ? -1 : 1;
return getCreatedAtTime(a) - getCreatedAtTime(b);
});
const listedActiveAccount = accounts.find((account) => account.isActive) ?? null;
return {
accounts,
activeAccount: listedActiveAccount ? { ...listedActiveAccount } : null,
availableAccounts: accounts.filter((account) => account.isAvailable).map((account) => ({ ...account }))
};
}
function createNear(network, headers, rpcUrl, keyStore) {
const config = { headers };
if (rpcUrl) config.network = {
rpcUrl,
networkId: network
};
else config.network = network;
if (keyStore) config.keyStore = keyStore;
return new Near(config);
}
async function initRelayer(relayerConfig, network, adapter, secret, apiKey, rpcUrl) {
if (!relayerConfig) return null;
const headers = {};
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
if (relayerConfig.accountId && (relayerConfig.privateKey || relayerConfig.privateKeys)) {
const keys = relayerConfig.privateKeys ?? (relayerConfig.privateKey ? [relayerConfig.privateKey] : []);
let keyStore;
if (keys.length === 1) keyStore = new InMemoryKeyStore({ [relayerConfig.accountId]: keys[0] });
else keyStore = new RotatingKeyStore({ [relayerConfig.accountId]: keys });
const near = createNear(network, headers, rpcUrl, keyStore);
const explicitPublicKey = parseKey(keys[0]);
return {
near,
accountId: relayerConfig.accountId,
network,
mode: "explicit",
publicKey: explicitPublicKey.publicKey.toString()
};
}
const existing = await adapter.findOne({
model: "relayerKey",
where: [{
field: "network",
operator: "eq",
value: network
}]
});
if (existing) {
if (!secret) throw new Error("BETTER_AUTH_SECRET required for relayer key decryption");
const privateKeyBytes = await decryptPrivateKey(existing.encryptedPrivateKey, existing.iv, secret);
const keyPair = parseKey(`ed25519:${base58.encode(privateKeyBytes)}`);
const accountId = bytesToHex(keyPair.publicKey.data);
console.log(`[siwn] Relayer recovered: ${accountId} (${network})`);
const keyStore = new InMemoryKeyStore();
await keyStore.add(accountId, keyPair);
return {
near: createNear(network, headers, rpcUrl, keyStore),
accountId,
network,
mode: "ephemeral",
publicKey: keyPair.publicKey.toString(),
createdAt: existing.createdAt,
lastUsedAt: existing.lastUsedAt
};
}
const keyPair = generateKey();
if (!secret) throw new Error("BETTER_AUTH_SECRET required for relayer key encryption");
const privateKeyBytes = keyPair.secretKey.startsWith("ed25519:") ? base58.decode(keyPair.secretKey.slice(8)) : new Uint8Array(0);
const publicKeyBase58 = keyPair.publicKey.toString().replace("ed25519:", "");
const accountId = bytesToHex(keyPair.publicKey.data);
const createdAt = /* @__PURE__ */ new Date();
const { encrypted, iv } = await encryptPrivateKey(privateKeyBytes, secret);
await adapter.create({
model: "relayerKey",
data: {
accountId,
encryptedPrivateKey: encrypted,
iv,
publicKey: `ed25519:${publicKeyBase58}`,
network,
createdAt,
lastUsedAt: createdAt
}
});
console.log(`[siwn] Relayer created in EPHEMERAL mode: ${accountId} (${network})`);
console.log(`[siwn] Fund this account with NEAR to enable gasless relay`);
console.log(`[siwn] Private key is encrypted in DB — persists across restarts`);
const keyStore = new InMemoryKeyStore();
await keyStore.add(accountId, keyPair);
return {
near: createNear(network, headers, rpcUrl, keyStore),
accountId,
network,
mode: "ephemeral",
publicKey: keyPair.publicKey.toString(),
createdAt,
lastUsedAt: createdAt
};
}
async function relayOnChain(payload, relayerState) {
const userAction = decodeSignedDelegateAction(payload);
return { txHash: (await relayerState.near.transaction(relayerState.accountId).signedDelegateAction(userAction).send({ waitUntil: "EXECUTED" })).transaction.hash };
}
async function defaultValidateLimitedAccessKey(accountId, publicKey, recipient, near) {
const key = await near.getAccessKey(accountId, publicKey);
if (!key) return false;
if (key.permission === "FullAccess") return true;
if ("FunctionCall" in key.permission) return key.permission.FunctionCall.receiver_id === recipient;
return false;
}
function isImplicitAccount(accountId) {
return /^[0-9a-f]{64}$/.test(accountId);
}
function resolveParentAccount(subAccountCfg, parentKey, rState) {
const parent = subAccountCfg?.parentAccount ?? rState?.accountId;
if (!parent || isImplicitAccount(parent)) return {
parentAccount: null,
subAccountAvailable: false
};
if (!rState && !parentKey) return {
parentAccount: null,
subAccountAvailable: false
};
return {
parentAccount: parent,
subAccountAvailable: true
};
}
const siwn = (options) => {
if (!options.recipient && !options.recipients) throw new Error("Either 'recipient' or 'recipients' must be provided to siwn plugin");
const apiKey = options.apiKey;
const getRecipient = (network) => {
if (options.recipients) return options.recipients[network];
return options.recipient;
};
const getSupportedNetworks = () => {
if (options.recipients) return ["mainnet", "testnet"];
if (options.recipient) return [getNetworkFromAccountId(options.recipient)];
return ["mainnet"];
};
const getRelayerConfig = (network) => {
if (!options.relayer) return void 0;
if ("mainnet" in options.relayer && typeof options.relayer.mainnet === "object") return options.relayer[network];
return options.relayer;
};
const getRpcUrl = (network) => {
if (!options.rpcUrl) return void 0;
if (typeof options.rpcUrl === "object") return options.rpcUrl[network];
return options.rpcUrl;
};
const getSubAccountConfig = (network) => {
if (!options.subAccount) return void 0;
if ("mainnet" in options.subAccount && typeof options.subAccount.mainnet === "object") return options.subAccount[network];
return options.subAccount;
};
const getParentKey = (network) => {
if (!options.secrets?.parentKey) return void 0;
if (typeof options.secrets.parentKey === "object") return options.secrets.parentKey[network];
return options.secrets.parentKey;
};
const getRawParentKey = (network) => {
const fromSecrets = getParentKey(network);
if (fromSecrets) return fromSecrets;
const relayerCfg = getRelayerConfig(network);
const parent = getSubAccountConfig(network)?.parentAccount ?? relayerCfg?.accountId;
if (parent && parent === relayerCfg?.accountId) return relayerCfg.privateKey ?? relayerCfg.privateKeys?.[0];
};
const primaryNetwork = getSupportedNetworks()[0];
const relayerStates = /* @__PURE__ */ new Map();
const relayerInitPromises = /* @__PURE__ */ new Map();
const headers = {};
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const ensureRelayer = async (adapter, secret, network) => {
if (relayerStates.has(network)) return relayerStates.get(network) ?? null;
const existingInit = relayerInitPromises.get(network);
if (existingInit) return existingInit;
const initPromise = initRelayer(getRelayerConfig(network), network, adapter, secret, apiKey, getRpcUrl(network)).then((state) => {
relayerStates.set(network, state);
relayerInitPromises.delete(network);
return state;
}).catch((error) => {
relayerInitPromises.delete(network);
throw error;
});
relayerInitPromises.set(network, initPromise);
return initPromise;
};
const getNear = (network) => {
const networkRpcUrl = getRpcUrl(network);
if (networkRpcUrl) return new Near({
network: {
rpcUrl: networkRpcUrl,
networkId: network
},
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : void 0
});
return new Near({
network,
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : void 0
});
};
return {
id: "siwn",
schema,
hooks: { after: [{
matcher: (context) => context.path === "/auth/session" && context.method === "GET",
handler: createAuthMiddleware(async (ctx) => {
const session = ctx.context.session;
if (session) {
const nearAccount = await ctx.context.adapter.findOne({
model: "nearAccount",
where: [{
field: "userId",
operator: "eq",
value: session.user.id
}, {
field: "isPrimary",
operator: "eq",
value: true
}]
});
if (nearAccount) ctx.context.session = {
...session,
user: {
...session.user,
nearAccount
}
};
}
return { context: ctx };
})
}] },
endpoints: {
linkNearAccount: createAuthEndpoint("/near/link-account", {
method: "POST",
body: LinkAccountRequest,
use: [sessionMiddleware],
requireRequest: true
}, async (ctx) => {
const { signedMessage, message, recipient, nonce, accountId, callbackUrl } = ctx.body;
const network = getNetworkFromAccountId(accountId);
const session = ctx.context.session;
if (!session) throw new APIError("UNAUTHORIZED", {
message: "Must be logged in to link NEAR account",
status: 401
});
try {
const near = getNear(network);
if (!await verifyNep413Signature(signedMessage, {
message,
recipient,
nonce: hex.decode(nonce),
callbackUrl
}, {
near,
maxAge: 900 * 1e3
})) throw new APIError("UNAUTHORIZED", {
message: "Unauthorized: Invalid signature",
status: 401
});
if (signedMessage.accountId !== accountId) throw new APIError("UNAUTHORIZED", {
message: "Unauthorized: Account ID mismatch",
status: 401
});
const publicKey = signedMessage.publicKey;
if (!options.requireFullAccessKey && options.validateLimitedAccessKey) {
if (!await options.validateLimitedAccessKey({
accountId,
publicKey,
recipient: options.recipient
})) throw new APIError("UNAUTHORIZED", {
message: "Unauthorized: Invalid function call access key",
status: 401
});
}
if (await ctx.context.adapter.findOne({
model: "nearAccount",
where: [{
field: "accountId",
operator: "eq",
value: accountId
}, {
field: "network",
operator: "eq",
value: network
}]
})) throw new APIError("BAD_REQUEST", {
message: "This NEAR account is already linked to another user",
status: 400
});
const existingPrimaryAccount = await ctx.context.adapter.findOne({
model: "nearAccount",
where: [{
field: "userId",
operator: "eq",
value: session.user.id
}, {
field: "isPrimary",
operator: "eq",
value: true
}]
});
await ctx.context.adapter.create({
model: "nearAccount",
data: {
userId: session.user.id,
accountId,
network,
publicKey,
isPrimary: !existingPrimaryAccount,
createdAt: /* @__PURE__ */ new Date()
}
});
await ctx.context.internalAdapter.createAccount({
userId: session.user.id,
providerId: "siwn",
accountId: `${accountId}:${network}`,
createdAt: /* @__PURE__ */ new Date(),
updatedAt: /* @__PURE__ */ new Date()
});
return ctx.json({
success: true,
accountId,
network,
message: "NEAR account successfully linked"
});
} catch (error) {
if (error instanceof APIError) throw error;
throw new APIError("UNAUTHORIZED", {
message: "Something went wrong. Please try again later.",
error: error instanceof Error ? error.message : "Unknown error",
status: 401
});
}
}),
unlinkNearAccount: createAuthEndpoint("/near/unlink-account", {
method: "POST",
body: z$1.object({
accountId: z$1.string(),
network: z$1.enum(["mainnet", "testnet"]).optional()
}),
use: [sessionMiddleware]
}, async (ctx) => {
const { accountId, network: providedNetwork } = ctx.body;
const session = ctx.context.session;
if (!session) throw new APIError("UNAUTHORIZED", {
message: "Must be logged in to unlink NEAR account",
status: 401
});
const network = providedNetwork || getNetworkFromAccountId(accountId);
const nearAccount = await ctx.context.adapter.findOne({
model: "nearAccount",
where: [
{
field: "userId",
operator: "eq",
value: session.user.id
},
{
field: "accountId",
operator: "eq",
value: accountId
},
{
field: "network",
operator: "eq",
value: network
}
]
});
if (!nearAccount) throw new APIError("NOT_FOUND", {
message: "NEAR account not found or not linked to your user",
status: 404
});
if ((await ctx.context.adapter.findMany({
model: "account",
where: [{
field: "userId",
operator: "eq",
value: session.user.id
}]
})).length <= 1) throw new APIError("BAD_REQUEST", {
message: "Cannot unlink last authentication method. Link another account first.",
status: 400
});
if (nearAccount.isPrimary) {
const otherNearAccounts = await ctx.context.adapter.findMany({
model: "nearAccount",
where: [{
field: "userId",
operator: "eq",
value: session.user.id
}, {
field: "accountId",
operator: "ne",
value: accountId
}]
});
if (otherNearAccounts.length > 0) await ctx.context.adapter.update({
model: "nearAccount",
where: [{
field: "id",
operator: "eq",
value: otherNearAccounts[0].id
}],
update: { isPrimary: true }
});
}
await ctx.context.adapter.delete({
model: "nearAccount",
where: [
{
field: "userId",
operator: "eq",
value: session.user.id
},
{
field: "accountId",
operator: "eq",
value: accountId
},
{
field: "network",
operator: "eq",
value: network
}
]
});
const accountToDelete = await ctx.context.adapter.findOne({
model: "account",
where: [
{
field: "userId",
operator: "eq",
value: session.user.id
},
{
field: "providerId",
operator: "eq",
value: "siwn"
},
{
field: "accountId",
operator: "eq",
value: `${accountId}:${network}`
}
]
});
if (accountToDelete) await ctx.context.internalAdapter.deleteAccount(accountToDelete.id);
return ctx.json({
success: true,
accountId,
network,
message: "NEAR account successfully unlinked"
});
}),
listNearAccounts: createAuthEndpoint("/near/list-accounts", {
method: "GET",
use: [sessionMiddleware]
}, async (ctx) => {
const session = ctx.context.session;
const nearAccounts = await ctx.context.adapter.findMany({
model: "nearAccount",
where: [{
field: "userId",
operator: "eq",
value: session.user.id
}]
});
return ctx.json(buildListAccountsResponse(nearAccounts));
}),
setPrimaryNearAccount: createAuthEndpoint("/near/set-primary-account", {
method: "POST",
body: SetPrimaryAccountRequest,
use: [sessionMiddleware]
}, async (ctx) => {
const { accountId, network: providedNetwork } = ctx.body;
const session = ctx.context.session;
const network = providedNetwork || getNetworkFromAccountId(accountId);
const targetAccount = await ctx.context.adapter.findOne({
model: "nearAccount",
where: [
{
field: "userId",
operator: "eq",
value: session.user.id
},
{
field: "accountId",
operator: "eq",
value: accountId
},
{
field: "network",
operator: "eq",
value: network
}
]
});
if (!targetAccount) throw new APIError("NOT_FOUND", {
message: "NEAR account not found or not linked to your user",
status: 404
});
const nearAccounts = await ctx.context.adapter.findMany({
model: "nearAccount",
where: [{
field: "userId",
operator: "eq",
value: session.user.id
}]
});
await Promise.all(nearAccounts.map((account) => ctx.context.adapter.update({
model: "nearAccount",
where: [{
field: "id",
operator: "eq",
value: account.id
}],
update: { isPrimary: nearAccountKey(account) === nearAccountKey(targetAccount) }
})));
const updatedNearAccounts = await ctx.context.adapter.findMany({
model: "nearAccount",
where: [{
field: "userId",
operator: "eq",
value: session.user.id
}]
});
return ctx.json({
success: true,
accountId,
network,
message: "Primary NEAR account updated",
...buildListAccountsResponse(updatedNearAccounts)
});
}),
getSiwnNonce: createAuthEndpoint("/near/nonce", {
method: "POST",
body: NonceRequest
}, async (ctx) => {
const { accountId, networkId } = ctx.body;
const network = getNetworkFromAccountId(accountId);
if (networkId !== network) throw new APIError("BAD_REQUEST", {
message: "Network ID mismatch with account ID",
status: 400
});
if (!await getNear(network).accountExists(accountId)) throw new APIError("BAD_REQUEST", {
message: "Account does not exist on-chain",
status: 400
});
const nonce = options.getNonce ? await options.getNonce() : generateNonce();
const nonceString = hex.encode(nonce);
await ctx.context.internalAdapter.createVerificationValue({
identifier: `siwn:${accountId}:${network}`,
value: nonceString,
expiresAt: new Date(Date.now() + 900 * 1e3)
});
return ctx.json(NonceResponse.parse({ nonce: nonceString }));
}),
getSiwnProfile: createAuthEndpoint("/near/profile", {
method: "POST",
body: ProfileRequest
}, async (ctx) => {
const { accountId } = ctx.body;
let targetAccountId = accountId;
if (!targetAccountId) {
const session = (await getSessionFromCtx(ctx))?.session;
if (!session) throw new APIError("UNAUTHORIZED", {
message: "Session required when no accountId provided",
status: 401
});
const nearAccount = await ctx.context.adapter.findOne({
model: "nearAccount",
where: [{
field: "userId",
operator: "eq",
value: session.user.id
}, {
field: "isPrimary",
operator: "eq",
value: true
}]
});
if (!nearAccount) throw new APIError("NOT_FOUND", {
message: "No NEAR account found for user",
status: 404
});
targetAccountId = nearAccount.accountId;
}
const profile = await (options.getProfile || ((id) => defaultGetProfile(id, apiKey)))(targetAccountId);
return ctx.json(ProfileResponse.parse(profile));
}),
verifySiwnMessage: createAuthEndpoint("/near/verify", {
method: "POST",
body: VerifyRequest,
requireRequest: true
}, async (ctx) => {
const { signedMessage, message, recipient, nonce, accountId, callbackUrl } = ctx.body;
const network = getNetworkFromAccountId(accountId);
try {
const near = getNear(network);
const nonceBytes = hex.decode(nonce);
if (!await verifyNep413Signature(signedMessage, {
message,
recipient,
nonce: nonceBytes,
callbackUrl
}, {
near,
maxAge: 900 * 1e3
})) throw new APIError("UNAUTHORIZED", {
message: "Unauthorized: Invalid signature",
status: 401
});
if (signedMessage.accountId !== accountId) throw new APIError("UNAUTHORIZED", {
message: "Unauthorized: Account ID mismatch",
status: 401
});
const publicKey = signedMessage.publicKey;
const nonceHash = await hashNonce(nonceBytes);
if (await ctx.context.internalAdapter.findVerificationValue(`siwn-nonce:${nonceHash}`)) throw new APIError("UNAUTHORIZED", {
message: "Unauthorized: Nonce already used (replay attack detected)",
status: 401,
code: "UNAUTHORIZED_NONCE_REPLAY"
});
await ctx.context.internalAdapter.createVerificationValue({
identifier: `siwn-nonce:${nonceHash}`,
value: "used",
expiresAt: new Date(Date.now() + 900 * 1e3)
});
if (!options.requireFullAccessKey) {
if (!await (options.validateLimitedAccessKey || ((args) => defaultValidateLimitedAccessKey(args.accountId, args.publicKey, args.recipient || getRecipient(network), near)))({
accountId,
publicKey,
recipient: options.recipient
})) throw new APIError("UNAUTHORIZED", {
message: "Unauthorized: Invalid function call access key",
status: 401
});
}
let user = null;
const existingNearAccount = await ctx.context.adapter.findOne({
model: "nearAccount",
where: [{
field: "accountId",
operator: "eq",
value: accountId
}, {
field: "network",
operator: "eq",
value: network
}]
});
if (existingNearAccount) user = await ctx.context.adapter.findOne({
model: "user",
where: [{
field: "id",
operator: "eq",
value: existingNearAccount.userId
}]
});
else {
const anyNearAccount = await ctx.context.adapter.findOne({
model: "nearAccount",
where: [{
field: "accountId",
operator: "eq",
value: accountId
}]
});
if (anyNearAccount) user = await ctx.context.adapter.findOne({
model: "user",
where: [{
field: "id",
operator: "eq",
value: anyNearAccount.userId
}]
});
}
if (!user) {
const userEmail = deriveEmail(accountId, getRecipient(network));
const profile = await (options.getProfile || ((id) => defaultGetProfile(id, apiKey)))(accountId);
user = await ctx.context.internalAdapter.createUser({
name: profile?.name ?? accountId,
email: userEmail,
image: profile?.image ? getImageUrl(profile.image) : ""
});
await ctx.context.adapter.create({
model: "nearAccount",
data: {
userId: user.id,
accountId,
network,
publicKey,
isPrimary: true,
createdAt: /* @__PURE__ */ new Date()
}
});
await ctx.context.internalAdapter.createAccount({
userId: user.id,
providerId: "siwn",
accountId: `${accountId}:${network}`,
createdAt: /* @__PURE__ */ new Date(),
updatedAt: /* @__PURE__ */ new Date()
});
} else if (!existingNearAccount) {
await ctx.context.adapter.create({
model: "nearAccount",
data: {
userId: user.id,
accountId,
network,
publicKey,
isPrimary: false,
createdAt: /* @__PURE__ */ new Date()
}
});
await ctx.context.internalAdapter.createAccount({
userId: user.id,
providerId: "siwn",
accountId: `${accountId}:${network}`,
createdAt: /* @__PURE__ */ new Date(),
updatedAt: /* @__PURE__ */ new Date()
});
}
await ensureRelayer(ctx.context.adapter, ctx.context.secret, network);
const session = await ctx.context.internalAdapter.createSession(user.id);
if (!session) throw new APIError("INTERNAL_SERVER_ERROR", {
message: "Internal Server Error",
status: 500
});
await setSessionCookie(ctx, {
session,
user
});
return ctx.json(VerifyResponse.parse({
token: session.token,
success: true,
user: {
id: user.id,
accountId,
network
}
}));
} catch (error) {
if (error instanceof APIError) throw error;
const msg = error instanceof Error ? error.message : "Unknown error";
const at = error instanceof Error && error.stack ? error.stack.split("\n").slice(1, 3).map((s) => s.trim()).join(" <- ") : "";
console.error(`[siwn] Verify error: ${msg}${at ? ` (${at})` : ""}`);
throw new APIError("UNAUTHORIZED", {
message: "Something went wrong. Please try again later.",
error: msg,
status: 401
});
}
}),
relayNearTransaction: createAuthEndpoint("/near/relay", {
method: "POST",
body: RelayRequest,
use: [sessionMiddleware]
}, async (ctx) => {
const { payload } = ctx.body;
const session = ctx.context.session;
if (!session) throw new APIError("UNAUTHORIZED", {
message: "Must be authenticated to relay transactions",
status: 401
});
const nearAccount = await ctx.context.adapter.findOne({
model: "nearAccount",
where: [{
field: "userId",
operator: "eq",
value: session.user.id
}, {
field: "isPrimary",
operator: "eq",
value: true
}]
});
if (!nearAccount) throw new APIError("UNAUTHORIZED", {
message: "No NEAR account linked to session",
status: 401
});
const network = nearAccount.network;
const rState = await ensureRelayer(ctx.context.adapter, ctx.context.secret, network);
if (!rState) throw new APIError("SERVICE_UNAVAILABLE", {
message: "Relayer not configured",
status: 503
});
try {
const delegateAction = decodeSignedDelegateAction(payload).signedDelegate.delegateAction;
if (delegateAction.senderId !== nearAccount.accountId) throw new APIError("UNAUTHORIZED", {
message: "Delegate action sender does not match session account",
status: 401
});
const relayerConfigForNetwork = getRelayerConfig(network);
if (relayerConfigForNetwork?.whitelistedContracts?.length) {
if (!relayerConfigForNetwork.whitelistedContracts.includes(delegateAction.receiverId)) throw new APIError("FORBIDDEN", {
message: `Contract ${delegateAction.receiverId} is not whitelisted for relay`,
status: 403
});
}
if (relayerConfigForNetwork?.maxGasPerTransaction) {
const totalGas = delegateAction.actions.reduce((sum, a) => {
return sum + ("functionCall" in a ? a.functionCall.gas : 0n);
}, 0n);
if (totalGas > BigInt(relayerConfigForNetwork.maxGasPerTransaction)) throw new APIError("BAD_REQUEST", {
message: `Transaction gas (${totalGas}) exceeds relayer limit (${relayerConfigForNetwork.maxGasPerTransaction})`,
status: 400
});
}
if (relayerConfigForNetwork?.maxDepositPerTransaction) {
const totalDeposit = delegateAction.actions.reduce((sum, a) => {
if ("functionCall" in a) return sum + a.functionCall.deposit;
if ("transfer" in a) return sum + a.transfer.deposit;
return sum;
}, 0n);
if (totalDeposit > BigInt(relayerConfigForNetwork.maxDepositPerTransaction)) throw new APIError("BAD_REQUEST", {
message: `Transaction deposit (${totalDeposit}) exceeds relayer limit (${relayerConfigForNetwork.maxDepositPerTransaction})`,
status: 400
});
}
const result = await relayOnChain(payload, rState);
await ctx.context.adapter.create({
model: "relayedTransaction",
data: {
userId: session.user.id,
txHash: result.txHash,
senderId: delegateAction.senderId,
receiverId: delegateAction.receiverId,
network,
status: "pending",
createdAt: /* @__PURE__ */ new Date()
}
});
if (rState.mode === "ephemeral") await ctx.context.adapter.update({
model: "relayerKey",
where: [{
field: "network",
operator: "eq",
value: network
}],
update: { lastUsedAt: /* @__PURE__ */ new Date() }
});
return ctx.json(RelayResponse.parse({
txHash: result.txHash,
status: "pending"
}));
} catch (error) {
if (error instanceof APIError) throw error;
throw new APIError("INTERNAL_SERVER_ERROR", {
message: error instanceof Error ? error.message : "Relay failed",
status: 500
});
}
}),
getRelayStatus: createAuthEndpoint("/near/relay-status/:txHash", {
method: "GET",
use: [sessionMiddleware]
}, async (ctx) => {
const txHash = ctx.params?.txHash;
if (!txHash) throw new APIError("BAD_REQUEST", {
message: "Transaction hash required",
status: 400
});
const session = ctx.context.session;
const relayedTx = await ctx.context.adapter.findOne({
model: "relayedTransaction",
where: [{
field: "txHash",
operator: "eq",
value: txHash
}, {
field: "userId",
operator: "eq",
value: session.user.id
}]
});
if (!relayedTx) throw new APIError("NOT_FOUND", {
message: "Transaction not found or not owned by this user",
status: 404
});
const network = relayedTx.network;
const senderId = relayedTx.senderId;
try {
const txResult = await getNear(network).getTransactionStatus(txHash, senderId);
const txStatus = txResult.status;
if (txStatus && typeof txStatus === "object") {
const hasSuccess = "SuccessValue" in txStatus || "SuccessReceiptId" in txStatus;
const hasFailure = "Failure" in txStatus;
const status = hasSuccess ? "completed" : hasFailure ? "failed" : "pending";
if (status !== "pending") await ctx.context.adapter.update({
model: "relayedTransaction",
where: [{
field: "txHash",
operator: "eq",
value: txHash
}],
update: { status }
});
const gasUsed = txResult.transaction_outcome?.outcome?.gas_burnt?.toString();
return ctx.json(RelayStatusResponse.parse({
status,
gasUsed,
outcome: txResult
}));
}
return ctx.json(RelayStatusResponse.parse({ status: "pending" }));
} catch (error) {
return ctx.json(RelayStatusResponse.parse({ status: "pending" }));
}
}),
getRelayerInfo: createAuthEndpoint("/near/relayer-info", {
method: "POST",
body: GetRelayerInfoRequest,
use: [sessionMiddleware]
}, async (ctx) => {
const { network: requestedNetwork } = ctx.body;
const targetNetwork = requestedNetwork ?? primaryNetwork;
const rState = await ensureRelayer(ctx.context.adapter, ctx.context.secret, targetNetwork);
if (!rState) return ctx.json({
enabled: false,
subAccountAvailable: false
});
const { parentAccount, subAccountAvailable } = resolveParentAccount(getSubAccountConfig(targetNetwork), getParentKey(targetNetwork), rState);
const near = getNear(rState.network);
let account;
try {
account = await near.getAccount(rState.accountId);
} catch {
account = {
balance: "0",
available: "0",
staked: "0",
storageUsage: "0",
storageBytes: 0,
hasContract: false,
codeHash: ""
};
}
return ctx.json({
enabled: true,
accountId: rState.accountId,
mode: rState.mode,
network: rState.network,
publicKey: rState.publicKey,
balance: account.balance,
available: account.available,
staked: account.staked,
storageUsage: account.storageUsage,
storageBytes: account.storageBytes,
hasContract: account.hasContract,
hasKey: true,
createdAt: rState.createdAt,
lastUsedAt: rState.lastUsedAt,
parentAccount: parentAccount ?? void 0,
subAccountAvailable
});
}),
getRelayHistory: createAuthEndpoint("/near/relay-history", {
method: "GET",
use: [sessionMiddleware]
}, async (ctx) => {
const session = ctx.context.session;
let transactions = [];
try {
transactions = await ctx.context.adapter.findMany({
model: "relayedTransaction",
where: [{
field: "userId",
operator: "eq",
value: session.user.id
}]
}) || [];
} catch (err) {
console.error("relay-history findMany error:", err);
}
const sorted = transactions.sort((a, b) => {
const aTime = a.createdAt instanceof Date ? a.createdAt.getTime() : new Date(a.createdAt ?? 0).getTime();
return (b.createdAt instanceof Date ? b.createdAt.getTime() : new Date(b.createdAt ?? 0).getTime()) - aTime;
});
return ctx.json({ transactions: sorted.map((tx) => ({
id: String(tx.id ?? ""),
userId: String(tx.userId ?? ""),
txHash: String(tx.txHash ?? ""),
senderId: String(tx.senderId ?? ""),
receiverId: String(tx.receiverId ?? ""),
network: String(tx.network ?? "mainnet"),
status: String(tx.status ?? "pending"),
gasUsed: tx.gasUsed ? String(tx.gasUsed) : void 0,
createdAt: tx.createdAt instanceof Date ? tx.createdAt.toISOString() : tx.createdAt ? String(tx.createdAt) : (/* @__PURE__ */ new Date()).toISOString(),
updatedAt: tx.updatedAt instanceof Date ? tx.updatedAt.toISOString() : tx.updatedAt ? String(tx.updatedAt) : void 0
})) });
}),
viewContract: createAuthEndpoint("/near/view", {
method: "POST",
body: ViewContractRequest,
use: [sessionMiddleware]
}, async (ctx) => {
const { contractId, methodName, args } = ctx.body;
const session = ctx.context.session;
const near = getNear((await ctx.context.adapter.findOne({
model: "nearAccount",
where: [{
field: "userId",
operator: "eq",
value: session.user.id
}, {
field: "isPrimary",
operator: "eq",
value: true
}]
}))?.network || primaryNetwork);
let result;
try {
result = await near.view(contractId, methodName, args ?? {});
} catch (error) {
if (error instanceof APIError) throw error;
throw new APIError("BAD_REQUEST", { message: error instanceof Error ? error.message : "Unknown error" });
}
return ctx.json(ViewContractResponse.parse({ result }));
}),
createSubAccount: createAuthEndpoint("/near/create-sub-account", {
method: "POST",
body: CreateSubAccountRequest,
use: [sessionMiddleware]
}, async (ctx) => {
const { subAccountName, network: providedNetwork, publicKey } = ctx.body;
const session = ctx.context.session;
if (!session) throw new APIError("UNAUTHORIZED", {
message: "Must be authenticated to create a sub-account",
status: 401
});
const nearAccount = await ctx.context.adapter.findOne({
model: "nearAccount",
where: [{
field: "userId",
operator: "eq",
value: session.user.id
}, {
field: "isPrimary",
operator: "eq",
value: true
}]
});
const network = providedNetwork || nearAccount?.network || primaryNetwork;
const subAccountCfg = getSubAccountConfig(network);
const userAccountId = nearAccount?.accountId ?? "";
const parentKey = getParentKey(network);
const rState = await ensureRelayer(ctx.context.adapter, ctx.context.secret, network);
const { parentAccount, subAccountAvailable } = resolveParentAccount(subAccountCfg, parentKey, rState);
if (!subAccountAvailable || !parentAccount) {
const configuredParent = subAccountCfg?.parentAccount;
let msg;
if (configuredParent && !parentKey) msg = `Sub-account creation unavailable on ${network}: parent key not configured for ${configuredParent}. Set NEAR_SUB_ACCOUNT_PARENT_KEY_${network.toUpperCase()} in your environment, or use an explicit relayer with a named account.`;
else if (!configuredParent) msg = `Sub-account creation unavailable on ${network}: no parent account configured. Set subAccount.${network}.parentAccount in your SIWN plugin options, or use an explicit relayer with a named account.`;
else msg = `Sub-account creation unavailable on ${network}: requires a named parent account with a parent key, or an explicit relayer with a named account.`;
throw new APIError("SERVICE_UNAVAILABLE", {
message: msg,
status: 503
});
}
const effectiveParentKey = getRawParentKey(network);
if (parentAccount !== rState?.accountId && !effectiveParentKey) throw new APIError("SERVICE_UNAVAILABLE", {
message: "Sub-account parent differs from relayer account. Provide secrets.parentKey to sign as the parent account.",
status: 503
});
const newAccountId = `${subAccountName}.${parentAccount}`;
const minDeposit = subAccountCfg?.minDeposit || "0.1 NEAR";
const near = getNear(network);
if (await near.accountExists(newAccountId)) throw new APIError("CONFLICT", {
message: `Account ${newAccountId} already exists on ${network}`,
status: 409
});
const txCtx = {
newAccountId,
parentAccount,
userPublicKey: publicKey,
userAccountId,
userId: session.user.id,
network
};
const signingNear = rState?.near ?? near;
let txBuilder = signingNear.transaction(parentAccount).createAccount(newAccountId).addKey(publicKey, { type: "fullAccess" });
if (subAccountCfg?.parentHasFullAccess) {
const parentPublicKey = parentAccount === rState?.accountId ? rState.publicKey : parseKey(effectiveParentKey).publicKey.toString();
txBuilder = txBuilder.addKey(parentPublicKey, { type: "fullAccess" });
}
if (subAccountCfg?.addRelayerFCAK !== false && subAccountCfg?.relayerFCAK && rState) {
const fcakPermission = {
type: "functionCall",
receiverId: subAccountCfg.relayerFCAK.receiverId
};
if (subAccountCfg.relayerFCAK.methodNames) fcakPermission.methodNames = subAccountCfg.relayerFCAK.methodNames;
if (subAccountCfg.relayerFCAK.allowance) fcakPermission.allowance = subAccountCfg.relayerFCAK.allowance;
txBuilder.addKey(rState.publicKey, fcakPermission);
}
txBuilder.transfer(newAccountId, minDeposit);
if (subAccountCfg?.deploy) {
if ("wasm" in subAccountCfg.deploy) txBuilder = txBuilder.deployContract(newAccountId, subAccountCfg.deploy.wasm);
else if ("fromPublished" in subAccountCfg.deploy) txBuilder = txBuilder.deployFromPublished(subAccountCfg.deploy.fromPublished);
}
if (subAccountCfg?.init) {
const initArgs = typeof subAccountCfg.init.args === "function" ? subAccountCfg.init.args(txCtx) : subAccountCfg.init.args;
txBuilder = txBuilder.functionCall(newAccountId, subAccountCfg.init.methodName, initArgs);
}
if (subAccountCfg?.extendTx) txBuilder = subAccountCfg.extendTx(txBuilder, txCtx);
if (effectiveParentKey) txBuilder = txBuilder.signWith(effectiveParentKey);
const cleanupDb = async () => {
try {
const existing = await ctx.context.adapter.findOne({
model: "nearAccount",
where: [{
field: "accountId",
operator: "eq",
value: newAccountId
}]
});
if (existing) await ctx.context.adapter.delete({
model: "nearAccount",
where: [{
field: "id",
operator: "eq",
value: existing.id
}]
});
} catch {}
try {
await ctx.context.internalAdapter.deleteAccount(`${newAccountId}:${network}`);
} catch {}
};
const lifecycleCtx = {
...txCtx,
near: signingNear
};
try {
await txBuilder.send({ waitUntil: "EXECUTED" });
} catch (error) {
throw new APIError("INTERNAL_SERVER_ERROR", {
message: `Failed to create sub-account: ${error instanceof Error ? error.message : "Unknown error"}`,
status: 500
});
}
try {
await ctx.context.adapter.create({
model: "nearAccount",
data: {
userId: session.user.id,
accountId: newAccountId,
network,
publicKey,
isPrimary: false,
createdAt: /* @__PURE__ */ new Date()
}
});
await ctx.context.internalAdapter.createAccount({
userId: session.user.id,
providerId: "siwn",
accountId: `${newAccountId}:${network}`,
createdAt: /* @__PURE__ */ new Date(),
updatedAt: /* @__PUR