# @locuschain/lib

> English: [README.md](./README.md)

Locus Chain 을 위한 viem 스타일 TypeScript 클라이언트. 타입이 잡힌 RPC,
dual-key 계정 처리, 컨트랙트 호출, 그리고 프로젝트 자체 WASM crypto 기반의
post-quantum 키스토어 헬퍼를 제공합니다.

## 설치

```sh
pnpm add @locuschain/lib
# 또는
npm i @locuschain/lib
```

Node ~22.19, ESM / CJS dual 빌드. 브라우저 호환 (`fetch` 외 Node 빌트인을
쓰지 않음).

## 빠른 시작

```ts
import {
  createLocusPublicClient,
  createLocusWalletClient,
  keysToAccount,
  createKeystoreBundle,
  unlockKeystoreBundle,
} from '@locuschain/lib';
import { loadLocusWasm } from '@locuschain/lib/utils';

await loadLocusWasm();

// 1. 읽기 전용 클라이언트 — `publicActions` + `debugPublicActions` 가 이미 부착.
const reader = createLocusPublicClient({
  rpcUrl: 'https://node.locuschain.example/rpc',
});
const detail = await reader.getAccountDetail({ account: 'LC1xxx...' });

// 2. dual-key 계정 구성 (인메모리 키. 자세한 패턴은 아래 "Account 모델" 참고)
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. 지갑 클라이언트 — `walletActions` + `debugWalletActions` 가 이미 부착.
//    모든 write RPC 가 prepare → sign → submit 파이프라인을 내부에서 처리.
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`
> + 수동 `.extend(...)` 도 그대로 사용 가능 — 아래 "클라이언트" 참고.

## API 구조

| 서브패스 | 노출되는 export |
|---|---|
| `@locuschain/lib` | 루트 barrel — clients, transports, accounts, errors, contracts, 키스토어 헬퍼, primitive 타입 재export |
| `@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`, 메소드별 자동생성 액션 (`transferCoin`, `getAccountDetail`, ...), 모든 RPC 스키마 타입 |
| `@locuschain/lib/utils` | WASM 바인딩 (`sign`, `signByMasterKey`, `signKeyBind`, `loadLocusWasm`, ...), 키스토어 헬퍼 (`createKeystoreBundle`, `unlockKeystoreBundle`, ...), 주소 유틸 |
| `@locuschain/lib/types` | `Hex`, `Hash`, `Address`, 그리고 모든 autogen 도메인 타입 |
| `@locuschain/lib/constant` | `TxType`, `TxTypes`, `Currency`, `AddressClass`, `FeeType` |

## 클라이언트

클라이언트 생성자는 두 계층이 있습니다:

| 생성자 | 언제 쓰나 |
|---|---|
| `createLocusPublicClient({ rpcUrl, transport? })` | 표준 Locus 액션 셋 (`publicActions` + `debugPublicActions`) 이 미리 부착된 read-only 클라이언트가 필요할 때. `LocusPublicClient` 를 반환. |
| `createLocusWalletClient({ rpcUrl, account, transport? })` | 표준 write 액션 셋 (`walletActions` + `debugWalletActions`) 이 부착되고 `DualKeyAccount` 가 바인드된 write 클라이언트가 필요할 때. `LocusWalletClient` 를 반환. |
| `createPublicClient({ transport })` / `createWalletClient({ transport, account })` | 액션 셋을 직접 `.extend(...)` 로 붙이고 싶을 때. |

```ts
import { createLocusPublicClient, createLocusWalletClient } from '@locuschain/lib';

const reader = createLocusPublicClient({
  rpcUrl: 'https://node.example/rpc',
  transport: { headers: { 'X-Trace-Id': 'abc' } },   // 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 });
```

bare 생성자는 액션 일부만 고르거나 debug / 커스텀 helper 를 명시적으로
쌓고 싶을 때 여전히 유용합니다:

```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` 는 얇은 JSON-RPC 호출입니다. 메소드별 액션이 타입이 잡힌 인자로
이를 감싸므로, `request` 를 직접 호출할 일은 거의 없습니다.

```ts
// c2.getNodeStatus() 와 동등
const status = await client.request<RpcGetNodeStatusResult>({
  method: 'locus_getNodeStatus',
  params: [],
});
```

