@asentum/metamask-snap
v0.1.0
Published
MetaMask Snap for AsentumChain. Manages Dilithium3 keys and signs AsentumChain transactions inside MetaMask.
Downloads
159
Maintainers
Readme
@asentum/metamask-snap
Post-quantum signing for AsentumChain, inside MetaMask.
This is a MetaMask Snap that gives MetaMask users a native AsentumChain account without forking MetaMask and without compromising the chain's post-quantum security property. The Dilithium3 private key lives only inside the Snap sandbox; the user's existing secp256k1 Ethereum accounts are untouched and sit alongside the AsentumChain account in the same MetaMask UI.
What it does
- Derives a Dilithium3 keypair from the user's MetaMask seed phrase (via
snap_getEntropy). Backing up / restoring the MetaMask recovery phrase also backs up / restores the AsentumChain account: no extra seed to remember. - Builds AsentumChain transactions (transfer, contract call, contract deploy) in the chain's native SSZ + Dilithium3 format.
- Signs those transactions with the derived keypair after showing the user a confirmation dialog that spells out to-address, value, method name, args, gas: everything they're authorizing.
- Returns the signed bytes to the calling dApp, which broadcasts them to an AsentumChain RPC endpoint. The Snap itself never touches the network.
What it does NOT do
- It does not speak Ethereum. The transactions it signs are pure AsentumChain: Dilithium3 signatures, BLAKE3 hashes, SSZ serialization. An attempt to submit a Snap-signed transaction to an Ethereum node would be meaningless.
- It does not hold ETH or any Ethereum-ecosystem balance. The user's AsentumChain address is derived from Dilithium3, which is completely separate from their secp256k1 Ethereum address. They are distinct accounts with distinct balances.
- It does not broadcast transactions. It signs and hands the bytes back. This keeps the Snap's permission surface small (
snap_getEntropy+snap_dialogonly: noendowment:network-access) and leaves the RPC choice to the dApp.
Snap RPC surface
Invoke via the standard MetaMask Snap interface:
const result = await window.ethereum.request({
method: 'wallet_invokeSnap',
params: {
snapId: 'npm:@asentum/metamask-snap',
request: { method: 'asentum_getAddress' },
},
});Read-only methods (no confirmation dialog)
| Method | Returns |
| ----------------------- | ------------------------------------------------------------- |
| asentum_getAddress | string: 0x-prefixed 20-byte address |
| asentum_getPublicKey | string: 0x-prefixed Dilithium3 public key (1952 bytes) |
| asentum_buildTransfer | { body }: an unsigned tx body preview (for UI rendering) |
Signing methods (each shows a confirmation dialog)
| Method | Params | Returns |
| ---------------------- | ------------------------------------------------------------------------------------------ | ------------------- |
| asentum_signTransfer | { chainId, nonce, to, value, gasLimit?, maxFeePerGas?, maxPriorityFeePerGas? } | { rawTx: string } |
| asentum_signCall | { chainId, nonce, to, value, method, args, gasLimit?, maxFeePerGas?, maxPriorityFeePerGas? } | { rawTx: string } |
| asentum_signDeploy | { chainId, nonce, source, value?, gasLimit?, maxFeePerGas?, maxPriorityFeePerGas? } | { rawTx: string } |
All numeric params are strings (decimal or 0x-hex) because JSON-RPC can't represent bigints natively. to is a 0x-prefixed 20-byte hex address. rawTx is 0x-prefixed SSZ-encoded SignedTransaction bytes ready for POST /tx on any AsentumChain node.
Example dApp snippet
// 1. Request the Snap be installed (first time only).
await window.ethereum.request({
method: 'wallet_requestSnaps',
params: { 'npm:@asentum/metamask-snap': {} },
});
// 2. Get the user's AsentumChain address.
const address = await window.ethereum.request({
method: 'wallet_invokeSnap',
params: {
snapId: 'npm:@asentum/metamask-snap',
request: { method: 'asentum_getAddress' },
},
});
console.log('AsentumChain address:', address);
// 3. Look up the current nonce via the Ethereum JSON-RPC compat layer
// (Phase 4.1) that any AsentumChain node exposes at POST /.
const res = await fetch('http://127.0.0.1:8545/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'eth_getTransactionCount',
params: [address, 'latest'],
}),
});
const { result: nonceHex } = await res.json();
// 4. Build + sign a transfer via the Snap. MetaMask will show a
// confirmation dialog with the tx details before signing.
const { rawTx } = await window.ethereum.request({
method: 'wallet_invokeSnap',
params: {
snapId: 'npm:@asentum/metamask-snap',
request: {
method: 'asentum_signTransfer',
params: {
chainId: '1337',
nonce: nonceHex, // hex string from eth_getTransactionCount
to: '0x' + '42'.repeat(20),
value: '1000000000000000000', // 1 ASE in wei
},
},
},
});
// 5. Broadcast the signed tx to the AsentumChain node.
await fetch('http://127.0.0.1:8545/tx', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rawTx }),
});That's the full flow: install Snap → get address → lookup nonce via eth-compat → sign via Snap → broadcast via native RPC.
Development
The Snap source lives in src/. The testable crypto and tx-building logic is in src/wallet-core.ts; the MetaMask-specific glue (confirmation dialogs, entropy loading, the onRpcRequest dispatcher) is in src/index.ts. This split means almost all of the important behavior can be unit-tested without running MetaMask: test/wallet-core.test.ts covers the primitives, and test/snap-handler.test.ts mocks the snap global to exercise the dispatcher end-to-end.
# Run the full test suite
pnpm --filter @asentum/metamask-snap test
# Typecheck
pnpm --filter @asentum/metamask-snap typecheck
# Build (produces dist/index.js for Snap packaging)
pnpm --filter @asentum/metamask-snap buildPublishing to a real MetaMask instance
For local testing against a real MetaMask Flask installation, you'll need @metamask/snaps-cli:
# Install once (not in the workspace: use your global environment)
npm install -g @metamask/snaps-cli
# Bundle the Snap into a single file MetaMask can load
mm-snap build
# Serve locally for MetaMask Flask to install from
mm-snap serveThen in MetaMask Flask, use wallet_requestSnaps with snapId: 'local:http://localhost:8080' to install from the local build.
When publishing to NPM for production use, swap the local type declaration (./snap-api.js) for the real MetaMask SDK:
// Before (development):
import type { OnRpcRequestHandler, OnRpcRequestArgs } from './snap-api.js';
// After (publishing):
import type { OnRpcRequestHandler, OnRpcRequestArgs } from '@metamask/snaps-sdk';And install @metamask/snaps-sdk as a dependency before publishing.
Security notes
- The Dilithium3 private key never leaves the Snap sandbox. It's derived on-the-fly from
snap_getEntropyevery time a signing method runs, used once, and the in-memory copy is discarded. - Every signing method shows a confirmation dialog. The user sees the full tx shape (to, value, method, args, chain, nonce, gas) before approving. Signing cannot happen silently.
- The origin of the calling dApp is included in the confirmation dialog. Users can verify they're signing for the site they expect.
- The Snap has no network access permission. It can't phone home, can't exfiltrate the key, can't observe network traffic. All it can do is sign what the dApp gives it.
