@locuschain/lib
v0.2.1
Published
> 한국어: [README.ko.md](./README.ko.md)
Readme
@locuschain/lib
한국어: README.ko.md
Viem-style TypeScript client for Locus Chain. Provides typed RPC, dual-key account handling, contract calls, and post-quantum keystore helpers backed by the project's own WASM crypto.
Install
pnpm add @locuschain/lib
# or
npm i @locuschain/libNode ~22.19, ESM and CJS dual builds. Browser-compatible (no Node
built-ins beyond fetch).
Quick start
import {
createLocusPublicClient,
createLocusWalletClient,
keysToAccount,
createKeystoreBundle,
unlockKeystoreBundle,
} from '@locuschain/lib';
import { loadLocusWasm } from '@locuschain/lib/utils';
await loadLocusWasm();
// 1. Read-only client — `publicActions` + `debugPublicActions` already attached.
const reader = createLocusPublicClient({
rpcUrl: 'https://node.locuschain.example/rpc',
});
const detail = await reader.getAccountDetail({ account: 'LC1xxx...' });
// 2. Build a dual-key account (in-memory keys; see "Account models" below).
const bundle = createKeystoreBundle({
passwordMaster: 'pw-master',
passwordNormal: 'pw-normal',
algoMaster: 'FALCON+ED25519',
algoNormal: 'ED25519',
});
const { address, msk, mpk, nsk, npk } = unlockKeystoreBundle({
ksJsonBundle: bundle,
passwordMaster: 'pw-master',
passwordNormal: 'pw-normal',
});
const account = keysToAccount({ address, msk, mpk, nsk, npk });
// 3. Wallet client — `walletActions` + `debugWalletActions` already attached.
// Every write RPC runs the prepare → sign → submit pipeline internally.
const wallet = createLocusWalletClient({
rpcUrl: 'https://node.locuschain.example/rpc',
account,
});
await wallet.transferCoin({
from: address,
to: 'LC1yyy...',
amount: '1000',
sign: '',
signedHeight: 0,
feeType: 0,
});Bare
createPublicClient/createWalletClient+ manual.extend(...)is still available when you want to customize the action set; see "Clients" below.
API surface
| Subpath | Exports |
|---|---|
| @locuschain/lib | Root barrel — re-exports clients, transports, accounts, errors, contracts, keystore helpers, primitive types |
| @locuschain/lib/clients | createClient, createPublicClient, createWalletClient, createLocusPublicClient, createLocusWalletClient, Client, PublicClient, WalletClient, LocusPublicClient, LocusWalletClient |
| @locuschain/lib/transports | http(url, config?), Transport, TransportInstance |
| @locuschain/lib/accounts | keysToAccount, toAccount, mnemonicToAccount, localKeySource, toKeySource, KeySource, DualKeyAccount |
| @locuschain/lib/errors | BaseError, RpcRequestError, RpcResponseError, HttpRequestError, TimeoutError, AbortedError, AccountRequiredError, InvalidTxResultError, SignFailedError, SignKeyBindUnsupportedError, DegenerateKeyError |
| @locuschain/lib/contracts | getContract({ address, abi, client }), erc20Abi |
| @locuschain/lib/autogen | publicActions, walletActions, debugPublicActions, debugWalletActions, every generated per-method action (e.g. transferCoin, getAccountDetail, ...), and every RPC schema type |
| @locuschain/lib/utils | WASM bindings (sign, signByMasterKey, signKeyBind, loadLocusWasm, ...), keystore helpers (createKeystoreBundle, unlockKeystoreBundle, ...), address utilities |
| @locuschain/lib/types | Hex, Hash, Address, and every autogen domain type |
| @locuschain/lib/constant | TxType, TxTypes, Currency, AddressClass, FeeType |
Clients
There are two layers of client constructors:
| Constructor | Use when |
|---|---|
| createLocusPublicClient({ rpcUrl, transport? }) | You want a read-only client with the standard Locus action set (publicActions + debugPublicActions) pre-attached. Returns a LocusPublicClient. |
| createLocusWalletClient({ rpcUrl, account, transport? }) | You want a write client with the standard write action set (walletActions + debugWalletActions) pre-attached and a DualKeyAccount bound. Returns a LocusWalletClient. |
| createPublicClient({ transport }) / createWalletClient({ transport, account }) | You want to attach a custom action set yourself via .extend(...). |
import { createLocusPublicClient, createLocusWalletClient } from '@locuschain/lib';
const reader = createLocusPublicClient({
rpcUrl: 'https://node.example/rpc',
transport: { headers: { 'X-Trace-Id': 'abc' } }, // forwarded to http()
});
await reader.getNodeStatus();
await reader.getAccountDetail({ account });
const wallet = createLocusWalletClient({ rpcUrl: 'https://node.example/rpc', account });
await wallet.transferCoin({ from, to, amount: '1000', sign: '', signedHeight: 0, feeType: 0 });The bare constructors are still useful when you want to pick a subset of actions or stack debug / custom helpers explicitly:
import { createPublicClient, http } from '@locuschain/lib';
import { publicActions, debugPublicActions } from '@locuschain/lib/autogen';
const client = createPublicClient({ transport: http(url) });
const c2 = client.extend(publicActions).extend(debugPublicActions);
await c2.getNodeStatus();
await c2.allHeights();request is a thin JSON-RPC call. Per-method actions wrap it with
typed args; you rarely need request directly.
// Equivalent of c2.getNodeStatus()
const status = await client.request<RpcGetNodeStatusResult>({
method: 'locus_getNodeStatus',
params: [],
});Transports
import { http } from '@locuschain/lib';
const transport = http('https://node.example/rpc', {
timeout: 30_000, // default 30s
headers: { 'X-Trace-Id': '...' }, // merged into every request
fetchOptions: { keepalive: true }, // forwarded to fetch
});Errors thrown by the http transport are subclasses of BaseError:
HttpRequestError, TimeoutError, AbortedError, RpcResponseError.
Account models
keysToAccount — in-memory plain keys
Suitable for dev, CLI tools, or service-side daemons that hold key material in process memory.
const account = keysToAccount({ address, msk, mpk, nsk, npk });mnemonicToAccount — BIP-39 derivation
One-call shortcut that turns a mnemonic into a sign-ready
DualKeyAccount. The derived secret keys live in the returned
localKeySource closures only — the library never re-exposes them.
const account = mnemonicToAccount({
mnemonic: 'word1 word2 ...',
algoMaster: 'FALCON+ED25519',
algoNormal: 'ED25519',
index: 0,
});
// or:
const account = mnemonicToAccount({
mnemonic: '...',
path: "m/4403'/190301'/0'/0'/0'",
});Use this when you need to sign a one-off TX from a mnemonic and discard the keys afterwards — CLI scripts, end-to-end tests, a dApp's "paste mnemonic, send one TX" flow.
Do NOT use this when you need the raw { msk, mpk, nsk, npk } —
for example, when a wallet wants to persist the keypair in its own
storage, derive a keystore JSON, or pre-compute a keyBind.keySign for
later open-account flows. mnemonicToAccount returns a DualKeyAccount
whose master / normal are KeySource objects with no secret-key
accessor by design (see KeySource /
localKeySource — the key
argument is captured in a closure and never re-exposed).
For that case, call the lower-level WASM helper directly and reshape the JSON yourself:
import { deriveKeysFromMnemonic } from '@locuschain/lib/utils';
import { keysToAccount } from '@locuschain/lib';
const k = JSON.parse(deriveKeysFromMnemonic({
mnemonic, algoMaster, algoNormal, index: 0,
}));
// k = { address, masterSecretKey, masterPublicKey, secretKey, publicKey, ... }
// Persist `k.*` in your own wallet storage, derive keystores, etc.
// Only when you need a sign-ready account again:
const account = keysToAccount({
address: k.address,
msk: k.masterSecretKey, mpk: k.masterPublicKey,
nsk: k.secretKey, npk: k.publicKey,
});This separation is deliberate — mnemonicToAccount is the viem-style
"derive and forget" API, deriveKeysFromMnemonic is the raw-material
API for wallet builders who must own the key bytes.
toAccount({ master, normal }) — callback-driven (keystore / hardware / external wallet)
This is the path for production wallets that don't want to give the
library raw secret keys. Provide two KeySource callback objects via
toKeySource(...); the library calls them and never sees the secret.
import { toAccount, toKeySource } from '@locuschain/lib';
const master = toKeySource({
type: 'ledger',
getPublicKey: () => ledger.getPublicKey(),
signMessage: ({ message }) => ledger.sign(message),
signKeyBind: ({ newNpk, currentNpk }) => ledger.signKeyBind(newNpk, currentNpk),
});
const normal = toKeySource({
type: 'keystore',
getPublicKey: () => Promise.resolve(npk),
signMessage: async ({ message }) => {
const password = await promptUser();
const sk = await loadNormalKeystore({ passStr: password, ksJson });
return sign({ sk, message });
},
});
const account = toAccount({ address, master, normal });Mix freely — master from hardware wallet + normal from keystore is fine. The library doesn't care.
Keystore helpers
For the bundled keystore format that the project's WASM emits:
import {
createKeystoreBundle,
splitKeystoreBundle,
unlockMasterKeystore,
unlockNormalKeystore,
unlockKeystoreBundle,
} from '@locuschain/lib';
const bundle = createKeystoreBundle({
passwordMaster, passwordNormal,
algoMaster: 'FALCON+ED25519',
algoNormal: 'ED25519',
});
// bundle.raw — persistable JSON
// bundle.masterKsJson — master half only
// bundle.normalKsJson — normal half only
const keys = unlockKeystoreBundle({
ksJsonBundle: bundle, // or the raw JSON string
passwordMaster, passwordNormal,
});
// keys = { address, msk, mpk, nsk, npk }
// Individually if needed:
const master = unlockMasterKeystore({ ksJson: bundle.masterKsJson, password: passwordMaster });
const normal = unlockNormalKeystore({ ksJson: bundle.normalKsJson, password: passwordNormal });Recommended algorithms
| Slot | Recommended | Notes |
|---|---|---|
| master | FALCON+ED25519 | Default when algoMaster is left empty. PQ-safe combined signer. |
| normal | ED25519 | Default when algoNormal is left empty. Fast, deterministic, sufficient for per-tx signing. |
mnemonicToAccount and unlockKeystoreBundle defensively detect the
case where the WASM returns identical master and normal public keys and
throw DegenerateKeyError. This guard is a regression net — an earlier
JS-bridge bug (now fixed) used to drop the deterministic seed when
forwarding it to WASM, causing keys to repeat across slots for AIMER /
HAETAE pairs. The bridge fix is in place, so the guard should never
fire under normal operation; if it does, it indicates a new regression
on the JS or WASM side rather than an algorithm-level limitation.
Locus dual-key transaction model
Every TX is signed by one of the two keys depending on tx.type. The
library handles the routing inside dualKeyAccount.signTransaction:
| TX type | tx main signature | extra master attestation |
|---|---|---|
| TX_CLOSE_ACCOUNT | master | — |
| TX_OPEN_ACCOUNT | normal | keySign ← master.signKeyBind(newNpk = pk, currentNpk = "") |
| TX_CHANGE_KEYPAIR | new normal | signByMasterKey ← master.signKeyBind(newNpk = newNormalPkey, currentNpk = old normal pubkey) |
| TX_CHANGE_VKEY | normal | — (newValidationPkey is part of the prepare param) |
| everything else | normal | — |
For TX_OPEN_ACCOUNT the master attestation must be present on the
prepare RPC too — the node validates it before returning the tx
hash. dualKeyAccount.prepareParams fills keySign in automatically
when the caller passes an empty string. On the submit RPC the library
reuses the prepare-stage signature read back from
prepared.tx.keyBind.sign — this matters because FALCON+ED25519 is
non-deterministic, so a re-signed value would diverge from what the
node already accepted.
Changing the normal key
TX_CHANGE_KEYPAIR is not auto-filled. The node verifies the main
tx signature against the new normal pubkey (proof-of-possession), and
signByMasterKey needs the old normal pubkey as currentNpk — those
are two different keys, so the auto-pipeline can't infer both from a
single bound account. The caller drives the flow explicitly:
import { keysToAccount, createWalletClient, http } from '@locuschain/lib';
import { walletActions } from '@locuschain/lib/autogen';
import { createNormalKey } from '@locuschain/lib/utils';
// 1. Build the OLD account and compute signByMasterKey on it.
const oldAccount = keysToAccount({ address, msk, mpk, nsk: nsk1, npk: npk1 });
const newKey = JSON.parse(createNormalKey({ addrStr: address, keyAlgo: 'ED25519' }));
const { nsecretKey: nsk2, npublicKey: npk2 } = newKey;
const signByMasterKey = await oldAccount.master.signKeyBind({
newNpk: npk2,
currentNpk: npk1,
});
// 2. Bind the NEW normal key into a fresh account.
const newAccount = keysToAccount({ address, msk, mpk, nsk: nsk2, npk: npk2 });
const wallet = createWalletClient({ transport: http(url), account: newAccount })
.extend(walletActions);
// 3. Submit. `signByMasterKey` is non-empty so prepareParams passes through;
// the main tx is signed by newAccount.normal (= nsk2), which matches the
// NewKey field the node will verify against.
await wallet.changeKey({
account: address,
masterPkey: mpk,
newNormalPkey: npk2,
signByMasterKey,
sign: '',
signedHeight: 0,
});Calling wallet.changeKey({ ..., signByMasterKey: '' }) raises a
BaseError pointing at this section. The library will not silently
compute a signature that the node would reject on submit.
Contracts
import { getContract, erc20Abi } from '@locuschain/lib';
const token = getContract({
address: 'LC1xxxTOKEN',
abi: erc20Abi,
client: reader,
});
const symbol = await token.read.symbol();
const dec = await token.read.decimals();
const balance = await token.read.balanceOf([userAddress]);Only view / pure methods are exposed via read.*. To call a
state-changing contract method use the autogen callContract action
on a wallet client:
import { callContract } from '@locuschain/lib/autogen';
await callContract(wallet, {
callerAccount: walletAddr,
contractAccount: tokenAddr,
/* fuelLimit / amount / tokenAmounts / func / argData / feeType */
...
});Wallet integration
This section captures the pattern used by
locus-wallet-extension— the canonical reference consumer of@locuschain/lib. Same shape applies to any wallet that wants to keep secret material out of the library while still using the typed action surface.
The wallet wraps two pieces from the lib:
toAccount({ address, master, normal })— builds aDualKeyAccountfrom twoKeySourcecallback objects. The lib never sees the raw secret key; it just callsgetPublicKey/signMessage/ (signKeyBindon master) when it needs them.createLocusWalletClient({ rpcUrl, account })— binds that account to a write client. Every TX method then runs prepare → sign → submit, routing to the right slot bytxKeyPolicy(see "Locus dual-key transaction model" above).
Lazy KeySource per slot
Build a KeySource for each slot, in priority order. The lib only
calls the callbacks at sign time, so a slot can stay unfilled (or
filled with a stub that throws on use) until a TX actually requires it.
import { toAccount, toKeySource, localKeySource } from '@locuschain/lib';
import { loadMasterKeystore, sign as wasmSign, signByMasterKey, signKeyBind } from '@locuschain/lib/utils';
function buildAccount(opts: {
address: string;
mpub: string;
npub: string;
npriv?: string; // memory-resident normal sk
masterKeystore?: { json: string; password: string }; // unlocked on demand
hardware?: { signMessage(args): Promise<string>; ... };
}) {
// Master slot — memory secret? keystore? hardware? otherwise a stub
// that throws a marker error so the wallet UI can prompt for input.
const master = opts.hardware
? toKeySource({ type: 'hw-master', ...opts.hardware })
: opts.masterKeystore
? toKeySource({
type: 'keystore-master',
getPublicKey: () => Promise.resolve(opts.mpub),
signMessage: ({ message }) => unlock(opts.masterKeystore!).then(({ msk }) =>
signByMasterKey({ msk, message })),
signKeyBind: ({ newNpk, currentNpk }) => unlock(opts.masterKeystore!).then(({ msk }) =>
signKeyBind({ msk, newNpk, currentNpk })),
})
: toKeySource({
type: 'require-master',
getPublicKey: () => Promise.resolve(opts.mpub),
signMessage: () => { throw markerError('requireMasterKey'); },
signKeyBind: () => { throw markerError('requireMasterKey'); },
});
// Normal slot — usually memory-resident, falling back the same way.
const normal = opts.npriv
? localKeySource({ key: opts.npriv, publicKey: opts.npub, role: 'normal' })
: toKeySource({
type: 'require-normal',
getPublicKey: () => Promise.resolve(opts.npub),
signMessage: () => { throw markerError('requireNormalKey'); },
});
return toAccount({ address: opts.address, master, normal });
}The unlock(...) helper memoizes its inner WASM call (loadMasterKeystore →
{ msk, mpk }) inside the closure so a TX that triggers signMessage
and signKeyBind only asks the user for the password once.
Signing pipeline at runtime
Once the account is bound, the wallet pipeline collapses to a single typed call. The library decides per-TX which slot to invoke:
import { createLocusWalletClient } from '@locuschain/lib';
const account = buildAccount({ ... }); // lazy slots
const wallet = createLocusWalletClient({ rpcUrl, account });
// TX_CLOSE_ACCOUNT — main sign is *master*. If the master slot is the
// require-stub above, the throw fires *here*, the wallet UI catches the
// marker, asks for the keystore + password, rebuilds the account with the
// keystore-backed master KeySource, and retries.
await wallet.closeAccount({ from, to, mpkey, sign: '', signedHeight: 0 });
// TX_OPEN_ACCOUNT — main sign is normal; master.signKeyBind is needed for
// `keySign`. The lib auto-fills `keySign` on the prepare RPC when the
// caller passes ''.
await wallet.openAccount({ account: addr, pk: npub, mpk: mpub, keySign: '', ... });
// transferCoin / callContract / ... — main sign is normal only.
await wallet.transferCoin({ from, to, amount: '1000', sign: '', signedHeight: 0, feeType: 0 });For TX_CHANGE_KEYPAIR the lib does not auto-fill signByMasterKey
— the caller has to drive the old/new key flow explicitly. See
"Changing the normal key" above for the canonical pattern.
Surfacing "key required" to the UI
The recommended pattern when a slot can't sign yet:
- The
KeySource.signMessagecallback throws an error tagged with a marker (e.g.{ requireMasterKey: true }/{ requireNormalKey: true }). - The wallet UI catches the marker, shows a "enter master keystore" / "enter normal keystore or secure key" modal.
- The user submits the keystore + password; the wallet rebuilds the
account with a non-stub
KeySourcefor that slot and retries the TX.
Because the lib never holds the secret, the only state to "rebuild" is
the account object — the wallet client itself is cheap (no network
state, just { transport, account, ...actions }).
Why not just pass plain keys?
Three reasons that surface during real wallet integration:
- Multiple secret stores per account — master may be in a keystore while normal is memory-resident, or master could be on hardware. The callback boundary lets each slot evolve independently.
- Non-deterministic signers —
FALCON+ED25519produces different signatures for the same message. The lib reuses the prepare-stage signature on submit (seekeyBind.signreuse below). Going through callbacks instead of cached secrets makes that ordering explicit. - Just-in-time unlock — keystores cost a WASM round trip. Doing it lazily and caching the result inside the closure keeps password prompts to one per TX even if the lib signs twice.
Errors
Every error thrown by the lib is a subclass of BaseError:
import { BaseError, RpcResponseError, TimeoutError } from '@locuschain/lib';
try {
await wallet.transferCoin({ ... });
} catch (err) {
if (err instanceof TimeoutError) ...
if (err instanceof RpcResponseError) console.error(err.code, err.shortMessage);
if (err instanceof BaseError) console.error(err.metaMessages.join('\n'));
}Project layout
src/clients,src/transports,src/accounts,src/errors,src/contracts,src/types— hand-written.src/actions/wallet/— internal 2-step pipeline primitives (_prepareTransaction,_signLocusTransaction,_sendPreparedTransaction).src/utils/— WASM bindings and helpers (mnemonic, address, keystore).src/autogen/— code-generator output. Do not edit manually. Regenerate withpnpm run generate-code(requires Docker; seegenerate-code.sh).