## 트랜스포트

```ts
import { http } from '@locuschain/lib';

const transport = http('https://node.example/rpc', {
  timeout: 30_000,                            // 기본 30초
  headers: { 'X-Trace-Id': '...' },           // 모든 요청에 병합
  fetchOptions: { keepalive: true },          // fetch 에 그대로 전달
});
```

http 트랜스포트가 던지는 에러는 모두 `BaseError` 의 하위 클래스입니다:
`HttpRequestError`, `TimeoutError`, `AbortedError`, `RpcResponseError`.

## Account 모델

### `keysToAccount` — 인메모리 plain 키

개발용, CLI 도구, 또는 프로세스 메모리에 키 자료를 보관해도 되는 서비스
사이드 데몬에 적합합니다.

```ts
const account = keysToAccount({ address, msk, mpk, nsk, npk });
```

### `mnemonicToAccount` — BIP-39 derivation

mnemonic 을 sign 가능한 `DualKeyAccount` 로 바로 만드는 단축 헬퍼.
derive 된 secret key 는 반환된 `localKeySource` 클로저 안에만
존재하고 라이브러리는 secret 을 다시 노출하지 않습니다.

```ts
const account = mnemonicToAccount({
  mnemonic: 'word1 word2 ...',
  algoMaster: 'FALCON+ED25519',
  algoNormal: 'ED25519',
  index: 0,
});
// 또는:
const account = mnemonicToAccount({
  mnemonic: '...',
  path: "m/4403'/190301'/0'/0'/0'",
});
```

**이 함수가 적합한 경우** — mnemonic 으로 일회성 TX 하나 서명하고
키를 즉시 버려도 되는 흐름. CLI 스크립트, e2e 테스트, dApp 의
"mnemonic 입력 → TX 한 건 서명" 같은 단발성 시나리오.

**적합하지 않은 경우** — raw `{ msk, mpk, nsk, npk }` 가 필요한
경우. 예: 지갑이 키페어를 자체 저장소에 영구 보관하거나, keystore
JSON 을 만들거나, open-account 흐름을 위해 `keyBind.keySign` 을
미리 계산해 두는 경우. `mnemonicToAccount` 는 `DualKeyAccount` 를
반환하는데 그 안의 `master` / `normal` 은 `KeySource` 객체이고
secret-key 접근자가 의도적으로 없습니다 (디자인 의도 —
[`KeySource`](src/accounts/types.mts) /
[`localKeySource`](src/accounts/keys/localKeySource.mts) 의 `key`
인자는 클로저에 캡처되어 외부 접근 불가).

이런 경우엔 하위 레벨 WASM 헬퍼를 직접 호출하고 JSON 을 직접
reshape 합니다:

```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, ... }

// `k.*` 를 지갑 저장소에 영속화하거나 keystore 를 derive 하는 등 자유롭게 사용.
// 다시 sign 가능한 account 가 필요할 때만:
const account = keysToAccount({
  address: k.address,
  msk: k.masterSecretKey, mpk: k.masterPublicKey,
  nsk: k.secretKey,       npk: k.publicKey,
});
```

이 분리는 의도적입니다 — `mnemonicToAccount` 는 viem 스타일의
"derive 하고 잊어라" API, `deriveKeysFromMnemonic` 은 키 바이트를
직접 소유해야 하는 지갑 빌더용 raw 자료 API.

### `toAccount({ master, normal })` — 콜백 기반 (keystore / 하드웨어 지갑 / 외부 지갑)

라이브러리에 raw secret key 를 넘기지 않는 프로덕션 지갑 경로입니다.
`toKeySource(...)` 로 만든 두 개의 `KeySource` 콜백 객체를 넘기면, 라이브러리는
콜백을 호출만 하고 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 });
```

자유롭게 조합 가능 — master 는 하드웨어 지갑, normal 은 keystore 식으로도
괜찮습니다. 라이브러리는 출처를 신경 쓰지 않습니다.

## 키스토어 헬퍼

프로젝트 WASM 이 emit 하는 번들 키스토어 포맷 처리용:

```ts
import {
  createKeystoreBundle,
  splitKeystoreBundle,
  unlockMasterKeystore,
  unlockNormalKeystore,
  unlockKeystoreBundle,
} from '@locuschain/lib';

