Skip to content
This repository was archived by the owner on Oct 12, 2025. It is now read-only.

Commit d78d5c4

Browse files
committed
Snap testing
1 parent 2c3c650 commit d78d5c4

File tree

9 files changed

+306
-0
lines changed

9 files changed

+306
-0
lines changed

packages/snap/README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# @ecency/snap
2+
3+
MetaMask Snap providing multi-chain wallet capabilities powered by
4+
[`@ecency/wallets`](../wallets).
5+
6+
## Installation
7+
8+
```bash
9+
yarn add @ecency/snap
10+
```
11+
12+
Add the snap to MetaMask using a local manifest or bundle produced by
13+
`yarn workspace @ecency/snap build`.
14+
15+
## Usage
16+
17+
The snap exposes several RPC methods:
18+
19+
- `initialize` – store a BIP39 mnemonic inside the snap.
20+
- `unlock` – validate and unlock previously stored mnemonic.
21+
- `getAddress` – return a public address for a given chain (`HIVE`, `BTC`, `ETH`, etc.).
22+
- `signHiveTx` – sign a Hive transaction with the active key.
23+
- `signExternalTx` – sign transactions for external chains via `signExternalTx` from
24+
`@ecency/wallets`.
25+
- `getBalance` – query balances using `useGetExternalWalletBalanceQuery` (todo: integrate).
26+
27+
Example from a dApp:
28+
29+
```ts
30+
const result = await window.ethereum.request({
31+
method: 'wallet_invokeSnap',
32+
params: {
33+
snapId: 'local:@ecency/snap',
34+
request: {
35+
method: 'getAddress',
36+
params: { chain: 'HIVE' }
37+
}
38+
}
39+
});
40+
```
41+
42+
## Security considerations
43+
44+
The mnemonic phrase is stored inside the snap's managed state. Snaps run in an
45+
isolated environment, but any state stored by the snap is persisted on the
46+
user's machine. Avoid exposing the mnemonic and consider encrypting state in
47+
production deployments.

packages/snap/package.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "@ecency/snap",
3+
"private": false,
4+
"version": "0.1.0",
5+
"type": "module",
6+
"license": "MIT",
7+
"main": "./dist/index.js",
8+
"typings": "./dist/index.d.ts",
9+
"files": [
10+
"dist",
11+
"README.md"
12+
],
13+
"scripts": {
14+
"dev": "vite",
15+
"build": "tsc && vite build",
16+
"test": "NODE_OPTIONS=--import=./test/mock-wallets.js node --test"
17+
},
18+
"dependencies": {
19+
"@ecency/wallets": "workspace:*"
20+
},
21+
"devDependencies": {
22+
"@types/node": "^22.13.8",
23+
"typescript": "^5.8.2",
24+
"vite": "^6.2.0",
25+
"vite-plugin-dts": "4.5.3",
26+
"vite-plugin-node-polyfills": "^0.23.0"
27+
}
28+
}

