@sidhujag/sysweb3-keyring
v2.1.15
Published
Keyring Manager for UTXO and Web3 Wallets
Readme
Sysweb3-keyring
A stateless, multi-chain keyring manager for Syscoin and Ethereum-based networks.
Overview
The sysweb3-keyring provides a unified interface for managing accounts, transactions, and hardware wallets across Syscoin (UTXO) and Ethereum (EVM) networks. The KeyringManager operates statelessly, relying on external state providers (like Redux) for account and network data.
Key Features
- Stateless Architecture: No internal state storage - integrates with external state management
- Multi-Chain Support: Handles both UTXO (Syscoin) and EVM (Ethereum) networks
- Hardware Wallet Support: Trezor and Ledger integration
- Secure Session Management: Encrypted private key handling with session transfer
- Transaction Management: Full transaction lifecycle support for both network types
Vault encryption compatibility
New vaults require WebCrypto and use PBKDF2-HMAC-SHA-512 (900,000 production iterations) with AES-256-GCM. Missing WebCrypto rejects the operation without writing a weaker fallback vault. Restore WebCrypto availability before retrying an unmarked legacy vault.
Historical fallback vaults used a 20,000-iteration key that also encrypted their
persisted account keys. After authenticating such a CBC vault, the keyring records
the explicit pbkdf2-sha512-20000 compatibility profile and rewraps the vault with
AES-GCM. This preserves existing account access across restart; it is not a
full rekey of those account records to 900,000 iterations. The profile is written
before rewrapping, so interruption of either write remains recoverable. Arbitrary
KDF profiles or parameters are rejected.
Storage clients must return their asynchronous write/delete promises. The core
adapter preserves them so rejected writes do not silently advance vault migration.
Fresh wallet creation additionally requires conditional batch creation. Native
chrome.storage.local and browser.storage.local use a prefix-scoped Web Lock
around the absence check and awaited write, coordinating contexts in the same
extension origin and storage partition. Custom clients must provide an atomic
createItemsIfAbsent(items) backend operation over prefixed, JSON-serialized
values; the built-in memory client provides this operation synchronously.
There is no sequential or unlocked fallback. A backend shared across storage
partitions needs its own atomic implementation or a single owner.
Installation
npm install @sidhujag/sysweb3-keyring [email protected]
# or
yarn add @sidhujag/sysweb3-keyring [email protected]Usage
Basic Setup
import { createKeyringHost, KeyringManager } from '@sidhujag/sysweb3-keyring';
// Create a vault state getter function (e.g., from Redux store)
const vaultStateGetter = () => store.getState().vault;
const walletHost = createKeyringHost(walletStorage);
const walletOptions = {
ledgerHost: authorizedLedgerHostPort,
captureEvmSubmission: captureReviewedSubmission,
};
const keyringManager = await KeyringManager.createInitialized(
walletHost,
seedPhrase,
password,
vaultStateGetter,
walletOptions
);The application supplies fixed wallet storage and a durable submission capability. The capability checks the active approval and persists exact signed bytes before the SDK can broadcast them.
Account Management
// Get active account
const activeAccount = keyringManager.getActiveAccount();
// Create new account
const newAccount = await keyringManager.addNewAccount('My Account');
// Switch active account
await keyringManager.setActiveAccount(accountId, KeyringAccountType.HDAccount);
// Import account from private key
const importedAccount = await keyringManager.importAccount(
privateKey,
'Imported Account'
);Network Management
// Set network (automatically switches between UTXO/EVM signers)
await keyringManager.setSignerNetwork(networkConfig);
// Get current network
const network = keyringManager.getNetwork();Transaction Operations
Syscoin (UTXO) Transactions
const quote = await keyringManager.syscoinTransaction.getEstimateSysTransactionFee(
{ amount: reviewedAmount, receivingAddress: reviewedRecipient },
draftUtxoRead
);
// Review the exact PSBT and capture its approval before signing.
const signedPsbt = await keyringManager.syscoinTransaction.signPSBT(
{ psbt: reviewedPsbt },
approvedUtxoRead
);
const receivingAddress = await keyringManager.updateReceivingAddress(accountUtxoRead);
const changeAddress = await keyringManager.getNewChangeAddress(accountUtxoRead);Each UTXO read owner supplies a bounded endpoint-specific fetch, an abort
signal and a synchronous account, network and session guard. A signing owner
also binds the approved PSBT to that context.
Ethereum (EVM) Transactions
// Submit a fully specified transaction after approval.
const submitted = await keyringManager.ethereumTransaction.sendFormattedTransaction(
reviewedTransaction,
undefined,
{ mode: 'reviewed-native', assertCurrent: assertReviewCurrent }
);
// The host owns the reviewed account/network/session and revokes its authority
// when that context changes or the approval is cancelled.
async function signReviewedMessage(reviewedMessage, accountAddress, authority) {
return keyringManager.ethereumTransaction.signPersonalMessage(
[reviewedMessage, accountAddress],
authority // Required { assertCurrent, signal } from the active host approval.
);
}Hardware Wallet Support
Trezor uses @trezor/connect-webextension 10.0.1. The hardware adapter loads it
only when a supported MV3 extension invokes Trezor; ordinary Node CommonJS/ESM
imports and the software entry do not initialize browser transports. Unsupported
hosts reject hardware operations instead of falling back to software signing.
The extension must configure externally_connectable for
https://suite.trezor.io/* and host access to that origin, as required by the
published Connect 10 webextension package.
Connect selects Suite Desktop or the Suite Web popup. The old Connect 9 iframe,
content script, and connectSrc settings are not part of this integration.
Connect 10 does not expose the old device event emitter. SDK connection status
and cached model information update from request results and disposal; they are
not immediate physical unplug notifications. Disposal immediately rejects pending
SDK operations and fences late replies. Suite Web may leave its popup open; a
late approval there is discarded locally rather than treated as a fresh approval.
When an underlying request is still active, transport cleanup is deferred to avoid
triggering an automatic fallback popup. New initialization fails with
HARDWARE_SIGNER_BUSY until that request settles; local lock/disposal does not
wait for the remote prompt. An upstream transport failure can still trigger remote
fallback UI while that old request is pending; the local SDK will reject its result.
A failed or cancelled operation
never authorizes a cached transaction, and terminal disconnect/unsupported-host
errors are not retried. Device pairing, display verification, signing, and
reconnection still require physical-device qualification in the consuming host.
// Import Trezor account
const trezorAccount = await keyringManager.importTrezorAccount(
'Trezor Account'
);
// Import Ledger account
const ledgerAccount = await keyringManager.importLedgerAccount(
false,
'Ledger Account'
);State Management Integration
The KeyringManager requires a vaultStateGetter function that returns the current vault state:
// Example with Redux
const vaultStateGetter = () => ({
accounts: {
[KeyringAccountType.HDAccount]: {
/* account data */
},
[KeyringAccountType.Imported]: {
/* imported accounts */
},
},
activeAccount: { id: 0, type: KeyringAccountType.HDAccount },
activeNetwork: {
/* network config */
},
// ... other vault state
});
// The keyring manager will call this function to get current state
const keyringManager = new KeyringManager(walletHost, walletOptions);
keyringManager.setVaultStateGetter(vaultStateGetter);Architecture
The stateless design means:
- No Internal State: The KeyringManager doesn't store account or network data
- External State Provider: Relies on your application's state management (Redux, Context, etc.)
- Session Management: Private keys are encrypted and managed securely in memory
- Multi-Instance Support: Multiple KeyringManager instances can operate independently
API Reference
KeyringManager
Main class for keyring operations:
createInitialized(host, seedPhrase, password, vaultStateGetter, options)- Create and initialize keyringaddNewAccount(label?)- Create new HD accountsetActiveAccount(id, type)- Switch active accountimportAccount(privateKey, label?)- Import account from private keysetSignerNetwork(network)- Set active networkunlock(password)- Unlock keyringlockWallet()- Lock keyring
Transaction Managers
syscoinTransaction- UTXO transaction operationsethereumTransaction- EVM transaction operations
The formatted-send options require an explicit mode and an approval guard.
The guard must return undefined synchronously or throw to deny the operation.
The SDK combines it with account, network and session checks at signing and
broadcast. Once broadcast is acknowledged, the transaction remains recorded
even if the approval has since expired.
Hardware Wallet Support
importTrezorAccount(label?)- Import Trezor accountimportLedgerAccount(isConnected, label?)- Import Ledger account
Encrypted private records
An unlocked keyring can seal small application records without returning its mnemonic or a derived encryption key:
const domain = 'org.example.wallet.private-records.v1';
const recordId = crypto.randomUUID();
const profileId = await keyringManager.getPrivateRecordProfileId();
const envelope = await keyringManager.encryptPrivateRecord({
domain,
recordId,
plaintext: new TextEncoder().encode('application-owned private data'),
});
// Store or back up only envelope; storage is owned by the calling application.
const plaintext = await keyringManager.decryptPrivateRecord({
domain,
recordId,
envelope,
});
// Consume the bytes, then clear them when no longer needed.
plaintext.fill(0);IEncryptedPrivateRecord contains only version: 1, domain, recordId,
profileId, a base64 nonce and base64 ciphertext including its authentication
tag. All application metadata should be encrypted inside plaintext. Domains
and record IDs are 1–128 ASCII characters: an alphanumeric first character,
followed by alphanumerics or ., _, :, /, -. Records may contain 0–65536
bytes. MAX_PRIVATE_RECORD_BYTES and MAX_PRIVATE_RECORD_LABEL_BYTES export
these bounds.
The API uses native WebCrypto HKDF-SHA256 and AES-256-GCM, with a fresh random 96-bit nonce and a 128-bit tag. The authenticated tuple binds the format version, domain, record ID and profile ID. Profile and encryption derivations have separate HKDF labels. The root material is the NFKD-normalized mnemonic or extended private key already held by the unlocked session; derivation does not depend on the selected account, network or password. Rewrapping the same root with a new password therefore preserves access to these records.
Each operation rejects if its source session is locked, replaced or transferred before completion, even if the same buffers are later transferred back. These methods do not read or write wallet storage. Callers remain responsible for record IDs, persistence, backup, rollback detection and clearing returned bytes. The seed alone cannot restore randomly generated records: their encrypted backups are also required. Temporary byte buffers are cleared; JavaScript strings and native cryptographic allocations remain subject to runtime garbage collection rather than guaranteed memory erasure.
Security
initializeSession,initializeWalletSecurely, and the initialization factories create a new wallet only when both vault records are absent. Useunlock()to restore an existing wallet. Repeating initialization on a matching live session verifies the stored wallet without rewriting it.- Fresh vault salt and ciphertext are created together only if both are absent, through the core conditional-creation operation. Native extension contexts sharing an origin/storage partition are coordinated by Web Locks; custom backends must provide atomic
createItemsIfAbsent. Unsupported adapters are rejected before writing. Existing or incomplete records are preserved. - UTXO signing authenticates the selected account's paths, public keys and spent scripts. A joint PSBT may include unfinished inputs for another signer: their HD/path hints are removed before the selected private signer runs, then their public metadata is restored to the returned partial PSBT. Another account in the same wallet remains unsigned. At least one unfinished input must authenticate to the selected account; a wholly finalized PSBT retains its existing handling. Standard P2WSH, wrapped P2WSH and P2SH multisig preserve external cosigner signatures and continuation. Hardware paths and single-address imports can reject unsupported joint inputs rather than widening signing authority.
- Hardware signing preserves already-finalized external inputs without requesting their private derivations. Trezor receives them as
EXTERNALinputs; Ledger retains their final scripts in PSBTv2 and skips signing/finalizing them again. Unfinished foreign hardware inputs remain unsupported. - Persisted wallet and account secrets are encrypted; plaintext signing material is used in memory
- Session data is cleared when the keyring is locked
- Hardware wallet integration follows device security models
- Secure memory management with explicit cleanup
License
MIT License
Ledger platform host
Ledger device access uses the Device Management Kit. The regular and software
entries do not load a Ledger transport or device SDK. Browser integrations must
create the heavy host from @sidhujag/sysweb3-keyring/esm/ledger-host in a visible
extension page and inject its semantic LedgerHost port into the wallet authority:
// Visible device page only:
import { createLedgerDmkHost } from '@sidhujag/sysweb3-keyring/esm/ledger-host';
const host = createLedgerDmkHost({
confirmBlindSigning: async (intent, signal) => {
// Show the exact immutable intent and an explicit blind-signing warning.
// Return true only after a fresh user decision; cancellation must return false.
return showTrustedWalletConfirmation(intent, signal);
},
});
// Wallet authority: use a secured semantic port to that page, not a raw APDU port.
const wallet = new KeyringManager(walletHost, {
ledgerHost: authorizedLedgerHostPort,
captureEvmSubmission: captureReviewedSubmission,
});Use only the ESM host entry with a browser bundler and its required platform
polyfills. cjs/ledger-host is not a supported entry. A service worker cannot own
the WebHID device picker. The platform must bind the host to its own trusted page,
revoke pending operations on lock/close, and never expose this port to dapps.
A missing host fails with HARDWARE_SIGNER_UNAVAILABLE; there is no legacy
LedgerJS fallback. Pass either a shared hardware manager or a Ledger host to
KeyringManager, not both.
A platform that owns its transport can lazily import
createSelectedLedgerDmkHost from
@sidhujag/sysweb3-keyring/esm/ledger/dmk-selected-host.js with an ESM-aware
bundler. Supply the platform's DMK and the same trusted approval callback.
This entry leaves WebHID to the browser host and cannot connect until a
single-use beginDeviceSelection capability accepts a device selected by the
user. The platform still owns transport permissions and cancellation.
The default host performs no remote metadata lookup, transaction checks, or
signing telemetry. The current Ethereum signer library can fall back to blind
signing, so transaction and typed-data signing require per-request approval
through the trusted confirmBlindSigning callback. Omitting it fails closed.
Personal-message signing and address retrieval remain available offline.
Signatures are checked against the expected account; UTXO signatures must also
match the approved PSBT and its requested sighash coverage. The custom UTXO host
supports the existing Syscoin and Bitcoin app protocols. Physical-device and
firmware qualification remain separate from source and mocked-device tests.
Shared standard EVM fees
cjs/evm-fees is a dependency-free read-only entry. Both the existing
EthereumTransactions.getFeeDataWithDynamicMaxPriorityFeePerGas method and
restricted hosts use the same calculation:
import { getEvmFeeData } from '@sidhujag/sysweb3-keyring/cjs/evm-fees';
const fees = await getEvmFeeData(readOnlyProvider, {
signal,
assertCurrent: () => assertSameAccountAndNetwork(),
});The reader supplies only getGasPrice(), getBlock('latest'), and
send('eth_maxPriorityFeePerGas' | 'eth_feeHistory', params). It must validate
its RPC responses and bind its endpoint/chain; this helper does not instantiate
a provider, estimate gas, select an account, sign or broadcast. The host must
retain request deadlines and perform its ordinary account/network/session
checks before signing.
Results are frozen bigint fields: gasPrice, maxFeePerGas, and
maxPriorityFeePerGas, with baseFeePerGas only when the observed header has
that field. An absent base fee produces the existing legacy zero sentinels for
the two EIP-1559 fields. A zero base fee or zero tip remains valid EIP-1559 data.
An explicitly unsupported priority method may use actual recent fee-history
rewards; other failures never turn into fallback fees. Quantities and calculated
fees are bounded to uint256, history to the requested five blocks, and no
signing authority or dependencies are loaded by this entry.
The optional minPriorityFee and whole-percent feeMultiplier preserve the
existing configured recommendation policy (default multiplier 250). Options
are snapshotted before asynchronous reads. The synchronous assertCurrent
callback must return undefined; an async callback is rejected. Cancellation
rejects pending discovery without permitting later results to produce a quote.
bufferedEvmGasLimit(estimate: bigint, minimum?: bigint) shares the existing
successful gas estimate policy used by EthereumTransactions.getTxGasLimit:
floor the estimate times120/100 and apply the configured minimum if larger.
It includes ordinary native transfers. The helper performs no estimation,
fallback request, transaction cap selection or broadcasting; callers enforce
the selected network's limits after applying it. The SDK's existing estimation
failure/fallback behavior is unchanged.