const bundle = createKeystoreBundle({
  passwordMaster, passwordNormal,
  algoMaster: 'FALCON+ED25519',
  algoNormal: 'ED25519',
});
// bundle.raw           — 영속화 가능한 JSON
// bundle.masterKsJson  — master 절반만
// bundle.normalKsJson  — normal 절반만

const keys = unlockKeystoreBundle({
  ksJsonBundle: bundle,           // 또는 raw JSON 문자열
  passwordMaster, passwordNormal,
});
// keys = { address, msk, mpk, nsk, npk }

// 필요하면 개별로:
const master = unlockMasterKeystore({ ksJson: bundle.masterKsJson, password: passwordMaster });
const normal = unlockNormalKeystore({ ksJson: bundle.normalKsJson, password: passwordNormal });
```

### 권장 알고리즘

| 슬롯 | 권장 | 비고 |
|---|---|---|
| master | `FALCON+ED25519` | `algoMaster` 가 비어 있을 때의 기본값. PQ-safe 조합 서명자. |
| normal | `ED25519` | `algoNormal` 이 비어 있을 때의 기본값. 빠르고 결정적이며 트랜잭션 서명에 충분. |

`mnemonicToAccount` 와 `unlockKeystoreBundle` 은 WASM 이 동일한 master /
normal public key 를 돌려주는 케이스를 방어적으로 감지해 `DegenerateKeyError`
를 던집니다. 이 가드는 회귀 안전망입니다 — 과거 JS 브릿지가 deterministic
seed 를 WASM 에 전달하지 못해 AIMER / HAETAE 조합에서 master/normal 키가
같은 값으로 derive 되던 버그가 있었고, 그 브릿지 결함은 이미 해결됐습니다.
따라서 정상 동작 상태에선 이 가드가 발화하지 않으며, 발화한다면 알고리즘
자체의 한계가 아니라 JS 또는 WASM 쪽 새 회귀로 봐야 합니다.

## Locus dual-key 트랜잭션 모델

모든 TX 는 `tx.type` 에 따라 두 키 중 하나로 서명됩니다. 라이브러리는
`dualKeyAccount.signTransaction` 내부에서 라우팅을 처리합니다:

| TX 타입 | tx 메인 서명 | 추가 master 증명 |
|---|---|---|
| `TX_CLOSE_ACCOUNT` | **master** | — |
| `TX_OPEN_ACCOUNT` | normal | `keySign` ← `master.signKeyBind(newNpk = pk, currentNpk = "")` |
| `TX_CHANGE_KEYPAIR` | **새** normal | `signByMasterKey` ← `master.signKeyBind(newNpk = newNormalPkey, currentNpk = 이전 normal pubkey)` |
| `TX_CHANGE_VKEY` | normal | — (`newValidationPkey` 는 prepare 파라미터에 포함) |
| 그 외 전부 | normal | — |

`TX_OPEN_ACCOUNT` 의 경우 master 증명은 **prepare** RPC 에서도 반드시 있어야
합니다 — 노드가 tx 해시를 돌려주기 전에 검증하기 때문입니다.
`dualKeyAccount.prepareParams` 가 호출자가 빈 문자열을 넘기면 `keySign` 을
자동으로 채워 줍니다. submit RPC 에서는 `prepared.tx.keyBind.sign` 에서
prepare 단계의 서명을 다시 읽어 그대로 재사용합니다 — `FALCON+ED25519` 가
non-deterministic 이라 다시 서명하면 노드가 이미 받아둔 값과 달라지기
때문입니다.

### Normal 키 교체

`TX_CHANGE_KEYPAIR` 는 **자동 채움 대상이 아닙니다**. 노드가 메인 tx 서명을
*새* normal pubkey 로 검증(proof-of-possession)하고, `signByMasterKey` 는
`currentNpk` 로 *이전* normal pubkey 가 필요합니다 — 두 키가 서로 다르기
때문에 단일 바인딩된 account 만으로는 auto-pipeline 이 양쪽을 다 추론할 수
없습니다. 호출자가 흐름을 명시적으로 주도해야 합니다:

```ts
import { keysToAccount, createWalletClient, http } from '@locuschain/lib';
import { walletActions } from '@locuschain/lib/autogen';
import { createNormalKey } from '@locuschain/lib/utils';