packages/snap/src/index.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import {
2+
mnemonicToSeedBip39,
3+
deriveHiveKeys,
4+
signTx,
5+
signExternalTx,
6+
getWallet,
7+
getKeysFromSeed,
8+
} from "@ecency/wallets";
9+
10+
export type RpcRequest = { method: string; params?: any };
11+
export interface RpcArgs { origin: string; request: RpcRequest }
12+
export type OnRpcRequestHandler = (args: RpcArgs) => Promise<any>;
13+
14+
interface SnapState { mnemonic?: string }
15+
16+
async function getState(): Promise<SnapState> {
17+
return (
18+
((await (globalThis as any).snap.request({
19+
method: "snap_manageState",
20+
params: { operation: "get" },
21+
})) as SnapState) || {}
22+
);
23+
}
24+
25+
async function updateState(state: SnapState): Promise<void> {
26+
await (globalThis as any).snap.request({
27+
method: "snap_manageState",
28+
params: { operation: "update", newState: state },
29+
});
30+
}
31+
32+
async function getAddressFromMnemonic(mnemonic: string, chain: string) {
33+
if (chain === "HIVE") {
34+
const keys = deriveHiveKeys(mnemonic);
35+
return keys.activePubkey;
36+
}
37+
const wallet = getWallet(chain as any);
38+
if (!wallet) throw new Error("Unsupported chain");
39+
const [, address] = await getKeysFromSeed(mnemonic, wallet, chain as any);
40+
return address;
41+
}
42+
43+
async function signHiveTransaction(mnemonic: string, tx: any) {
44+
const keys = deriveHiveKeys(mnemonic);
45+
return signTx(tx, keys.active);
46+
}
47+
48+
export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => {
49+
const state = await getState();
50+
51+
switch (request.method) {
52+
case "initialize": {
53+
const mnemonic = request.params?.mnemonic;
54+
if (typeof mnemonic !== "string") throw new Error("mnemonic required");
55+
mnemonicToSeedBip39(mnemonic); // validate
56+
await updateState({ mnemonic });
57+
return true;
58+
}
59+
case "unlock": {
60+
const mnemonic = request.params?.mnemonic;
61+
if (typeof mnemonic !== "string") throw new Error("mnemonic required");
62+
mnemonicToSeedBip39(mnemonic); // validate
63+
if (state.mnemonic && mnemonic !== state.mnemonic) {
64+
throw new Error("invalid mnemonic");
65+
}
66+
await updateState({ mnemonic });
67+
return true;
68+
}
69+
case "getAddress": {
70+
if (!state.mnemonic) throw new Error("locked");
71+
const chain = request.params?.chain;
72+
const address = await getAddressFromMnemonic(state.mnemonic, chain);
73+
return { address };
74+
}
75+
case "signHiveTx": {
76+
if (!state.mnemonic) throw new Error("locked");
77+
const tx = request.params?.tx;
78+
return signHiveTransaction(state.mnemonic, tx);
79+
}
80+
case "signExternalTx": {
81+
if (!state.mnemonic) throw new Error("locked");
82+
const { currency, params } = request.params ?? {};
83+
return signExternalTx(currency, params);
84+
}
85+
case "getBalance": {
86+
// Placeholder implementation. In a full snap environment the
87+
// useGetExternalWalletBalanceQuery hook from @ecency/wallets
88+
// should be used via a QueryClient.
89+
return 0;
90+
}
91+
default:
92+
throw new Error("Method not found.");
93+
}
94+
};

packages/snap/src/shims.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
declare module '@ecency/wallets';
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { onRpcRequest } from '../dist/ecency-snap.es.js';
4+
5+
const state = {};
6+
7+
globalThis.snap = {
8+
request: async ({ method, params }) => {
9+
if (method !== 'snap_manageState') throw new Error('unsupported');
10+
if (params.operation === 'get') return state;
11+
if (params.operation === 'update') { Object.assign(state, params.newState); return null; }
12+
throw new Error('bad operation');
13+
},
14+
};
15+
16+
const mnemonic = 'test test test test test test test test test test test junk';
17+
18+
test('dapp flow', async () => {
19+
await onRpcRequest({ origin: 'dapp', request: { method: 'initialize', params: { mnemonic } } });
20+
const addr = await onRpcRequest({ origin: 'dapp', request: { method: 'getAddress', params: { chain: 'HIVE' } } });
21+
assert.ok(addr.address);
22+
23+
const tx = { ref_block_num: 0, ref_block_prefix: 0, expiration: '2020-01-01T00:00:00', operations: [], extensions: [] };
24+
const signed = await onRpcRequest({ origin: 'dapp', request: { method: 'signHiveTx', params: { tx } } });
25+
assert.ok(signed.signatures.length > 0);
26+
});
27+

packages/snap/test/mock-wallets.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
4+
const pkgDir = path.resolve('node_modules/@ecency/wallets');
5+
await fs.promises.mkdir(pkgDir, { recursive: true });
6+
await fs.promises.writeFile(path.join(pkgDir, 'package.json'), '{"name":"@ecency/wallets","type":"module"}');
7+
await fs.promises.writeFile(path.join(pkgDir, 'index.js'), `
8+
export function mnemonicToSeedBip39(m){return m;}
9+
export function deriveHiveKeys(){return {active:'priv',activePubkey:'pub'};}
10+
export function signTx(tx){return {...tx,signatures:['sig']};}
11+
export function signExternalTx(){return 'signed';}
12+
export function getWallet(){return {getNewAddress:async()=>({address:'addr',publicKey:'pub'}),getDerivedPrivateKey:async()=> 'priv',signTransaction:async()=> 'signed'};}
13+
export async function getKeysFromSeed(){return ['priv','addr'];}
14+
`);
15+

