# @locuschain/lib

> 한국어: [README.ko.md](./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

```sh
pnpm add @locuschain/lib
# or
npm i @locuschain/lib
```

Node ~22.19, ESM and CJS dual builds. Browser-compatible (no Node
built-ins beyond `fetch`).

## Quick start

```ts
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(...)`. |

```ts
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:

```ts
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.

```ts
// Equivalent of c2.getNodeStatus()
const status = await client.request<RpcGetNodeStatusResult>({
  method: 'locus_getNodeStatus',
  params: [],
});
```

## Transports

```ts
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.

```ts
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.

```ts
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`](src/accounts/types.mts) /
[`localKeySource`](src/accounts/keys/localKeySource.mts) — 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:

```ts
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.

```ts
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:

```ts
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:

```ts
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

```ts
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:

```ts
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:

1. **`toAccount({ address, master, normal })`** — builds a `DualKeyAccount`
   from two `KeySource` callback objects. The lib never sees the raw
   secret key; it just calls `getPublicKey` / `signMessage` /
   (`signKeyBind` on master) when it needs them.
2. **`createLocusWalletClient({ rpcUrl, account })`** — binds that
   account to a write client. Every TX method then runs prepare → sign →
   submit, routing to the right slot by `txKeyPolicy` (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.

```ts
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:

```ts
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:

1. The `KeySource.signMessage` callback throws an error tagged with a
   marker (e.g. `{ requireMasterKey: true }` / `{ requireNormalKey: true }`).
2. The wallet UI catches the marker, shows a "enter master keystore" /
   "enter normal keystore or secure key" modal.
3. The user submits the keystore + password; the wallet rebuilds the
   account with a non-stub `KeySource` for 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+ED25519` produces different
  signatures for the same message. The lib reuses the prepare-stage
  signature on submit (see `keyBind.sign` reuse 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`:

```ts
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 with `pnpm run generate-code` (requires Docker; see
  `generate-code.sh`).