// 1. 이전 account 를 만들고 거기서 signByMasterKey 를 계산.
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. 새 normal 키를 새 account 에 바인딩.
const newAccount = keysToAccount({ address, msk, mpk, nsk: nsk2, npk: npk2 });
const wallet = createWalletClient({ transport: http(url), account: newAccount })
  .extend(walletActions);

// 3. submit. signByMasterKey 가 비어있지 않아 prepareParams 가 그대로 통과시킴.
//    메인 tx 는 newAccount.normal (= nsk2) 으로 서명되어, 노드가 검증할
//    NewKey 필드와 일치한다.
await wallet.changeKey({
  account: address,
  masterPkey: mpk,
  newNormalPkey: npk2,
  signByMasterKey,
  sign: '',
  signedHeight: 0,
});
```

`wallet.changeKey({ ..., signByMasterKey: '' })` 처럼 빈 값을 넘기면 위 섹션을
가리키는 `BaseError` 가 발생합니다. 라이브러리는 노드가 submit 시점에 거부할
서명을 silently 만들어 주지 않습니다.

## 컨트랙트

```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]);
```

view / pure 메소드만 `read.*` 로 노출됩니다. 상태를 바꾸는 컨트랙트 메소드를
부를 때는 wallet 클라이언트에 autogen `callContract` 액션을 사용:

```ts
import { callContract } from '@locuschain/lib/autogen';

await callContract(wallet, {
  callerAccount: walletAddr,
  contractAccount: tokenAddr,
  /* fuelLimit / amount / tokenAmounts / func / argData / feeType */
  ...
});
```

## 지갑 통합 (wallet integration)

> 이 섹션은 `locus-wallet-extension` — `@locuschain/lib` 의 표준 소비자 —
> 가 실제로 쓰는 패턴을 정리한 것입니다. 라이브러리에 secret 자료를
> 넘기지 않으면서 타입이 잡힌 액션 표면을 그대로 쓰고 싶은 모든 지갑에
> 동일하게 적용됩니다.

지갑은 lib 에서 두 가지 조각을 감쌉니다:

1. **`toAccount({ address, master, normal })`** — 두 개의 `KeySource`
   콜백 객체로부터 `DualKeyAccount` 를 만듭니다. lib 는 raw secret key 를
   보지 않고, 필요할 때만 `getPublicKey` / `signMessage` /
   (master 의 경우) `signKeyBind` 를 호출합니다.
2. **`createLocusWalletClient({ rpcUrl, account })`** — 그 account 를
   write client 에 묶습니다. 이후 모든 TX 메서드가 prepare → sign →
   submit 를 실행하며, `txKeyPolicy` 규칙대로 master/normal 슬롯이
   라우팅됩니다 (위의 "Locus dual-key 트랜잭션 모델" 참고).

### 슬롯별 lazy `KeySource`

각 슬롯에 `KeySource` 를 우선순위 순서로 구성합니다. lib 는 sign 시점에만
콜백을 *호출* 하므로, 어떤 슬롯은 실제로 그 TX 가 필요로 하기 전까지는
비어 있거나 호출 시 throw 하는 stub 으로 둘 수도 있습니다.

```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;                                      // 메모리 상주 normal sk
  masterKeystore?: { json: string; password: string };  // 필요할 때만 unlock
  hardware?: { signMessage(args): Promise<string>; ... };
}) {
  // master 슬롯 — 메모리 secret? keystore? 하드웨어? 아무 것도 없으면
  // sign 시점에 marker error 를 던지는 stub. 지갑 UI 가 modal 로 입력 받음.
  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 슬롯 — 보통 메모리 상주. 없으면 동일한 방식으로 fallback.
  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 });
}
```

`unlock(...)` 헬퍼는 클로저 내부에서 WASM 호출 (`loadMasterKeystore` →
`{ msk, mpk }`) 결과를 한 번만 캐싱합니다 — 같은 TX 가 `signMessage` 와
`signKeyBind` 를 둘 다 트리거해도 password 는 한 번만 묻습니다.

### 런타임 서명 파이프라인

account 를 한 번 바인드하면 지갑 파이프라인은 단일 타입 호출로
줄어듭니다. TX 별로 어느 슬롯을 부를지는 라이브러리가 결정:

```ts
import { createLocusWalletClient } from '@locuschain/lib';

