better-near-auth
Version:
Sign in with NEAR (SIWN) plugin for Better Auth
904 lines • 43.1 kB
JavaScript
import { APIError, createAuthEndpoint, createAuthMiddleware, sessionMiddleware } from "better-auth/api";
import { setSessionCookie } from "better-auth/cookies";
import { Near, generateNonce, generateKey, parseKey, verifyNep413Signature, decodeSignedDelegateAction, InMemoryKeyStore, RotatingKeyStore } from "near-kit";
import { hex, base58 } from "@scure/base";
import z from "zod";
import { defaultGetProfile, getImageUrl, getNetworkFromAccountId } from "./profile.js";
import { schema } from "./schema.js";
import { LinkAccountRequest, NonceRequest, NonceResponse, ProfileRequest, ProfileResponse, VerifyRequest, VerifyResponse, RelayRequest, RelayResponse, RelayStatusResponse, ViewContractRequest, ViewContractResponse, } from "./types.js";
export * from "./types.js";
import { bytesToHex, encryptPrivateKey, decryptPrivateKey, } from "./utils.js";
async function hashNonce(nonce) {
const encoder = new TextEncoder();
const data = encoder.encode(Array.from(nonce).map(b => b.toString(16).padStart(2, '0')).join(''));
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
function deriveEmail(accountId) {
if (accountId.endsWith(".near")) {
const localPart = accountId.slice(0, -5);
return `${localPart}@near.email`;
}
return null;
}
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);
return {
near,
accountId: relayerConfig.accountId,
network,
mode: "explicit",
};
}
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);
const near = createNear(network, headers, rpcUrl, keyStore);
return {
near,
accountId,
network,
mode: "ephemeral",
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 { encrypted, iv } = await encryptPrivateKey(privateKeyBytes, secret);
await adapter.create({
model: "relayerKey",
data: {
accountId,
encryptedPrivateKey: encrypted,
iv,
publicKey: `ed25519:${publicKeyBase58}`,
network,
createdAt: new Date(),
},
});
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);
const near = createNear(network, headers, rpcUrl, keyStore);
return {
near,
accountId,
network,
mode: "ephemeral",
createdAt: new Date(),
};
}
async function relayOnChain(payload, relayerState) {
const userAction = decodeSignedDelegateAction(payload);
const result = await relayerState.near
.transaction(relayerState.accountId)
.signedDelegateAction(userAction)
.send({ waitUntil: "EXECUTED" });
return { txHash: result.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;
}
export const siwn = (options) => {
const apiKey = options.apiKey;
let relayerState = null;
let relayerInitialized = false;
const headers = {};
if (apiKey) {
headers["Authorization"] = `Bearer ${apiKey}`;
}
const ensureRelayer = async (adapter, secret, network) => {
if (relayerInitialized)
return relayerState;
relayerInitialized = true;
relayerState = await initRelayer(options.relayer, network, adapter, secret, apiKey, options.rpcUrl);
return relayerState;
};
const getNear = (network) => {
if (options.rpcUrl) {
return new Near({ network: { rpcUrl: options.rpcUrl, networkId: network }, headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined });
}
return new Near({ network, headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined });
};
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: 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 } = 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);
const nonceBytes = hex.decode(nonce);
const isValid = await verifyNep413Signature(signedMessage, { message, recipient, nonce: nonceBytes }, { near, maxAge: 15 * 60 * 1000 });
if (!isValid) {
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) {
const isValidKey = await options.validateLimitedAccessKey({
accountId: accountId,
publicKey: publicKey,
recipient: options.recipient
});
if (!isValidKey) {
throw new APIError("UNAUTHORIZED", {
message: "Unauthorized: Invalid function call access key",
status: 401,
});
}
}
const existingNearAccount = await ctx.context.adapter.findOne({
model: "nearAccount",
where: [
{ field: "accountId", operator: "eq", value: accountId },
{ field: "network", operator: "eq", value: network },
],
});
if (existingNearAccount) {
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: publicKey,
isPrimary: !existingPrimaryAccount,
createdAt: new Date(),
},
});
await ctx.context.internalAdapter.createAccount({
userId: session.user.id,
providerId: "siwn",
accountId: `${accountId}:${network}`,
createdAt: new Date(),
updatedAt: 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.object({
accountId: z.string(),
network: z.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,
});
}
const accounts = await ctx.context.adapter.findMany({
model: "account",
where: [{ field: "userId", operator: "eq", value: session.user.id }],
});
if (accounts.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({ accounts: nearAccounts });
}),
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,
});
}
const near = getNear(network);
const exists = await near.accountExists(accountId);
if (!exists) {
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() + 15 * 60 * 1000),
});
return ctx.json(NonceResponse.parse({ nonce: nonceString }));
}),
getSiwnProfile: createAuthEndpoint("/near/profile", {
method: "POST",
body: ProfileRequest,
use: [sessionMiddleware],
}, async (ctx) => {
const { accountId } = ctx.body;
let targetAccountId = accountId;
if (!targetAccountId) {
const session = ctx.context.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, } = ctx.body;
const network = getNetworkFromAccountId(accountId);
try {
const near = getNear(network);
const nonceBytes = hex.decode(nonce);
const isValid = await verifyNep413Signature(signedMessage, { message, recipient, nonce: nonceBytes }, { near, maxAge: 15 * 60 * 1000 });
if (!isValid) {
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);
const existingNonce = await ctx.context.internalAdapter.findVerificationValue(`siwn-nonce:${nonceHash}`);
if (existingNonce) {
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() + 15 * 60 * 1000),
});
if (!options.requireFullAccessKey) {
const validateKey = options.validateLimitedAccessKey
|| ((args) => defaultValidateLimitedAccessKey(args.accountId, args.publicKey, args.recipient || options.recipient, near));
const isValidKey = await validateKey({
accountId: accountId,
publicKey: publicKey,
recipient: options.recipient
});
if (!isValidKey) {
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) ?? "";
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: publicKey,
isPrimary: true,
createdAt: new Date(),
},
});
await ctx.context.internalAdapter.createAccount({
userId: user.id,
providerId: "siwn",
accountId: `${accountId}:${network}`,
createdAt: new Date(),
updatedAt: new Date(),
});
}
else {
if (!existingNearAccount) {
await ctx.context.adapter.create({
model: "nearAccount",
data: {
userId: user.id,
accountId,
network,
publicKey: publicKey,
isPrimary: false,
createdAt: new Date(),
},
});
await ctx.context.internalAdapter.createAccount({
userId: user.id,
providerId: "siwn",
accountId: `${accountId}:${network}`,
createdAt: new Date(),
updatedAt: 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 decoded = decodeSignedDelegateAction(payload);
const delegateAction = decoded.signedDelegate.delegateAction;
if (delegateAction.senderId !== nearAccount.accountId) {
throw new APIError("UNAUTHORIZED", {
message: "Delegate action sender does not match session account",
status: 401,
});
}
const relayerConfig = options.relayer;
if (relayerConfig?.whitelistedContracts?.length) {
if (!relayerConfig.whitelistedContracts.includes(delegateAction.receiverId)) {
throw new APIError("FORBIDDEN", {
message: `Contract ${delegateAction.receiverId} is not whitelisted for relay`,
status: 403,
});
}
}
if (relayerConfig?.maxGasPerTransaction) {
const totalGas = delegateAction.actions.reduce((sum, a) => {
return sum + ("functionCall" in a ? a.functionCall.gas : 0n);
}, 0n);
if (totalGas > BigInt(relayerConfig.maxGasPerTransaction)) {
throw new APIError("BAD_REQUEST", {
message: `Transaction gas (${totalGas}) exceeds relayer limit (${relayerConfig.maxGasPerTransaction})`,
status: 400,
});
}
}
if (relayerConfig?.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(relayerConfig.maxDepositPerTransaction)) {
throw new APIError("BAD_REQUEST", {
message: `Transaction deposit (${totalDeposit}) exceeds relayer limit (${relayerConfig.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: new Date(),
},
});
if (rState.mode === "ephemeral") {
await ctx.context.adapter.update({
model: "relayerKey",
where: [{ field: "network", operator: "eq", value: network }],
update: { lastUsedAt: 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 near = getNear(network);
const txResult = await near.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: "GET",
use: [sessionMiddleware],
}, async (ctx) => {
const session = ctx.context.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 },
],
});
const network = (nearAccount?.network || "mainnet");
const rState = await ensureRelayer(ctx.context.adapter, ctx.context.secret, network);
if (!rState) {
return ctx.json({ enabled: false });
}
const near = getNear(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,
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,
});
}),
getRelayHistory: createAuthEndpoint("/near/relay-history", {
method: "GET",
use: [sessionMiddleware],
}, async (ctx) => {
const session = ctx.context.session;
let transactions = [];
try {
const result = await ctx.context.adapter.findMany({
model: "relayedTransaction",
where: [
{ field: "userId", operator: "eq", value: session.user.id },
],
});
transactions = (result || []);
}
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();
const bTime = b.createdAt instanceof Date ? b.createdAt.getTime() : new Date(b.createdAt ?? 0).getTime();
return bTime - 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) : undefined,
createdAt: tx.createdAt instanceof Date ? tx.createdAt.toISOString() : tx.createdAt ? String(tx.createdAt) : new Date().toISOString(),
updatedAt: tx.updatedAt instanceof Date ? tx.updatedAt.toISOString() : tx.updatedAt ? String(tx.updatedAt) : undefined,
})),
});
}),
viewContract: createAuthEndpoint("/near/view", {
method: "POST",
body: ViewContractRequest,
use: [sessionMiddleware],
}, async (ctx) => {
const { contractId, methodName, args } = ctx.body;
const session = ctx.context.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 },
],
});
const network = (nearAccount?.network || "mainnet");
const near = getNear(network);
const result = await near.view(contractId, methodName, args ?? {});
return ctx.json(ViewContractResponse.parse({ result }));
}),
},
};
};
//# sourceMappingURL=index.js.map