@stacknet/keyutils
v0.9.8
Published
Reusable components for StackNet node keys and noncustodial node key pools
Maintainers
Readme
@stacknet/keyutils
Reusable React components, hooks, and clients for StackNet node keys and noncustodial node key pools.
Install
pnpm add @stacknet/keyutilsPeer dependencies: react >= 18, react-dom >= 18
Quick Start
import '@stacknet/keyutils/styles.css';
import { BuyKeyWidget } from '@stacknet/keyutils/components';
<BuyKeyWidget
apiBaseUrl=""
stackId="stk_your_stack_id"
stackName="My App"
paymentMethods={['stripe', 'solana']}
merchantWallet="YOUR_SOLANA_WALLET_ADDRESS"
maxQuantity={5}
keyImage="/key-art.png"
aispImage="/aisp-chip.png"
onMintSuccess={(result) => console.log('Key generated:', result)}
/>The BuyKeyWidget wraps its own KeyUtilsProvider and manages state internally. Import the package stylesheet once in your app entry point (for Next.js, app/layout.tsx).
Styles and theme (0.9.0)
The package ships compiled CSS and local DM Sans and JetBrains Mono fonts. Consumers do not need Tailwind, a content scan, a font service, or a CDN. Keep the stylesheet import when upgrading from an earlier release.
Dark mode is the default, independent of the host page. Set theme="light" or theme="system" on BuyKeyWidget or NodeKeyPoolsWidget to opt in. For individual panels, set config.theme on KeyUtilsProvider. The system option follows the device preference through CSS and is safe for server rendering.
import '@stacknet/keyutils/styles.css';
import { KeyUtilsProvider, NodeKeyPoolsPanel } from '@stacknet/keyutils/components';
<KeyUtilsProvider config={{ apiBaseUrl: '', theme: 'dark', pools }}>
<NodeKeyPoolsPanel />
</KeyUtilsProvider>Each provider creates a .sn-keyutils scope. Colors, reset rules, typography, utilities, and animations stay inside that scope; the package does not toggle classes on the document. KeyUtilsTheme exposes the same wrapper for standalone presentational components. Providers and theme wrappers accept className and style for host layout. Nested wrappers may use different themes.
The widget uses full-width segmented navigation and the original max-w-xl content width (36rem), with individual bordered key cards and Active/Listed filters. Meters show tokens remaining in 40 segments: green at 60% or more, amber at 30–59%, and red below 30%. Numeric values remain visible alongside color. Controls include keyboard focus styles, and animation respects reduced motion.
Font assets, OFL licenses, and source versions/checksums are included in dist/fonts. The fonts are served from the installed package alongside the CSS.
Components
BuyKeyWidget
Main embeddable widget with Buy, Use, and Keys tabs. Supplying pools adds a Pools tab.
| Prop | Type | Description |
|------|------|-------------|
| apiBaseUrl | string | Base URL for API calls (use "" for same-origin) |
| stackId | string | Stack identifier for payment config |
| stackName | string | Display name for the originating stack |
| paymentMethods | ('stripe' \| 'solana')[] | Enabled payment methods |
| merchantWallet | string | Solana wallet address for crypto payments |
| maxQuantity | number | Max keys per purchase (default: 5) |
| keyImage | string | Image URL shown above price on Buy tab |
| keyImageClass | string | CSS class for key image (default: w-1/2 mx-auto) |
| aispImage | string | Image URL shown on Use tab |
| termsUrl | string | URL for terms link (default: /terms) |
| solanaRpcUrl | string | Solana RPC endpoint (default: mainnet) |
| defaultTab | 'buy' \| 'use' \| 'keys' \| 'pools' | Initial active tab (pools requires pool configuration) |
| theme | 'dark' \| 'light' \| 'system' | Color theme (default: dark) |
| pools | NodeKeyPoolsConfig | Enables pool creation, invitations, participation, and management |
| onMintSuccess | (result) => void | Called after successful key generation |
| onMintError | (error) => void | Called on generation failure |
| onListingCreated | (listing) => void | Called after marketplace listing |
KeyUtilsProvider
Context provider for using individual components and hooks outside of BuyKeyWidget.
import { KeyUtilsProvider, KeyList } from '@stacknet/keyutils/components';
<KeyUtilsProvider config={{ apiBaseUrl: '' }}>
<KeyList getKeyHref="/keys/get" />
</KeyUtilsProvider>Other Components
| Component | Description |
|-----------|-------------|
| BuyPanel | Purchase flow with pricing, quantity, and payment |
| UsePanel | Download links with platform detection |
| SellPanel | Marketplace listing interface |
| KeyList | Displays user's keys with balances |
| KeyCard | Single key with expandable ledger |
| KeyBalanceBar | Token balance progress bar |
| SolanaPayButton | Solana payment via Phantom wallet |
| MintSuccess | Post-purchase success display |
All components except BuyKeyWidget and NodeKeyPoolsWidget require a parent KeyUtilsProvider.
Node key pools
Pools delegate operating rights only: arm, disarm, and node operation. The original owner retains the key, earnings, and spending permissions. A controller cannot transfer, list, sell, withdraw, or redelegate a contributor's key. An invitation identifies a pool; it grants no permission. Operating failures remain subject to protocol slashing.
Add the Pools tab or embed it separately
import { BuyKeyWidget, NodeKeyPoolsWidget } from '@stacknet/keyutils/components';
import type { NodeKeyPoolsConfig } from '@stacknet/keyutils/types';
// This example assumes the host implements a trusted /api/stacknet proxy
// for the /v1 routes listed below. Existing /api/keys routes are insufficient.
const pools: NodeKeyPoolsConfig = {
apiBaseUrl: '/api/stacknet',
inviteBaseUrl: 'https://your-stacknet-authority.example',
};
<BuyKeyWidget apiBaseUrl="" pools={pools} defaultTab="pools" />
// Or a self-contained pool-only embed:
<NodeKeyPoolsWidget config={pools} />Without signCommand, users can view their pools, copy invitations, use their account-authorized owner enable switch, and follow the displayed CLI commands for signed operations. With a signer connected, forms support creating a pool, reviewing and signing a contribution, updating its expiry/reserve by joining the same pool again, controller arm/disarm, and owner revocation. Creation requires an active owned key and the public key of an existing controller signer. It does not automatically delegate the creator's key.
NodeKeyPoolsPanel also works inside KeyUtilsProvider with config.pools. Panel and standalone widget props:
| Prop | Purpose |
|------|---------|
| initialPoolId | Initially selected pool, subject to account access |
| invitation | Prefills the join form with an ID or URL; always requires review and explicit consent |
| onPoolChanged(poolId) | Called after an accepted mutation |
| className, style | Host styling |
Connect the existing signer
The host provides a NodeKeyPoolSigner callback. It receives the required role, expected public key, the command to review, and exact UTF-8 bytes to sign with Ed25519. It returns a canonical base64 encoding of the 64-byte signature. No private key, seed, or wallet dependency is accepted or stored by keyutils.
import type { NodeKeyPoolSigner } from '@stacknet/keyutils/types';
// Implement this adapter using your existing signer integration.
// signerBridge here is host-owned; it is not an export from this package.
const signCommand: NodeKeyPoolSigner = async (request) => {
const signer = await signerBridge.select({
role: request.role,
publicKeyB64: request.publicKeyB64,
keyId: request.keyId,
poolId: request.poolId,
});
if (signer.publicKeyB64 !== request.publicKeyB64) {
throw new Error('The connected signer does not match this key or pool');
}
return signer.reviewAndSignEd25519(request.command, request.payload);
};
const pools = {
apiBaseUrl: '/api/stacknet',
inviteBaseUrl: 'https://your-stacknet-authority.example',
signCommand,
};Owner signatures use the node key's already bound Ed25519 identity; an unrelated payment wallet cannot sign for it. If that binding is absent, the UI reports it and the CLI delegation workflow can initialize the local identity. Pool creation stores an immutable controller public key: provision and retain its matching signer on node hosts before creating the pool. The package does not generate controller keys or implement controller recovery. Treat the callback's command as review data; sign the supplied bytes exactly.
Client and hook
import { createNodeKeyPoolClient } from '@stacknet/keyutils/core';
const client = createNodeKeyPoolClient({
apiBaseUrl: 'https://your-stacknet-authority.example',
getHeaders: () => auth.getHeaders(), // host-owned account auth
signCommand, // host-owned signer adapter above
});
const { pools } = await client.list();
const { keys, ownerId } = await client.getOwnedKeys();
const details = await client.getDescriptor('nkp_pool_id');
await client.delegate({
keyId: 'nk_key_owned_id',
pool: details.poolId,
descriptor: details, // pin the pool descriptor the user reviewed
expiresInDays: 30,
minBalance: '1000',
});
await client.setOwnerEnabled('nk_key_owned_id', true);
// Separately, with the controller's signer:
await client.setPoolArmed('nkp_pool_id', 'nk_key_owned_id', true);
// The key owner may revoke at any time:
await client.revoke('nk_key_owned_id');create({ name, creatorKeyId, operatorPubkeyB64, poolId? }), getStatus(poolId), and invitation(poolId) complete the client API. Read methods accept an optional AbortSignal. NodeKeyPoolError.status preserves HTTP status codes, including stale-nonce conflicts (409). Mutations fetch fresh authoritative key state before requesting a signature, and do not automatically retry or re-sign a failed request. Nonces, owner epochs, and reserves remain decimal strings without number coercion. A two-minute signing request that expires while awaiting approval is not submitted.
useNodeKeyPools(poolId?) exposes client, pools, keys, ownerId, status, loading, error, actionError, busy, canSign, refresh(), and run(client => client.operation(...)). run serializes user actions and refreshes authoritative state, returning the result on success or null on failure. Errors are available separately. Mount it inside KeyUtilsProvider with config.pools. Keep auth/signer callbacks stable; remount the widget on an account change or supply new callbacks so account-specific state is cleared.
Pool transport routes
pools.apiBaseUrl is the authority or trusted proxy root, without /v1. All of these routes must reach the same authority:
| Route | Method | Authorization |
|-------|--------|---------------|
| /v1/node-key-pools | GET | Account; lists created or contributed pools and the signing domain |
| /v1/node-key-pools/:id | GET | Account; creator sees operational roster, contributor sees own keys |
| /v1/node-key-pools/:id/descriptor | GET | Public; minimum enrollment descriptor |
| /v1/node-key-pools/:id/join | GET | Public invitation page (link destination) |
| /v1/node-key-pools/commands | POST | Protocol owner or controller signature |
| /v1/node-keys/by-owner | GET | Account; owned keys, signer binding, nonce and pool state |
| /v1/node-keys/:id/arm | POST | Owner account with write permission; { armed: boolean } |
For browser integration, use same-origin authenticated proxies or configure the authority's CORS and account transport. getHeaders supports bearer/native authentication. Browser mutations include the existing CSRF header; a cookie-authenticated host proxy must enforce CSRF and authenticate its owner routes. Proxy signed commands unchanged to the authority; do not replace a contributor signature with an application/admin signer or translate the operator into a key owner. The authority enforces the final permission check. No Next.js routes are installed by this library.
Use inviteBaseUrl for the public authority root when requests use a proxy. Invitations are restricted to that configured origin and path; the client extracts only the ID and never follows an invitation host with account credentials. Descriptors omit credentials, API redirects are rejected, and expectedDomain can pin a known authority signing domain. Pool-list and descriptor domains must match before signing.
Participation and rotation
Delegate for 1–90 days with at least 1,000 tokens reserved. Drain a running node before delegation; the authority rejects an active serving lease or competing live worker session. New grants start with the pool switch disarmed. The owner enable switch and controller pool switch are independent, and both must be on. The owner can revoke immediately; this can interrupt in-flight work. Expired delegations remain visible until explicitly revoked or replaced.
The UI displays operational status, reserves, expiry, and active worker sessions as a snapshot. Refresh for current state. Automatic acquisition, renewal, draining, and rotation run in the existing CLI supervisor:
stacknet node start --pool nkp_pool_id --relayThe default rotation low-water threshold is 10,000 tokens; a replacement must clear that threshold and the owner's reserve. An Available UI label reports basic eligibility; the authority arbitrates actual acquisition, and the supervisor applies its configured threshold. Browser tabs never acquire worker sessions or run a hidden rotation loop. No reward sharing, ownership custody, controller rotation, or pool deletion is introduced.
Pool components use the bundled stylesheet and the same scoped theme as the rest of keyutils.
Hooks
import { usePricing, useKeys, useMintKey, useListKey } from '@stacknet/keyutils/hooks';| Hook | Returns | Description |
|------|---------|-------------|
| usePricing() | { pricing, loading, error, refresh } | Live pricing with 60s auto-refresh |
| useKeys() | { keys, totalBalance, loading, error, refresh } | User's node keys |
| useKeyLedger(keyId) | { entries, loading, error, refresh } | Ledger for a specific key |
| useMintKey() | { mint, minting, result, error } | Key generation after payment |
| useListKey() | { listKey, delistKey, listing, result, error } | Marketplace list/delist |
| useStripeCheckout() | { createSession, loading, error } | Stripe Checkout Session creation |
All hooks require a parent KeyUtilsProvider.
API Proxy Routes
The widget expects these proxy routes on the host app:
| Route | Method | Proxies To |
|-------|--------|------------|
| /api/keys | GET | /api/v2/node-keys |
| /api/keys/pricing | GET | /api/v2/node-keys/pricing |
| /api/keys/mint | POST | /api/v2/node-keys/mint |
| /api/keys/:id | GET | /api/v2/node-keys/:id |
| /api/keys/:id/ledger | GET | /api/v2/node-keys/:id/ledger |
| /api/keys/:id/list | POST/DELETE | /api/v2/node-keys/:id/list |
| /api/keys/stripe-session | POST | /api/v2/node-keys/stripe-session |
Sub-path Exports
import { BuyKeyWidget } from '@stacknet/keyutils/components';
import { usePricing } from '@stacknet/keyutils/hooks';
import type { NodeKeyInfo } from '@stacknet/keyutils/types';
import { cn, formatTokens } from '@stacknet/keyutils/utils';Build
pnpm --filter @stacknet/keyutils build
pnpm --filter @stacknet/keyutils typecheck
pnpm --filter @stacknet/keyutils test
pnpm --filter @stacknet/keyutils test:exports
pnpm --filter @stacknet/keyutils test:stylestest:exports runs after building and checks ESM/CJS exports, React client boundaries, and shared provider context across /components, /hooks, and root imports.
test:styles checks the published CSS export, selector and animation scoping, theme rules, local font assets, checksums, and licenses. Both regular and publish builds generate the stylesheet and copy its fonts.
License
MIT
SOL payment signing
SolanaPayButton and useSolanaPayment require a wallet with signTransaction support.
They verify that solanaRpcUrl is an HTTPS mainnet RPC before asking for a signature,
then verify the network again before broadcasting and confirming through that RPC.
Custom HTTPS proxy paths and API-key query parameters are supported.
A changed transaction or invalid signature is rejected before broadcast.