const account = buildAccount({ ... });                     // lazy 슬롯
const wallet  = createLocusWalletClient({ rpcUrl, account });

// TX_CLOSE_ACCOUNT — 메인 sign 이 *master*. master 슬롯이 위의 require-stub
// 이라면 throw 가 *여기서* 발생. 지갑 UI 가 marker 를 잡아 keystore + password
// 를 받고, keystore 기반 master KeySource 로 account 를 다시 만들어 재시도.
await wallet.closeAccount({ from, to, mpkey, sign: '', signedHeight: 0 });

// TX_OPEN_ACCOUNT — 메인 sign 은 normal, master.signKeyBind 가 `keySign` 에 필요.
// caller 가 빈 문자열을 넘기면 lib 가 prepare RPC 단계에서 `keySign` 을 자동 채움.
await wallet.openAccount({ account: addr, pk: npub, mpk: mpub, keySign: '', ... });

// transferCoin / callContract / ... — 메인 sign 은 normal 만.
await wallet.transferCoin({ from, to, amount: '1000', sign: '', signedHeight: 0, feeType: 0 });
```

`TX_CHANGE_KEYPAIR` 의 경우 lib 는 `signByMasterKey` 를 **자동 채우지 않습니다**
— caller 가 old/new 키 흐름을 명시적으로 주도해야 합니다. 위의
"Normal 키 교체" 표준 패턴 참고.

### "키 입력 필요" 를 UI 로 surface 하기

슬롯이 아직 sign 할 수 없을 때 권장 패턴:

1. `KeySource.signMessage` 콜백이 marker 가 붙은 에러를 throw —
   예: `{ requireMasterKey: true }` / `{ requireNormalKey: true }`.
2. 지갑 UI 가 marker 를 catch 해서 "master keystore 입력" /
   "normal keystore 또는 시크릿키 입력" modal 을 표시.
3. 사용자가 keystore + password 를 제출하면 지갑이 해당 슬롯의
   `KeySource` 를 stub 아닌 진짜 콜백으로 다시 구성해 TX 를 재시도.

라이브러리가 secret 을 보관하지 않으므로 "rebuild" 대상은 account
객체뿐 — wallet client 자체는 네트워크 상태 없는 단순 `{ transport,
account, ...actions }` 라 비용이 없습니다.

### 왜 그냥 평문 키를 넘기지 않는가?

실제 지갑 통합에서 마주치는 세 가지 이유:

- **account 당 secret 저장소가 여럿** — master 는 keystore 에, normal 은
  메모리에, 또는 master 만 하드웨어 지갑에 있을 수도 있음. 콜백 경계가
  각 슬롯의 독립적 진화를 허용함.
- **Non-deterministic signer** — `FALCON+ED25519` 는 같은 메시지에 대해
  서명이 매번 달라짐. lib 는 prepare 단계 서명을 submit 단계에서
  재사용 (아래 `keyBind.sign` 재사용 참고). 캐싱된 secret 대신 콜백을
  거치는 것이 그 ordering 을 명시적으로 만듦.
- **Just-in-time unlock** — keystore 는 WASM 라운드 트립 비용이 있음.
  lazy 로 풀고 클로저 내부에 결과를 캐싱하면 lib 가 두 번 서명해도
  password prompt 는 TX 당 한 번으로 유지.

## 에러

라이브러리가 던지는 모든 에러는 `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'));
}
```

## 프로젝트 구조

- `src/clients`, `src/transports`, `src/accounts`, `src/errors`,
  `src/contracts`, `src/types` — 손으로 작성된 코어.
- `src/actions/wallet/` — 내부 2단계 파이프라인 프리미티브
  (`_prepareTransaction`, `_signLocusTransaction`,
  `_sendPreparedTransaction`).
- `src/utils/` — WASM 바인딩 및 헬퍼 (mnemonic, address, keystore).
- `src/autogen/` — 코드 제너레이터 출력. **수동으로 편집하지 말 것.**
  재생성은 `pnpm run generate-code` (Docker 필요, 자세한 건
  `generate-code.sh` 참고).
