@agentlayer.tech/wallet 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.openclaw/AGENTS.md +98 -0
- package/.openclaw/extensions/agent-wallet/README.md +127 -0
- package/.openclaw/extensions/agent-wallet/index.ts +1520 -0
- package/.openclaw/extensions/agent-wallet/openclaw.plugin.json +184 -0
- package/.openclaw/extensions/agent-wallet/package.json +11 -0
- package/.openclaw/extensions/agent-wallet/skills/wallet-operator/SKILL.md +20 -0
- package/CHANGELOG.md +42 -0
- package/LICENSE +104 -0
- package/README.md +332 -0
- package/RELEASING.md +204 -0
- package/agent-wallet/.env.example +62 -0
- package/agent-wallet/AGENTS.md +129 -0
- package/agent-wallet/README.md +527 -0
- package/agent-wallet/agent_wallet/__init__.py +11 -0
- package/agent-wallet/agent_wallet/approval.py +161 -0
- package/agent-wallet/agent_wallet/bootstrap.py +178 -0
- package/agent-wallet/agent_wallet/btc_user_wallets.py +217 -0
- package/agent-wallet/agent_wallet/config.py +382 -0
- package/agent-wallet/agent_wallet/encrypted_storage.py +161 -0
- package/agent-wallet/agent_wallet/evm_user_wallets.py +370 -0
- package/agent-wallet/agent_wallet/exceptions.py +9 -0
- package/agent-wallet/agent_wallet/file_ops.py +34 -0
- package/agent-wallet/agent_wallet/http_client.py +25 -0
- package/agent-wallet/agent_wallet/models.py +66 -0
- package/agent-wallet/agent_wallet/nonce_registry.py +59 -0
- package/agent-wallet/agent_wallet/openclaw_adapter.py +5128 -0
- package/agent-wallet/agent_wallet/openclaw_cli.py +626 -0
- package/agent-wallet/agent_wallet/openclaw_runtime.py +272 -0
- package/agent-wallet/agent_wallet/plugin_bundle.py +42 -0
- package/agent-wallet/agent_wallet/providers/__init__.py +1 -0
- package/agent-wallet/agent_wallet/providers/bags.py +259 -0
- package/agent-wallet/agent_wallet/providers/evm_portfolio.py +470 -0
- package/agent-wallet/agent_wallet/providers/jupiter.py +567 -0
- package/agent-wallet/agent_wallet/providers/kamino.py +215 -0
- package/agent-wallet/agent_wallet/providers/lifi.py +277 -0
- package/agent-wallet/agent_wallet/providers/solana_rpc.py +470 -0
- package/agent-wallet/agent_wallet/providers/wdk_btc_local.py +114 -0
- package/agent-wallet/agent_wallet/providers/wdk_evm_local.py +205 -0
- package/agent-wallet/agent_wallet/sealed_keys.py +61 -0
- package/agent-wallet/agent_wallet/solana_stake.py +103 -0
- package/agent-wallet/agent_wallet/solana_tx.py +93 -0
- package/agent-wallet/agent_wallet/spending_limits.py +101 -0
- package/agent-wallet/agent_wallet/transaction_policy.py +518 -0
- package/agent-wallet/agent_wallet/user_wallets.py +355 -0
- package/agent-wallet/agent_wallet/validation.py +31 -0
- package/agent-wallet/agent_wallet/wallet_layer/__init__.py +1 -0
- package/agent-wallet/agent_wallet/wallet_layer/base.py +808 -0
- package/agent-wallet/agent_wallet/wallet_layer/base58.py +44 -0
- package/agent-wallet/agent_wallet/wallet_layer/factory.py +102 -0
- package/agent-wallet/agent_wallet/wallet_layer/solana.py +4252 -0
- package/agent-wallet/agent_wallet/wallet_layer/wdk_btc.py +272 -0
- package/agent-wallet/agent_wallet/wallet_layer/wdk_evm.py +1628 -0
- package/agent-wallet/examples/bootstrap_wallet.py +21 -0
- package/agent-wallet/examples/openclaw_runtime_onboarding.py +28 -0
- package/agent-wallet/examples/openclaw_user_wallet_example.py +31 -0
- package/agent-wallet/examples/openclaw_wallet_adapter_example.py +33 -0
- package/agent-wallet/openclaw.plugin.json +138 -0
- package/agent-wallet/pyproject.toml +31 -0
- package/agent-wallet/scripts/bootstrap_openclaw_btc.py +278 -0
- package/agent-wallet/scripts/build_release_bundle.py +188 -0
- package/agent-wallet/scripts/finalize_openclaw_local_wallet_config.py +121 -0
- package/agent-wallet/scripts/install_agent_wallet.py +505 -0
- package/agent-wallet/scripts/install_openclaw_local_config.py +226 -0
- package/agent-wallet/scripts/install_openclaw_sealed_keys.py +105 -0
- package/agent-wallet/scripts/manage_openclaw_btc_wallet.py +244 -0
- package/agent-wallet/scripts/reveal_btc_seed.sh +130 -0
- package/agent-wallet/scripts/security_utils.py +37 -0
- package/agent-wallet/scripts/setup_btc_wallet.sh +146 -0
- package/agent-wallet/scripts/switch_openclaw_wallet_network.py +106 -0
- package/agent-wallet/skills/wallet-operator/SKILL.md +128 -0
- package/bin/openclaw-agent-wallet.mjs +487 -0
- package/install-from-github.sh +134 -0
- package/package.json +61 -0
- package/setup.sh +40 -0
- package/wdk-btc-wallet/README.md +325 -0
- package/wdk-btc-wallet/bootstrap.sh +22 -0
- package/wdk-btc-wallet/package-lock.json +1839 -0
- package/wdk-btc-wallet/package.json +18 -0
- package/wdk-btc-wallet/run-local.sh +21 -0
- package/wdk-btc-wallet/src/config.js +160 -0
- package/wdk-btc-wallet/src/json.js +35 -0
- package/wdk-btc-wallet/src/local_vault.js +432 -0
- package/wdk-btc-wallet/src/network_state.js +84 -0
- package/wdk-btc-wallet/src/server.js +257 -0
- package/wdk-btc-wallet/src/wdk_btc_wallet.js +332 -0
- package/wdk-evm-wallet/README.md +183 -0
- package/wdk-evm-wallet/bootstrap.sh +8 -0
- package/wdk-evm-wallet/package-lock.json +2340 -0
- package/wdk-evm-wallet/package.json +23 -0
- package/wdk-evm-wallet/run-local.sh +12 -0
- package/wdk-evm-wallet/src/config.js +274 -0
- package/wdk-evm-wallet/src/json.js +35 -0
- package/wdk-evm-wallet/src/local_vault.js +430 -0
- package/wdk-evm-wallet/src/network_state.js +92 -0
- package/wdk-evm-wallet/src/server.js +575 -0
- package/wdk-evm-wallet/src/wdk_evm_wallet.js +4981 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wdk-btc-wallet",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Separate BTC-only wallet service built on Tether WDK.",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"bootstrap": "sh ./bootstrap.sh",
|
|
9
|
+
"start:local": "sh ./run-local.sh",
|
|
10
|
+
"start": "node src/server.js",
|
|
11
|
+
"check": "node --check src/server.js && node --check src/wdk_btc_wallet.js && node --check src/config.js && node --check src/json.js"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@tetherto/wdk": "^1.0.0-beta.6",
|
|
15
|
+
"@tetherto/wdk-wallet-btc": "^1.0.0-beta.5",
|
|
16
|
+
"dotenv": "^16.4.5"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
set -eu
|
|
3
|
+
|
|
4
|
+
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
|
|
5
|
+
cd "$SCRIPT_DIR"
|
|
6
|
+
|
|
7
|
+
if [ ! -f .env ]; then
|
|
8
|
+
cp .env.example .env
|
|
9
|
+
echo "Created .env from .env.example"
|
|
10
|
+
fi
|
|
11
|
+
|
|
12
|
+
if [ -z "${NPM_CONFIG_CACHE:-}" ]; then
|
|
13
|
+
export NPM_CONFIG_CACHE=/tmp/npm-cache
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
if [ ! -d node_modules ]; then
|
|
17
|
+
echo "Installing dependencies..."
|
|
18
|
+
npm install
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
exec npm start
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const DEFAULTS = {
|
|
7
|
+
host: "127.0.0.1",
|
|
8
|
+
port: 8080,
|
|
9
|
+
network: "bitcoin",
|
|
10
|
+
bip: 84,
|
|
11
|
+
unlockTimeoutSeconds: 0,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const DEFAULT_NETWORK_PROFILES = {
|
|
15
|
+
bitcoin: {
|
|
16
|
+
electrumProtocol: "tcp",
|
|
17
|
+
electrumHost: "electrum.blockstream.info",
|
|
18
|
+
electrumPort: 50001,
|
|
19
|
+
},
|
|
20
|
+
testnet: {
|
|
21
|
+
electrumProtocol: "tcp",
|
|
22
|
+
electrumHost: "blockstream.info",
|
|
23
|
+
electrumPort: 143,
|
|
24
|
+
},
|
|
25
|
+
regtest: {
|
|
26
|
+
electrumProtocol: "tcp",
|
|
27
|
+
electrumHost: "127.0.0.1",
|
|
28
|
+
electrumPort: 60401,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function parseInteger(value, fallback, fieldName) {
|
|
33
|
+
const normalized = String(value ?? "").trim();
|
|
34
|
+
if (!normalized) {
|
|
35
|
+
return fallback;
|
|
36
|
+
}
|
|
37
|
+
const parsed = Number.parseInt(normalized, 10);
|
|
38
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
39
|
+
throw new Error(`${fieldName} must be a positive integer.`);
|
|
40
|
+
}
|
|
41
|
+
return parsed;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseNonNegativeInteger(value, fallback, fieldName) {
|
|
45
|
+
const normalized = String(value ?? "").trim();
|
|
46
|
+
if (!normalized) {
|
|
47
|
+
return fallback;
|
|
48
|
+
}
|
|
49
|
+
const parsed = Number.parseInt(normalized, 10);
|
|
50
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
51
|
+
throw new Error(`${fieldName} must be a non-negative integer.`);
|
|
52
|
+
}
|
|
53
|
+
return parsed;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveOpenClawHome(env) {
|
|
57
|
+
const configured = String(env.OPENCLAW_HOME ?? "").trim();
|
|
58
|
+
if (!configured) {
|
|
59
|
+
return path.join(os.homedir(), ".openclaw");
|
|
60
|
+
}
|
|
61
|
+
if (configured === "~") {
|
|
62
|
+
return os.homedir();
|
|
63
|
+
}
|
|
64
|
+
if (configured.startsWith("~/")) {
|
|
65
|
+
return path.join(os.homedir(), configured.slice(2));
|
|
66
|
+
}
|
|
67
|
+
return configured;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function ensureLocalAuthToken(tokenPath, configuredToken = "") {
|
|
71
|
+
const direct = String(configuredToken ?? "").trim();
|
|
72
|
+
if (direct) {
|
|
73
|
+
return direct;
|
|
74
|
+
}
|
|
75
|
+
fs.mkdirSync(path.dirname(tokenPath), { recursive: true, mode: 0o700 });
|
|
76
|
+
try {
|
|
77
|
+
const existing = fs.readFileSync(tokenPath, "utf8").trim();
|
|
78
|
+
if (existing) {
|
|
79
|
+
fs.chmodSync(tokenPath, 0o600);
|
|
80
|
+
return existing;
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// Generate a new token below.
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const generated = crypto.randomBytes(32).toString("hex");
|
|
87
|
+
fs.writeFileSync(tokenPath, `${generated}\n`, {
|
|
88
|
+
encoding: "utf8",
|
|
89
|
+
mode: 0o600,
|
|
90
|
+
});
|
|
91
|
+
fs.chmodSync(tokenPath, 0o600);
|
|
92
|
+
return generated;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function loadConfig(env = process.env) {
|
|
96
|
+
const host = String(env.HOST ?? DEFAULTS.host).trim() || DEFAULTS.host;
|
|
97
|
+
const network = String(env.WDK_BTC_NETWORK ?? DEFAULTS.network).trim() || DEFAULTS.network;
|
|
98
|
+
if (!["bitcoin", "testnet", "regtest"].includes(network)) {
|
|
99
|
+
throw new Error("WDK_BTC_NETWORK must be one of: bitcoin, testnet, regtest.");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const bip = parseInteger(env.WDK_BTC_BIP, DEFAULTS.bip, "WDK_BTC_BIP");
|
|
103
|
+
if (![44, 84].includes(bip)) {
|
|
104
|
+
throw new Error("WDK_BTC_BIP must be either 44 or 84.");
|
|
105
|
+
}
|
|
106
|
+
const openClawHome = resolveOpenClawHome(env);
|
|
107
|
+
const dataDir =
|
|
108
|
+
String(env.WDK_BTC_DATA_DIR ?? "").trim() ||
|
|
109
|
+
path.join(openClawHome, "wdk-btc-wallet");
|
|
110
|
+
const authTokenPath =
|
|
111
|
+
String(env.WDK_BTC_LOCAL_TOKEN_PATH ?? "").trim() ||
|
|
112
|
+
path.join(openClawHome, "wdk-btc-wallet", "local-auth-token");
|
|
113
|
+
|
|
114
|
+
const networkProfiles = Object.fromEntries(
|
|
115
|
+
Object.entries(DEFAULT_NETWORK_PROFILES).map(([name, defaults]) => {
|
|
116
|
+
const prefix = `WDK_BTC_${name.toUpperCase()}_ELECTRUM_`;
|
|
117
|
+
const electrumProtocol =
|
|
118
|
+
String(env[`${prefix}PROTOCOL`] ?? defaults.electrumProtocol).trim().toLowerCase() ||
|
|
119
|
+
defaults.electrumProtocol;
|
|
120
|
+
if (!["tcp", "tls", "ssl", "ws"].includes(electrumProtocol)) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`${prefix}PROTOCOL must be one of: tcp, tls, ssl, ws.`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const electrumHost =
|
|
126
|
+
String(env[`${prefix}HOST`] ?? defaults.electrumHost).trim() || defaults.electrumHost;
|
|
127
|
+
const electrumPort = parseInteger(
|
|
128
|
+
env[`${prefix}PORT`],
|
|
129
|
+
defaults.electrumPort,
|
|
130
|
+
`${prefix}PORT`
|
|
131
|
+
);
|
|
132
|
+
return [
|
|
133
|
+
name,
|
|
134
|
+
{
|
|
135
|
+
electrumProtocol,
|
|
136
|
+
electrumHost,
|
|
137
|
+
electrumPort,
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
})
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
host,
|
|
145
|
+
port: parseInteger(env.PORT, DEFAULTS.port, "PORT"),
|
|
146
|
+
network,
|
|
147
|
+
bip,
|
|
148
|
+
openClawHome,
|
|
149
|
+
dataDir,
|
|
150
|
+
authRequired: true,
|
|
151
|
+
authTokenPath,
|
|
152
|
+
authToken: ensureLocalAuthToken(authTokenPath, env.WDK_BTC_LOCAL_TOKEN),
|
|
153
|
+
unlockTimeoutSeconds: parseNonNegativeInteger(
|
|
154
|
+
env.WDK_BTC_UNLOCK_TIMEOUT_SECONDS,
|
|
155
|
+
DEFAULTS.unlockTimeoutSeconds,
|
|
156
|
+
"WDK_BTC_UNLOCK_TIMEOUT_SECONDS"
|
|
157
|
+
),
|
|
158
|
+
networkProfiles,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export function jsonSafe(value) {
|
|
2
|
+
if (typeof value === "bigint") {
|
|
3
|
+
return value.toString();
|
|
4
|
+
}
|
|
5
|
+
if (Array.isArray(value)) {
|
|
6
|
+
return value.map((item) => jsonSafe(item));
|
|
7
|
+
}
|
|
8
|
+
if (value && typeof value === "object") {
|
|
9
|
+
return Object.fromEntries(
|
|
10
|
+
Object.entries(value).map(([key, item]) => [key, jsonSafe(item)])
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function sendJson(response, statusCode, payload) {
|
|
17
|
+
const body = JSON.stringify(jsonSafe(payload));
|
|
18
|
+
response.writeHead(statusCode, {
|
|
19
|
+
"Content-Type": "application/json",
|
|
20
|
+
"Content-Length": Buffer.byteLength(body),
|
|
21
|
+
});
|
|
22
|
+
response.end(body);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function readJsonBody(request) {
|
|
26
|
+
const chunks = [];
|
|
27
|
+
for await (const chunk of request) {
|
|
28
|
+
chunks.push(chunk);
|
|
29
|
+
}
|
|
30
|
+
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
|
31
|
+
if (!raw) {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
return JSON.parse(raw);
|
|
35
|
+
}
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
|
|
6
|
+
import WDK from "@tetherto/wdk";
|
|
7
|
+
|
|
8
|
+
const scryptAsync = promisify(crypto.scrypt);
|
|
9
|
+
|
|
10
|
+
const REGISTRY_FILE = "registry.json";
|
|
11
|
+
const WALLETS_DIR = "wallets";
|
|
12
|
+
const VAULT_VERSION = 1;
|
|
13
|
+
|
|
14
|
+
function assertNonEmptyString(value, fieldName) {
|
|
15
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
16
|
+
throw new Error(`${fieldName} is required.`);
|
|
17
|
+
}
|
|
18
|
+
return value.trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function assertPositiveInteger(value, fieldName) {
|
|
22
|
+
const parsed = Number(value);
|
|
23
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
24
|
+
throw new Error(`${fieldName} must be a positive integer.`);
|
|
25
|
+
}
|
|
26
|
+
return parsed;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function assertNonNegativeInteger(value, fieldName) {
|
|
30
|
+
const parsed = Number(value);
|
|
31
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
32
|
+
throw new Error(`${fieldName} must be a non-negative integer.`);
|
|
33
|
+
}
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sanitizeLabel(label) {
|
|
38
|
+
const normalized = String(label ?? "").trim();
|
|
39
|
+
return normalized || "BTC Wallet";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function assertValidNetwork(network, fieldName = "network") {
|
|
43
|
+
const normalized = assertNonEmptyString(network, fieldName);
|
|
44
|
+
if (!["bitcoin", "testnet", "regtest"].includes(normalized)) {
|
|
45
|
+
throw new Error(`${fieldName} must be one of: bitcoin, testnet, regtest.`);
|
|
46
|
+
}
|
|
47
|
+
return normalized;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function deriveKey(password, salt) {
|
|
51
|
+
return scryptAsync(password, salt, 32, {
|
|
52
|
+
N: 1 << 15,
|
|
53
|
+
r: 8,
|
|
54
|
+
p: 1,
|
|
55
|
+
maxmem: 64 * 1024 * 1024,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function encryptSeedPhrase({ seedPhrase, password, walletId }) {
|
|
60
|
+
const salt = crypto.randomBytes(16);
|
|
61
|
+
const iv = crypto.randomBytes(12);
|
|
62
|
+
const key = await deriveKey(password, salt);
|
|
63
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
64
|
+
cipher.setAAD(Buffer.from(`wdk-btc-wallet:${walletId}:v${VAULT_VERSION}`, "utf8"));
|
|
65
|
+
const ciphertext = Buffer.concat([
|
|
66
|
+
cipher.update(Buffer.from(seedPhrase, "utf8")),
|
|
67
|
+
cipher.final(),
|
|
68
|
+
]);
|
|
69
|
+
const tag = cipher.getAuthTag();
|
|
70
|
+
return {
|
|
71
|
+
version: VAULT_VERSION,
|
|
72
|
+
kdf: {
|
|
73
|
+
name: "scrypt",
|
|
74
|
+
salt: salt.toString("base64"),
|
|
75
|
+
N: 1 << 15,
|
|
76
|
+
r: 8,
|
|
77
|
+
p: 1,
|
|
78
|
+
},
|
|
79
|
+
cipher: {
|
|
80
|
+
name: "aes-256-gcm",
|
|
81
|
+
iv: iv.toString("base64"),
|
|
82
|
+
tag: tag.toString("base64"),
|
|
83
|
+
},
|
|
84
|
+
ciphertext: ciphertext.toString("base64"),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function decryptSeedPhrase({ encrypted, password, walletId }) {
|
|
89
|
+
const salt = Buffer.from(encrypted.kdf.salt, "base64");
|
|
90
|
+
const iv = Buffer.from(encrypted.cipher.iv, "base64");
|
|
91
|
+
const tag = Buffer.from(encrypted.cipher.tag, "base64");
|
|
92
|
+
const ciphertext = Buffer.from(encrypted.ciphertext, "base64");
|
|
93
|
+
const key = await deriveKey(password, salt);
|
|
94
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
|
|
95
|
+
decipher.setAAD(Buffer.from(`wdk-btc-wallet:${walletId}:v${VAULT_VERSION}`, "utf8"));
|
|
96
|
+
decipher.setAuthTag(tag);
|
|
97
|
+
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
98
|
+
return plaintext.toString("utf8");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function decryptSeedPhraseWithPasswordCheck(args) {
|
|
102
|
+
try {
|
|
103
|
+
return await decryptSeedPhrase(args);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
106
|
+
if (
|
|
107
|
+
message.includes("authenticate data") ||
|
|
108
|
+
message.includes("unable to authenticate") ||
|
|
109
|
+
message.includes("Unsupported state")
|
|
110
|
+
) {
|
|
111
|
+
throw new Error("Invalid password.");
|
|
112
|
+
}
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export class LocalBtcVault {
|
|
118
|
+
constructor(config) {
|
|
119
|
+
this.config = config;
|
|
120
|
+
this._unlocked = new Map();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async createWallet({
|
|
124
|
+
label = "",
|
|
125
|
+
password,
|
|
126
|
+
words = 12,
|
|
127
|
+
revealSeedPhrase = false,
|
|
128
|
+
network,
|
|
129
|
+
}) {
|
|
130
|
+
const count = assertPositiveInteger(words, "words");
|
|
131
|
+
if (count !== 12) {
|
|
132
|
+
throw new Error("Only 12-word wallet creation is currently supported.");
|
|
133
|
+
}
|
|
134
|
+
const seedPhrase = WDK.getRandomSeedPhrase();
|
|
135
|
+
const wallet = await this.#storeWallet({
|
|
136
|
+
label,
|
|
137
|
+
password,
|
|
138
|
+
seedPhrase,
|
|
139
|
+
source: "created",
|
|
140
|
+
network,
|
|
141
|
+
});
|
|
142
|
+
await this.unlockWallet({ walletId: wallet.walletId, password, timeoutSeconds: 0 });
|
|
143
|
+
return {
|
|
144
|
+
...wallet,
|
|
145
|
+
unlocked: true,
|
|
146
|
+
unlockExpiresAt: null,
|
|
147
|
+
...(revealSeedPhrase ? { seedPhrase } : {}),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async importWallet({ label = "", password, seedPhrase, network }) {
|
|
152
|
+
const mnemonic = assertNonEmptyString(seedPhrase, "seedPhrase");
|
|
153
|
+
if (!WDK.isValidSeed(mnemonic)) {
|
|
154
|
+
throw new Error("seedPhrase must be a valid BIP-39 seed phrase.");
|
|
155
|
+
}
|
|
156
|
+
const wallet = await this.#storeWallet({
|
|
157
|
+
label,
|
|
158
|
+
password,
|
|
159
|
+
seedPhrase: mnemonic,
|
|
160
|
+
source: "imported",
|
|
161
|
+
network,
|
|
162
|
+
});
|
|
163
|
+
await this.unlockWallet({ walletId: wallet.walletId, password, timeoutSeconds: 0 });
|
|
164
|
+
return {
|
|
165
|
+
...wallet,
|
|
166
|
+
unlocked: true,
|
|
167
|
+
unlockExpiresAt: null,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async listWallets() {
|
|
172
|
+
this.#sweepExpiredUnlocked();
|
|
173
|
+
const registry = await this.#loadRegistry();
|
|
174
|
+
return registry.wallets.map((wallet) => {
|
|
175
|
+
const unlocked = this._unlocked.get(wallet.walletId);
|
|
176
|
+
return {
|
|
177
|
+
...wallet,
|
|
178
|
+
unlocked: Boolean(unlocked),
|
|
179
|
+
unlockExpiresAt: unlocked ? unlocked.expiresAt : null,
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async getWallet({ walletId }) {
|
|
185
|
+
this.#sweepExpiredUnlocked();
|
|
186
|
+
const wallet = await this.#getWalletMetadata(assertNonEmptyString(walletId, "walletId"));
|
|
187
|
+
const unlocked = this._unlocked.get(wallet.walletId);
|
|
188
|
+
return {
|
|
189
|
+
...wallet,
|
|
190
|
+
unlocked: Boolean(unlocked),
|
|
191
|
+
unlockExpiresAt: unlocked ? unlocked.expiresAt : null,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async unlockWallet({ walletId, password, timeoutSeconds }) {
|
|
196
|
+
const metadata = await this.#getWalletMetadata(assertNonEmptyString(walletId, "walletId"));
|
|
197
|
+
const encrypted = await this.#loadEncryptedWallet(walletId);
|
|
198
|
+
const secret = await decryptSeedPhraseWithPasswordCheck({
|
|
199
|
+
encrypted,
|
|
200
|
+
password: assertNonEmptyString(password, "password"),
|
|
201
|
+
walletId,
|
|
202
|
+
});
|
|
203
|
+
if (!WDK.isValidSeed(secret)) {
|
|
204
|
+
throw new Error("Decrypted wallet seed phrase is invalid.");
|
|
205
|
+
}
|
|
206
|
+
const ttl =
|
|
207
|
+
timeoutSeconds === undefined || timeoutSeconds === null
|
|
208
|
+
? this.config.unlockTimeoutSeconds
|
|
209
|
+
: assertNonNegativeInteger(timeoutSeconds, "timeoutSeconds");
|
|
210
|
+
const expiresAt = ttl === 0 ? null : new Date(Date.now() + ttl * 1000).toISOString();
|
|
211
|
+
this._unlocked.set(walletId, {
|
|
212
|
+
seedPhrase: secret,
|
|
213
|
+
expiresAt,
|
|
214
|
+
});
|
|
215
|
+
return {
|
|
216
|
+
walletId,
|
|
217
|
+
label: metadata.label,
|
|
218
|
+
unlocked: true,
|
|
219
|
+
unlockExpiresAt: expiresAt,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async lockWallet({ walletId }) {
|
|
224
|
+
this._unlocked.delete(assertNonEmptyString(walletId, "walletId"));
|
|
225
|
+
return {
|
|
226
|
+
walletId,
|
|
227
|
+
unlocked: false,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async revealSeedPhrase({ walletId, password }) {
|
|
232
|
+
const id = assertNonEmptyString(walletId, "walletId");
|
|
233
|
+
const metadata = await this.#getWalletMetadata(id);
|
|
234
|
+
const encrypted = await this.#loadEncryptedWallet(id);
|
|
235
|
+
const seedPhrase = await decryptSeedPhraseWithPasswordCheck({
|
|
236
|
+
encrypted,
|
|
237
|
+
password: assertNonEmptyString(password, "password"),
|
|
238
|
+
walletId: id,
|
|
239
|
+
});
|
|
240
|
+
if (!WDK.isValidSeed(seedPhrase)) {
|
|
241
|
+
throw new Error("Decrypted wallet seed phrase is invalid.");
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
walletId: id,
|
|
245
|
+
label: metadata.label,
|
|
246
|
+
seedPhrase,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async changePassword({ walletId, currentPassword, newPassword }) {
|
|
251
|
+
const id = assertNonEmptyString(walletId, "walletId");
|
|
252
|
+
const safeCurrentPassword = assertNonEmptyString(currentPassword, "currentPassword");
|
|
253
|
+
const safeNewPassword = assertNonEmptyString(newPassword, "newPassword");
|
|
254
|
+
const metadata = await this.#getWalletMetadata(id);
|
|
255
|
+
const encrypted = await this.#loadEncryptedWallet(id);
|
|
256
|
+
const seedPhrase = await decryptSeedPhraseWithPasswordCheck({
|
|
257
|
+
encrypted,
|
|
258
|
+
password: safeCurrentPassword,
|
|
259
|
+
walletId: id,
|
|
260
|
+
});
|
|
261
|
+
if (!WDK.isValidSeed(seedPhrase)) {
|
|
262
|
+
throw new Error("Decrypted wallet seed phrase is invalid.");
|
|
263
|
+
}
|
|
264
|
+
const reencrypted = await encryptSeedPhrase({
|
|
265
|
+
seedPhrase,
|
|
266
|
+
password: safeNewPassword,
|
|
267
|
+
walletId: id,
|
|
268
|
+
});
|
|
269
|
+
await fs.writeFile(this.#walletFilePath(id), JSON.stringify(reencrypted, null, 2), {
|
|
270
|
+
encoding: "utf8",
|
|
271
|
+
mode: 0o600,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const registry = await this.#loadRegistry();
|
|
275
|
+
const index = registry.wallets.findIndex((wallet) => wallet.walletId === id);
|
|
276
|
+
if (index === -1) {
|
|
277
|
+
throw new Error(`Unknown walletId: ${id}`);
|
|
278
|
+
}
|
|
279
|
+
const updatedAt = new Date().toISOString();
|
|
280
|
+
registry.wallets[index] = {
|
|
281
|
+
...registry.wallets[index],
|
|
282
|
+
updatedAt,
|
|
283
|
+
};
|
|
284
|
+
await this.#saveRegistry(registry);
|
|
285
|
+
|
|
286
|
+
const unlocked = this._unlocked.get(id);
|
|
287
|
+
if (unlocked) {
|
|
288
|
+
this._unlocked.set(id, {
|
|
289
|
+
seedPhrase,
|
|
290
|
+
expiresAt: unlocked.expiresAt,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
walletId: id,
|
|
296
|
+
label: metadata.label,
|
|
297
|
+
passwordChanged: true,
|
|
298
|
+
updatedAt,
|
|
299
|
+
unlocked: Boolean(this._unlocked.get(id)),
|
|
300
|
+
unlockExpiresAt: this._unlocked.get(id)?.expiresAt ?? null,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async resolveSeedPhrase({ walletId, seedPhrase }) {
|
|
305
|
+
if (typeof seedPhrase === "string" && seedPhrase.trim()) {
|
|
306
|
+
if (!WDK.isValidSeed(seedPhrase.trim())) {
|
|
307
|
+
throw new Error("seedPhrase must be a valid BIP-39 seed phrase.");
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
seedPhrase: seedPhrase.trim(),
|
|
311
|
+
source: "request",
|
|
312
|
+
walletId: null,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
const id = assertNonEmptyString(walletId, "walletId");
|
|
316
|
+
this.#sweepExpiredUnlocked();
|
|
317
|
+
const unlocked = this._unlocked.get(id);
|
|
318
|
+
if (!unlocked) {
|
|
319
|
+
throw new Error("Wallet is locked. Unlock it first or provide seedPhrase explicitly.");
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
seedPhrase: unlocked.seedPhrase,
|
|
323
|
+
source: "local-vault",
|
|
324
|
+
walletId: id,
|
|
325
|
+
unlockExpiresAt: unlocked.expiresAt,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async #storeWallet({ label, password, seedPhrase, source, network }) {
|
|
330
|
+
const safePassword = assertNonEmptyString(password, "password");
|
|
331
|
+
await this.#ensureLayout();
|
|
332
|
+
|
|
333
|
+
const walletId = crypto.randomUUID();
|
|
334
|
+
const now = new Date().toISOString();
|
|
335
|
+
const encrypted = await encryptSeedPhrase({
|
|
336
|
+
seedPhrase,
|
|
337
|
+
password: safePassword,
|
|
338
|
+
walletId,
|
|
339
|
+
});
|
|
340
|
+
const entry = {
|
|
341
|
+
walletId,
|
|
342
|
+
label: sanitizeLabel(label),
|
|
343
|
+
createdAt: now,
|
|
344
|
+
updatedAt: now,
|
|
345
|
+
network: assertValidNetwork(network ?? this.config.network, "network"),
|
|
346
|
+
bip: this.config.bip,
|
|
347
|
+
source,
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
await fs.writeFile(this.#walletFilePath(walletId), JSON.stringify(encrypted, null, 2), {
|
|
351
|
+
encoding: "utf8",
|
|
352
|
+
mode: 0o600,
|
|
353
|
+
});
|
|
354
|
+
const registry = await this.#loadRegistry();
|
|
355
|
+
registry.wallets.push(entry);
|
|
356
|
+
await this.#saveRegistry(registry);
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
walletId,
|
|
360
|
+
label: entry.label,
|
|
361
|
+
createdAt: entry.createdAt,
|
|
362
|
+
updatedAt: entry.updatedAt,
|
|
363
|
+
network: entry.network,
|
|
364
|
+
bip: entry.bip,
|
|
365
|
+
source: entry.source,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async #getWalletMetadata(walletId) {
|
|
370
|
+
const registry = await this.#loadRegistry();
|
|
371
|
+
const wallet = registry.wallets.find((item) => item.walletId === walletId);
|
|
372
|
+
if (!wallet) {
|
|
373
|
+
throw new Error(`Unknown walletId: ${walletId}`);
|
|
374
|
+
}
|
|
375
|
+
return wallet;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async #loadEncryptedWallet(walletId) {
|
|
379
|
+
const raw = await fs.readFile(this.#walletFilePath(walletId), "utf8");
|
|
380
|
+
return JSON.parse(raw);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async #ensureLayout() {
|
|
384
|
+
await fs.mkdir(this.config.dataDir, { recursive: true, mode: 0o700 });
|
|
385
|
+
await fs.mkdir(path.join(this.config.dataDir, WALLETS_DIR), {
|
|
386
|
+
recursive: true,
|
|
387
|
+
mode: 0o700,
|
|
388
|
+
});
|
|
389
|
+
try {
|
|
390
|
+
await fs.access(this.#registryPath());
|
|
391
|
+
} catch {
|
|
392
|
+
await this.#saveRegistry({
|
|
393
|
+
version: VAULT_VERSION,
|
|
394
|
+
wallets: [],
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async #loadRegistry() {
|
|
400
|
+
await this.#ensureLayout();
|
|
401
|
+
const raw = await fs.readFile(this.#registryPath(), "utf8");
|
|
402
|
+
const parsed = JSON.parse(raw);
|
|
403
|
+
if (!Array.isArray(parsed.wallets)) {
|
|
404
|
+
throw new Error("Vault registry is invalid.");
|
|
405
|
+
}
|
|
406
|
+
return parsed;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async #saveRegistry(registry) {
|
|
410
|
+
await fs.writeFile(this.#registryPath(), JSON.stringify(registry, null, 2), {
|
|
411
|
+
encoding: "utf8",
|
|
412
|
+
mode: 0o600,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
#walletFilePath(walletId) {
|
|
417
|
+
return path.join(this.config.dataDir, WALLETS_DIR, `${walletId}.json`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
#registryPath() {
|
|
421
|
+
return path.join(this.config.dataDir, REGISTRY_FILE);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
#sweepExpiredUnlocked() {
|
|
425
|
+
const now = Date.now();
|
|
426
|
+
for (const [walletId, state] of this._unlocked.entries()) {
|
|
427
|
+
if (state.expiresAt && Date.parse(state.expiresAt) <= now) {
|
|
428
|
+
this._unlocked.delete(walletId);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|