packages/snap/test/rpc.test.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { onRpcRequest } from '../dist/ecency-snap.es.js';
4+
5+
const state = {};
6+
7+
// simple snap state stub
8+
globalThis.snap = {
9+
request: async ({ method, params }) => {
10+
if (method !== 'snap_manageState') throw new Error('unsupported');
11+
if (params.operation === 'get') return state;
12+
if (params.operation === 'update') {
13+
Object.assign(state, params.newState);
14+
return null;
15+
}
16+
throw new Error('bad operation');
17+
},
18+
};
19+
20+
const mnemonic = 'test test test test test test test test test test test junk';
21+
22+
test('initialize and unlock', async () => {
23+
const init = await onRpcRequest({ origin: 'test', request: { method: 'initialize', params: { mnemonic } } });
24+
assert.equal(init, true);
25+
26+
const unlock = await onRpcRequest({ origin: 'test', request: { method: 'unlock', params: { mnemonic } } });
27+
assert.equal(unlock, true);
28+
});
29+
30+
test('get hive address', async () => {
31+
await onRpcRequest({ origin: 'test', request: { method: 'initialize', params: { mnemonic } } });
32+
const res = await onRpcRequest({ origin: 'test', request: { method: 'getAddress', params: { chain: 'HIVE' } } });
33+
assert.ok(res.address);
34+
});
35+
36+
test('sign hive tx', async () => {
37+
await onRpcRequest({ origin: 'test', request: { method: 'initialize', params: { mnemonic } } });
38+
const tx = { ref_block_num: 0, ref_block_prefix: 0, expiration: '2020-01-01T00:00:00', operations: [], extensions: [] };
39+
const signed = await onRpcRequest({ origin: 'test', request: { method: 'signHiveTx', params: { tx } } });
40+
assert.ok(Array.isArray(signed.signatures));
41+
});
42+
43+
test('balance query placeholder', async () => {
44+
await onRpcRequest({ origin: 'test', request: { method: 'initialize', params: { mnemonic } } });
45+
const bal = await onRpcRequest({ origin: 'test', request: { method: 'getBalance', params: { currency: 'BTC', address: 'xyz' } } });
46+
assert.equal(bal, 0);
47+
});
48+

packages/snap/tsconfig.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"compilerOptions": {
4+
"outDir": "dist",
5+
"rootDir": "src",
6+
"composite": true
7+
},
8+
"include": ["src"]
9+
}

packages/snap/vite.config.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import path from "path";
2+
import { defineConfig } from "vite";
3+
import dtsPlugin from "vite-plugin-dts";
4+
import { nodePolyfills } from "vite-plugin-node-polyfills";
5+
6+
export default defineConfig({
7+
build: {
8+
lib: {
9+
entry: path.resolve(__dirname, "src/index.ts"),
10+
name: "Ecency Snap",
11+
formats: ["es"],
12+
fileName: (format) => `ecency-snap.${format}.js`,
13+
},
14+
rollupOptions: {
15+
external: [
16+
"crypto",
17+
"@ecency/wallets",
18+
"@okxweb3/coin-aptos",
19+
"@okxweb3/coin-base",
20+
"@okxweb3/coin-bitcoin",
21+
"@okxweb3/coin-cosmos",
22+
"@okxweb3/coin-ethereum",
23+
"@okxweb3/coin-solana",
24+
"@okxweb3/coin-ton",
25+
"@okxweb3/coin-tron",
26+
"@okxweb3/crypto-lib",
27+
"bip39"
28+
],
29+
},
30+
},
31+
resolve: {
32+
alias: {
33+
"@": path.resolve(__dirname, "./src"),
34+
},
35+
},
36+
plugins: [dtsPlugin(), nodePolyfills()],
37+
});

0 commit comments

Comments
 (0)