| name | subaccount |
| description | Configure sub-account creation with parent ownership, contract deployment, init calls, composable transaction hooks, and lifecycle callbacks with automatic rollback. Load when setting up subAccount config, deploying contracts to new sub-accounts, or handling post-creation side effects.
|
| metadata | {"type":"core","library":"better-near-auth","library_version":"1.6.5"} |
| sources | ["elliotBraem/better-near-auth:src/index.ts","elliotBraem/better-near-auth:src/types.ts","elliotBraem/better-near-auth:src/near.test.ts","elliotBraem/better-near-auth:README.md","elliotBraem/better-near-auth:LLM.txt"] |
| requires | ["siwn"] |
Better-Near-Auth โ Sub-Account Creation
Create named sub-accounts (e.g. myapp.parent.near) with configurable access keys, contract deployment, init calls, and composable transaction hooks. The server builds a single atomic NEAR transaction that either succeeds completely or reverts entirely.
For transaction building reference, see: NEAR Kit โ Action Reference and NEAR Kit โ Advanced Transactions
Setup
Recommended default: parent owns it
The recommended configuration gives the parent account full access to the subaccount. This enables recovery, rollback, and admin operations.
import { siwn } from "better-near-auth";
export const auth = betterAuth({
plugins: [
siwn({
recipient: "myapp.near",
relayer: {
accountId: "myapp.near",
privateKey: process.env.RELAYER_PRIVATE_KEY,
},
subAccount: {
parentAccount: "myapp.near",
parentHasFullAccess: true,
minDeposit: "0.1 NEAR",
},
}),
],
});
The user provides a public key (from a wallet or generated keypair) when creating the sub-account. The server adds it as a full access key alongside the parent's key:
Transaction (atomic):
createAccount("myapp.myapp.near")
addKey(userPublicKey, fullAccess)
addKey(parentPublicKey, fullAccess) โ parentHasFullAccess: true
transfer("myapp.myapp.near", "0.1 NEAR")
Standalone mode (no relayer)
Sub-account creation works without a relayer when you provide the parent key via secrets. The server creates a Near instance from the RPC URL and signs with the parent key.
siwn({
recipient: "myapp.near",
secrets: {
parentKey: process.env.PARENT_KEY,
},
subAccount: {
parentAccount: "myapp.near",
parentHasFullAccess: true,
},
});
No relayer setup needed โ the parent account pays gas directly.
Parent different from relayer
When the parent account is different from the relayer, provide the parent key in secrets.parentKey:
siwn({
recipient: "myapp.com",
relayer: {
accountId: "relayer.myapp.near",
privateKey: process.env.RELAYER_PRIVATE_KEY,
},
secrets: {
parentKey: process.env.PARENT_KEY,
},
subAccount: {
parentAccount: "user.parent.near",
parentHasFullAccess: true,
},
});
Configuration
SubAccountConfig
| Field | Type | Default | Description |
|---|
parentAccount | string | relayer accountId | Named parent account for sub-account namespace |
minDeposit | string | "0.1 NEAR" | NEAR transferred to new account |
parentHasFullAccess | boolean | false | Add parent's key as full access on subaccount |
deploy | object | โ | Deploy a contract to the subaccount |
init | object | โ | Call a method after deploy |
extendTx | function | โ | Custom transaction building hook |
onCreated | function | โ | Post-creation callback (before response) |
onRollback | function | โ | Consumer cleanup when onCreated fails |
secrets (on SIWNPluginOptions)
| Field | Type | Default | Description |
|---|
parentKey | string | DualNetworkConfig<string> | โ | ed25519:... key for signing as parent account |
Context types
interface SubAccountTxCtx {
newAccountId: string;
parentAccount: string;
userPublicKey: string;
userAccountId: string;
userId: string;
network: "mainnet" | "testnet";
}
interface SubAccountLifecycleCtx extends SubAccountTxCtx {
near: Near;
}
Contract deployment
From a global contract (recommended)
Publish your contract to the global registry (once), then deploy by reference. This is cheaper and faster than uploading Wasm each time.
See: NEAR Kit โ Global Contracts
siwn({
subAccount: {
parentAccount: "myapp.near",
deploy: { fromPublished: { accountId: "myapp.near" } },
init: {
methodName: "init",
args: (ctx) => ({ owner: ctx.userAccountId }),
},
},
});
From raw Wasm
import { readFileSync } from "fs";
const wasm = readFileSync("./contract.wasm");
siwn({
subAccount: {
parentAccount: "myapp.near",
deploy: { wasm },
},
});
From an immutable hash
siwn({
subAccount: {
parentAccount: "myapp.near",
deploy: { fromPublished: { codeHash: "5FzD8..." } },
init: { methodName: "init", args: { owner: "myapp.near" } },
},
});
Dynamic init args
init.args accepts a function that receives the transaction context:
init: {
methodName: "init",
args: (ctx) => ({
owner: ctx.userAccountId,
parent: ctx.parentAccount,
subaccount: ctx.newAccountId,
}),
}
Transaction hooks
extendTx โ compose arbitrary actions
Add any NEAR Kit TransactionBuilder actions (stake, function calls, additional keys, delegate actions, etc.) to the creation transaction. All actions are atomic โ if any fails, the entire transaction reverts.
The tx already has createAccount, addKey (user), addKey (parent if parentHasFullAccess), transfer, deploy, and init applied before extendTx is called.
siwn({
subAccount: {
parentAccount: "myapp.near",
parentHasFullAccess: true,
extendTx: (tx, ctx) => tx
.functionCall(ctx.newAccountId, "configure", {
owner: ctx.userAccountId,
quota: "100",
}),
},
});
Available TransactionBuilder methods: createAccount, addKey, deleteKey, deleteAccount, functionCall, transfer, stake, deployContract, deployFromPublished, publishContract, stateInit, signWith, signedDelegateAction, delegate.
Reference: NEAR Kit โ Action Reference
Lifecycle hooks
onCreated โ post-creation side effects
Runs after the transaction succeeds and internal DB records are written. Use for creating your own DB records, sending notifications, etc.
siwn({
subAccount: {
parentAccount: "myapp.near",
onCreated: async (ctx) => {
await db.wiki.create({
accountId: ctx.newAccountId,
owner: ctx.userAccountId,
});
},
},
});
onRollback โ consumer cleanup
If onCreated throws, the plugin:
- Deletes internal DB records (nearAccount, internalAdapter)
- Calls
onRollback(ctx) if provided (consumer cleanup)
- Automatically deletes the on-chain account (
deleteAccount({ beneficiary: parentAccount }))
- Returns a 500 error
siwn({
subAccount: {
parentAccount: "myapp.near",
onCreated: async (ctx) => {
await db.wiki.create({ accountId: ctx.newAccountId });
},
onRollback: async (ctx) => {
await db.wiki.delete({ accountId: ctx.newAccountId }).catch(() => {});
},
},
});
The lifecycle flow:
1. Send atomic transaction
โ fail โ return error (nothing to rollback)
โ succeed โ continue
2. Write internal DB records (nearAccount, internalAdapter)
โ fail โ ROLLBACK: onRollback โ delete DB records โ deleteAccount โ return 500
โ succeed โ continue
3. Call onCreated(ctx)
โ fail โ ROLLBACK: onRollback โ delete DB records โ deleteAccount โ return 500
โ succeed โ return 200 success
Complete event: SubAccountLifecycleCtx includes near: Near for additional on-chain operations in both onCreated and onRollback.
Client-side flow
import { authClient } from "./auth-client";
const { data: avail } = await authClient.near.checkSubAccountAvailability({
subAccountName: "myapp",
});
if (!avail.available) {
console.log("Not available:", avail.reason);
return;
}
const result = await authClient.near.createSubAccount({
subAccountName: "myapp",
publicKey: "ed25519:...",
});
console.log(result.data.accountId);
Plugin Config (everything.dev)
When using the @everything-dev/auth-plugin, the following SubAccountConfig fields can be set through bos.config.json variables.siwn.subAccount. Fields that accept functions (extendTx, onCreated, onRollback, dynamic init.args) or binary data (deploy.wasm) are not serializable through JSON config โ use the raw better-near-auth library for those.
{
"app": {
"auth": {
"variables": {
"siwn": {
"subAccount": {
"mainnet": {
"parentAccount": "myapp.near",
"parentHasFullAccess": true,
"minDeposit": "0.1 NEAR",
"deploy": { "fromPublished": { "accountId": "myapp.near" } },
"init": { "methodName": "init", "args": { "owner": "myapp.near" } },
"addRelayerFCAK": true,
"relayerFCAK": {
"receiverId": "myapp.near",
"methodNames": ["*"],
"allowance": "0.25 NEAR"
}
},
"testnet": {
"parentAccount": "dev.myapp.testnet"
}
}
}
},
"secrets": [
"NEAR_SUB_ACCOUNT_PARENT_KEY_MAINNET",
"NEAR_SUB_ACCOUNT_PARENT_KEY_TESTNET"
]
}
}
}
The plugin's server code forwards these to the siwn() plugin's subAccount config automatically. The parent keys go in secrets โ see better-near-auth#auth-plugin for plugin registration details.
Available vs. unavailable fields via plugin config
| Scalar (available in bos.config.json) | Non-scalar (not serializable) |
|---|
parentAccount | deploy.wasm |
parentHasFullAccess | init.args (dynamic โ function) |
minDeposit | extendTx |
deploy.fromPublished | onCreated |
init (static args object) | onRollback |
addRelayerFCAK | |
relayerFCAK | |
For non-scalar fields, configure siwn() directly on the server instead of using the plugin config.
Common Mistakes
HIGH Not setting parentHasFullAccess when parent needs recovery
Without parentHasFullAccess: true, the parent has no access to the subaccount. If the user loses their key, the subaccount is unrecoverable.
siwn({
subAccount: { parentAccount: "myapp.near" },
});
siwn({
subAccount: {
parentAccount: "myapp.near",
parentHasFullAccess: true,
},
});
HIGH Missing secrets.parentKey when parent differs from relayer
The creation transaction must be signed by the parent account. If the parent account is different from the relayer, provide the parent key via secrets:
siwn({
relayer: { accountId: "relayer.near", privateKey: "..." },
subAccount: { parentAccount: "user.parent.near" },
});
siwn({
relayer: { accountId: "relayer.near", privateKey: "..." },
secrets: { parentKey: process.env.PARENT_KEY },
subAccount: { parentAccount: "user.parent.near" },
});
MEDIUM Not checking availability before creation
Always check availability before attempting creation:
await authClient.near.createSubAccount({ subAccountName: "myapp", publicKey });
const { data } = await authClient.near.checkSubAccountAvailability({
subAccountName: "myapp",
});
if (!data.available) return;
await authClient.near.createSubAccount({ subAccountName: "myapp", publicKey });
MEDIUM Using relayer ephemeral mode without parentAccount
Ephemeral mode generates an implicit hex account that cannot own sub-accounts. Either set subAccount.parentAccount to a named account or use an explicit relayer.
MEDIUM Forgetting to handle rollback cleanup in onCreated
If onCreated writes to your own database and throws, the plugin automatically deletes the on-chain account and its internal DB records. Your onRollback should clean up your own side effects:
onCreated: async (ctx) => {
await myDb.create({ id: ctx.newAccountId });
},
onRollback: async (ctx) => {
await myDb.delete({ id: ctx.newAccountId }).catch(() => {});
},
MEDIUM Assuming onRollback replaces on-chain cleanup
onRollback is for consumer cleanup only. The plugin always deletes the on-chain account and its internal DB records automatically when rollback is triggered. Do not call deleteAccount inside onRollback.
LOW Using secrets.parentKey in subAccount config
parentKey is no longer a field on SubAccountConfig. It was moved to secrets.parentKey for security (kept out of config and encrypted storage):
subAccount: { parentKey: "ed25519:..." }
secrets: { parentKey: "ed25519:..." }
subAccount: { parentAccount: "myapp.near" }