@kakedashi/paylog 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/README.md +82 -0
- package/bin/paylog.js +128 -0
- package/bin/paylog.ts +142 -0
- package/package.json +35 -0
- package/src/api.js +17 -0
- package/src/api.ts +52 -0
- package/src/enrich.js +108 -0
- package/src/enrich.ts +158 -0
- package/src/format.js +92 -0
- package/src/format.ts +114 -0
- package/src/history.js +131 -0
- package/src/history.ts +137 -0
- package/src/wallet.js +53 -0
- package/src/wallet.ts +52 -0
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# paylog
|
|
2
|
+
|
|
3
|
+
CLI tool to view your [MPP](https://mpp.dev) spending history, powered by [paylog.dev](https://paylog.dev).
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx paylog report --days 7
|
|
9
|
+
npx paylog report --days 30 --enrich
|
|
10
|
+
npx paylog wallet
|
|
11
|
+
npx paylog balance
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Commands
|
|
15
|
+
|
|
16
|
+
### `paylog report`
|
|
17
|
+
|
|
18
|
+
Show a spending report for your Tempo wallet.
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
Options:
|
|
22
|
+
-d, --days <n> Past N days (default: 7)
|
|
23
|
+
--from <date> Start date YYYY-MM-DD
|
|
24
|
+
--to <date> End date YYYY-MM-DD
|
|
25
|
+
--wallet <address> Override auto-detected wallet
|
|
26
|
+
--enrich Enrich Locus payments using local history
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The `--enrich` flag reads your shell history and Claude Code chat logs to identify which Locus MPP services you used, since all Locus services share a single on-chain recipient address.
|
|
30
|
+
|
|
31
|
+
### `paylog wallet`
|
|
32
|
+
|
|
33
|
+
Display the auto-detected wallet address and its source.
|
|
34
|
+
|
|
35
|
+
### `paylog balance`
|
|
36
|
+
|
|
37
|
+
Run `tempo wallet whoami` (requires the [Tempo CLI](https://tempo.xyz)).
|
|
38
|
+
|
|
39
|
+
## Wallet Auto-Detection
|
|
40
|
+
|
|
41
|
+
The wallet address is resolved in this order:
|
|
42
|
+
|
|
43
|
+
1. `~/.agentcash/wallet.json` → `address` field
|
|
44
|
+
2. `~/.mppx/wallet.json` → `address` field
|
|
45
|
+
3. `~/.tempo/wallet/keys.toml` → `wallet_address` field
|
|
46
|
+
4. `--wallet 0x...` option
|
|
47
|
+
|
|
48
|
+
## `--enrich` Details
|
|
49
|
+
|
|
50
|
+
Locus routes 40+ services through a single on-chain recipient (`0x060b0fb0...`), making them indistinguishable from on-chain data alone. The `--enrich` flag uses local history to estimate which service was used:
|
|
51
|
+
|
|
52
|
+
**History sources scanned:**
|
|
53
|
+
- `~/.zsh_history` (with timestamps)
|
|
54
|
+
- `~/.bash_history`
|
|
55
|
+
- `~/.local/share/fish/fish_history`
|
|
56
|
+
- `~/.claude/projects/*/chat.jsonl`
|
|
57
|
+
|
|
58
|
+
**Matching logic:**
|
|
59
|
+
|
|
60
|
+
| Confidence | Condition |
|
|
61
|
+
|-----------|-----------|
|
|
62
|
+
| High | Timestamp matches within ±30 seconds |
|
|
63
|
+
| Medium | Timestamp matches within ±60 seconds + price match |
|
|
64
|
+
| Low | Price match only |
|
|
65
|
+
|
|
66
|
+
**Example output:**
|
|
67
|
+
```
|
|
68
|
+
Locus (paywithlocus.com) $0.048 4 txns
|
|
69
|
+
┌─ Locus内訳 (ローカル履歴より推定) ─────────────────
|
|
70
|
+
│ ✓ Brave $0.010 1件 信頼度:高
|
|
71
|
+
│ ✓ Perplexity $0.010 1件 信頼度:高
|
|
72
|
+
│ ? Deepseek $0.020 1件 信頼度:中
|
|
73
|
+
└─ ※ ローカル履歴との照合による推定 ──────────────
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Cost
|
|
77
|
+
|
|
78
|
+
Reports cost **$0.001 USDC** per call via MPP (Tempo chain). Your wallet must be funded.
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
package/bin/paylog.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { resolveWallet } from '../src/wallet.js';
|
|
8
|
+
import { fetchReport } from '../src/api.js';
|
|
9
|
+
import { enrichLocusPayments } from '../src/enrich.js';
|
|
10
|
+
import { printReport, printWallet, printError } from '../src/format.js';
|
|
11
|
+
const program = new Command();
|
|
12
|
+
program
|
|
13
|
+
.name('paylog')
|
|
14
|
+
.description('View your MPP spending history from paylog.dev')
|
|
15
|
+
.version('0.1.0');
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// paylog report
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
program
|
|
20
|
+
.command('report')
|
|
21
|
+
.description('Show spending report for your Tempo wallet')
|
|
22
|
+
.option('-d, --days <n>', 'Number of past days to include', '7')
|
|
23
|
+
.option('--from <date>', 'Start date (YYYY-MM-DD)')
|
|
24
|
+
.option('--to <date>', 'End date (YYYY-MM-DD)')
|
|
25
|
+
.option('--wallet <address>', 'Wallet address (overrides auto-detection)')
|
|
26
|
+
.option('--enrich', 'Enrich Locus payments using local shell/Claude history', false)
|
|
27
|
+
.action(async (opts) => {
|
|
28
|
+
// Resolve wallet
|
|
29
|
+
let wallet;
|
|
30
|
+
try {
|
|
31
|
+
wallet = resolveWallet(opts.wallet);
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
printError(err.message);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
if (!wallet) {
|
|
38
|
+
printError('No wallet found. Provide one via --wallet, or set up a Tempo/mppx/agentcash wallet.');
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
// Resolve date range
|
|
42
|
+
const toDate = opts.to ?? new Date().toISOString().slice(0, 10);
|
|
43
|
+
let fromDate;
|
|
44
|
+
if (opts.from) {
|
|
45
|
+
fromDate = opts.from;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
const days = parseInt(opts.days, 10);
|
|
49
|
+
if (isNaN(days) || days < 1) {
|
|
50
|
+
printError('--days must be a positive integer');
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
const d = new Date();
|
|
54
|
+
d.setUTCDate(d.getUTCDate() - days);
|
|
55
|
+
fromDate = d.toISOString().slice(0, 10);
|
|
56
|
+
}
|
|
57
|
+
// Fetch report
|
|
58
|
+
let report;
|
|
59
|
+
try {
|
|
60
|
+
report = await fetchReport(wallet, fromDate, toDate, opts.enrich);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
printError(err.message);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
// Optionally enrich Locus payments
|
|
67
|
+
const enrich = opts.enrich ? enrichLocusPayments(report) : undefined;
|
|
68
|
+
printReport(report, enrich);
|
|
69
|
+
});
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// paylog wallet
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
program
|
|
74
|
+
.command('wallet')
|
|
75
|
+
.description('Show the detected wallet address')
|
|
76
|
+
.option('--wallet <address>', 'Wallet address (overrides auto-detection)')
|
|
77
|
+
.action((opts) => {
|
|
78
|
+
const home = homedir();
|
|
79
|
+
const sources = [
|
|
80
|
+
{ path: join(home, '.agentcash', 'wallet.json'), label: '~/.agentcash/wallet.json' },
|
|
81
|
+
{ path: join(home, '.mppx', 'wallet.json'), label: '~/.mppx/wallet.json' },
|
|
82
|
+
{ path: join(home, '.tempo', 'wallet', 'keys.toml'), label: '~/.tempo/wallet/keys.toml' },
|
|
83
|
+
];
|
|
84
|
+
if (opts.wallet) {
|
|
85
|
+
try {
|
|
86
|
+
const addr = resolveWallet(opts.wallet);
|
|
87
|
+
printWallet(addr, '--wallet option');
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
printError(err.message);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
let foundSource = 'auto-detected';
|
|
96
|
+
for (const src of sources) {
|
|
97
|
+
if (existsSync(src.path)) {
|
|
98
|
+
foundSource = src.label;
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const wallet = resolveWallet();
|
|
103
|
+
if (wallet) {
|
|
104
|
+
printWallet(wallet, foundSource);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
printError('No wallet found. Try:\n' +
|
|
108
|
+
' ~/.agentcash/wallet.json { "address": "0x..." }\n' +
|
|
109
|
+
' ~/.mppx/wallet.json { "address": "0x..." }\n' +
|
|
110
|
+
' ~/.tempo/wallet/keys.toml wallet_address = "0x..."\n' +
|
|
111
|
+
' paylog wallet --wallet 0x...');
|
|
112
|
+
process.exit(1);
|
|
113
|
+
});
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// paylog balance
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
program
|
|
118
|
+
.command('balance')
|
|
119
|
+
.description('Show Tempo wallet balance (requires tempo CLI)')
|
|
120
|
+
.action(() => {
|
|
121
|
+
const result = spawnSync('tempo', ['wallet', 'whoami'], { stdio: 'inherit' });
|
|
122
|
+
if (result.error) {
|
|
123
|
+
printError('Could not run `tempo wallet whoami`. Is the tempo CLI installed?');
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
process.exit(result.status ?? 0);
|
|
127
|
+
});
|
|
128
|
+
program.parse();
|
package/bin/paylog.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander'
|
|
3
|
+
import { existsSync } from 'node:fs'
|
|
4
|
+
import { spawnSync } from 'node:child_process'
|
|
5
|
+
import { homedir } from 'node:os'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { resolveWallet } from '../src/wallet.js'
|
|
8
|
+
import { fetchReport } from '../src/api.js'
|
|
9
|
+
import { enrichLocusPayments } from '../src/enrich.js'
|
|
10
|
+
import { printReport, printWallet, printError } from '../src/format.js'
|
|
11
|
+
|
|
12
|
+
const program = new Command()
|
|
13
|
+
|
|
14
|
+
program
|
|
15
|
+
.name('paylog')
|
|
16
|
+
.description('View your MPP spending history from paylog.dev')
|
|
17
|
+
.version('0.1.0')
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// paylog report
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
program
|
|
23
|
+
.command('report')
|
|
24
|
+
.description('Show spending report for your Tempo wallet')
|
|
25
|
+
.option('-d, --days <n>', 'Number of past days to include', '7')
|
|
26
|
+
.option('--from <date>', 'Start date (YYYY-MM-DD)')
|
|
27
|
+
.option('--to <date>', 'End date (YYYY-MM-DD)')
|
|
28
|
+
.option('--wallet <address>', 'Wallet address (overrides auto-detection)')
|
|
29
|
+
.option('--enrich', 'Enrich Locus payments using local shell/Claude history', false)
|
|
30
|
+
.action(async (opts) => {
|
|
31
|
+
// Resolve wallet
|
|
32
|
+
let wallet: string | null
|
|
33
|
+
try {
|
|
34
|
+
wallet = resolveWallet(opts.wallet as string | undefined)
|
|
35
|
+
} catch (err: unknown) {
|
|
36
|
+
printError((err as Error).message)
|
|
37
|
+
process.exit(1)
|
|
38
|
+
}
|
|
39
|
+
if (!wallet) {
|
|
40
|
+
printError(
|
|
41
|
+
'No wallet found. Provide one via --wallet, or set up a Tempo/mppx/agentcash wallet.',
|
|
42
|
+
)
|
|
43
|
+
process.exit(1)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Resolve date range
|
|
47
|
+
const toDate = (opts.to as string | undefined) ?? new Date().toISOString().slice(0, 10)
|
|
48
|
+
let fromDate: string
|
|
49
|
+
if (opts.from) {
|
|
50
|
+
fromDate = opts.from as string
|
|
51
|
+
} else {
|
|
52
|
+
const days = parseInt(opts.days as string, 10)
|
|
53
|
+
if (isNaN(days) || days < 1) {
|
|
54
|
+
printError('--days must be a positive integer')
|
|
55
|
+
process.exit(1)
|
|
56
|
+
}
|
|
57
|
+
const d = new Date()
|
|
58
|
+
d.setUTCDate(d.getUTCDate() - days)
|
|
59
|
+
fromDate = d.toISOString().slice(0, 10)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Fetch report
|
|
63
|
+
let report
|
|
64
|
+
try {
|
|
65
|
+
report = await fetchReport(wallet, fromDate, toDate, opts.enrich as boolean)
|
|
66
|
+
} catch (err: unknown) {
|
|
67
|
+
printError((err as Error).message)
|
|
68
|
+
process.exit(1)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Optionally enrich Locus payments
|
|
72
|
+
const enrich = (opts.enrich as boolean) ? enrichLocusPayments(report) : undefined
|
|
73
|
+
|
|
74
|
+
printReport(report, enrich)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// paylog wallet
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
program
|
|
81
|
+
.command('wallet')
|
|
82
|
+
.description('Show the detected wallet address')
|
|
83
|
+
.option('--wallet <address>', 'Wallet address (overrides auto-detection)')
|
|
84
|
+
.action((opts) => {
|
|
85
|
+
const home = homedir()
|
|
86
|
+
const sources = [
|
|
87
|
+
{ path: join(home, '.agentcash', 'wallet.json'), label: '~/.agentcash/wallet.json' },
|
|
88
|
+
{ path: join(home, '.mppx', 'wallet.json'), label: '~/.mppx/wallet.json' },
|
|
89
|
+
{ path: join(home, '.tempo', 'wallet', 'keys.toml'), label: '~/.tempo/wallet/keys.toml' },
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
if (opts.wallet) {
|
|
93
|
+
try {
|
|
94
|
+
const addr = resolveWallet(opts.wallet as string)
|
|
95
|
+
printWallet(addr!, '--wallet option')
|
|
96
|
+
} catch (err: unknown) {
|
|
97
|
+
printError((err as Error).message)
|
|
98
|
+
process.exit(1)
|
|
99
|
+
}
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let foundSource = 'auto-detected'
|
|
104
|
+
for (const src of sources) {
|
|
105
|
+
if (existsSync(src.path)) {
|
|
106
|
+
foundSource = src.label
|
|
107
|
+
break
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const wallet = resolveWallet()
|
|
112
|
+
if (wallet) {
|
|
113
|
+
printWallet(wallet, foundSource)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
printError(
|
|
118
|
+
'No wallet found. Try:\n' +
|
|
119
|
+
' ~/.agentcash/wallet.json { "address": "0x..." }\n' +
|
|
120
|
+
' ~/.mppx/wallet.json { "address": "0x..." }\n' +
|
|
121
|
+
' ~/.tempo/wallet/keys.toml wallet_address = "0x..."\n' +
|
|
122
|
+
' paylog wallet --wallet 0x...',
|
|
123
|
+
)
|
|
124
|
+
process.exit(1)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// paylog balance
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
program
|
|
131
|
+
.command('balance')
|
|
132
|
+
.description('Show Tempo wallet balance (requires tempo CLI)')
|
|
133
|
+
.action(() => {
|
|
134
|
+
const result = spawnSync('tempo', ['wallet', 'whoami'], { stdio: 'inherit' })
|
|
135
|
+
if (result.error) {
|
|
136
|
+
printError('Could not run `tempo wallet whoami`. Is the tempo CLI installed?')
|
|
137
|
+
process.exit(1)
|
|
138
|
+
}
|
|
139
|
+
process.exit(result.status ?? 0)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
program.parse()
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kakedashi/paylog",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI tool to view your MPP spending history powered by paylog.dev",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"paylog": "./bin/paylog.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"dev": "tsx bin/paylog.ts",
|
|
16
|
+
"prepublishOnly": "npm run build"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"mpp",
|
|
20
|
+
"tempo",
|
|
21
|
+
"usdc",
|
|
22
|
+
"payments",
|
|
23
|
+
"cli"
|
|
24
|
+
],
|
|
25
|
+
"author": "",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"commander": "^12.1.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^22.0.0",
|
|
32
|
+
"tsx": "^4.19.0",
|
|
33
|
+
"typescript": "^5.7.0"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const BASE_URL = 'https://paylog.dev';
|
|
2
|
+
export async function fetchReport(wallet, from, to, resolve = false) {
|
|
3
|
+
const params = new URLSearchParams({ wallet, from, to });
|
|
4
|
+
if (resolve)
|
|
5
|
+
params.set('resolve', 'true');
|
|
6
|
+
const url = `${BASE_URL}/api/v1/report?${params}`;
|
|
7
|
+
const res = await fetch(url);
|
|
8
|
+
if (res.status === 402) {
|
|
9
|
+
throw new Error('Payment required (402). This API costs $0.001 USDC per call.\n' +
|
|
10
|
+
'Make sure your Tempo wallet is funded and the mppx client is configured.');
|
|
11
|
+
}
|
|
12
|
+
if (!res.ok) {
|
|
13
|
+
const body = await res.text();
|
|
14
|
+
throw new Error(`API error ${res.status}: ${body}`);
|
|
15
|
+
}
|
|
16
|
+
return res.json();
|
|
17
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const BASE_URL = 'https://paylog.dev'
|
|
2
|
+
|
|
3
|
+
export interface ServiceSummary {
|
|
4
|
+
name: string
|
|
5
|
+
url: string
|
|
6
|
+
spent: number
|
|
7
|
+
txns: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface DailyBreakdown {
|
|
11
|
+
date: string
|
|
12
|
+
total_usd: number
|
|
13
|
+
by_service: ServiceSummary[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ReportResponse {
|
|
17
|
+
wallet: string
|
|
18
|
+
period: { from: string; to: string }
|
|
19
|
+
total_spent_usd: number
|
|
20
|
+
service_spent_usd: number
|
|
21
|
+
by_service: ServiceSummary[]
|
|
22
|
+
session_deposits: { total_usd: number; txns: number; note: string }
|
|
23
|
+
network_fees: { total_usd: number; txns: number }
|
|
24
|
+
other: { total_usd: number; txns: number }
|
|
25
|
+
daily_breakdown: DailyBreakdown[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function fetchReport(
|
|
29
|
+
wallet: string,
|
|
30
|
+
from: string,
|
|
31
|
+
to: string,
|
|
32
|
+
resolve = false,
|
|
33
|
+
): Promise<ReportResponse> {
|
|
34
|
+
const params = new URLSearchParams({ wallet, from, to })
|
|
35
|
+
if (resolve) params.set('resolve', 'true')
|
|
36
|
+
|
|
37
|
+
const url = `${BASE_URL}/api/v1/report?${params}`
|
|
38
|
+
const res = await fetch(url)
|
|
39
|
+
|
|
40
|
+
if (res.status === 402) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
'Payment required (402). This API costs $0.001 USDC per call.\n' +
|
|
43
|
+
'Make sure your Tempo wallet is funded and the mppx client is configured.',
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
const body = await res.text()
|
|
48
|
+
throw new Error(`API error ${res.status}: ${body}`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return res.json() as Promise<ReportResponse>
|
|
52
|
+
}
|
package/src/enrich.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { readLocalHistory } from './history.js';
|
|
2
|
+
// Locus gateway address (all Locus MPP services share this)
|
|
3
|
+
const LOCUS_RECIPIENT = '0x060b0fb0be9d90557577b3aee480711067149ff0';
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Block-time → unix seconds estimate
|
|
6
|
+
// (paylog.dev returns daily_breakdown with date; we approximate block timestamp
|
|
7
|
+
// from the date. In a future version the API could return block timestamps.)
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
/** Confidence rules:
|
|
10
|
+
* high: |Δt| ≤ 30s
|
|
11
|
+
* medium: |Δt| ≤ 60s AND price within 0.5% of history entry (same endpoint cost)
|
|
12
|
+
* low: price within 2% but timestamp unknown or >60s
|
|
13
|
+
*/
|
|
14
|
+
function calcConfidence(deltaSec, priceMatch) {
|
|
15
|
+
const absDelta = Math.abs(deltaSec);
|
|
16
|
+
if (absDelta <= 30)
|
|
17
|
+
return 'high';
|
|
18
|
+
if (absDelta <= 60 && priceMatch)
|
|
19
|
+
return 'medium';
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
function extractLocusTxStubs(report) {
|
|
23
|
+
const stubs = [];
|
|
24
|
+
for (const day of report.daily_breakdown) {
|
|
25
|
+
// Find "Locus" service in this day's breakdown
|
|
26
|
+
// The API returns service named "Locus (paywithlocus.com)"
|
|
27
|
+
const locusEntry = day.by_service.find(s => s.name.toLowerCase().includes('locus') || s.url.includes('paywithlocus'));
|
|
28
|
+
if (!locusEntry || locusEntry.txns === 0)
|
|
29
|
+
continue;
|
|
30
|
+
stubs.push({
|
|
31
|
+
date: day.date,
|
|
32
|
+
amount: locusEntry.spent / locusEntry.txns, // avg per tx
|
|
33
|
+
txns: locusEntry.txns,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return stubs;
|
|
37
|
+
}
|
|
38
|
+
/** Date string YYYY-MM-DD → approximate unix timestamp (noon UTC) */
|
|
39
|
+
function dateToNoonUtc(date) {
|
|
40
|
+
return Math.floor(new Date(`${date}T12:00:00Z`).getTime() / 1000);
|
|
41
|
+
}
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Match history entries to Locus payments
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
export function enrichLocusPayments(report) {
|
|
46
|
+
const locusService = report.by_service.find(s => s.name.toLowerCase().includes('locus') || s.url.includes('paywithlocus'));
|
|
47
|
+
if (!locusService || locusService.txns === 0) {
|
|
48
|
+
return { total_usd: 0, txns: 0, matched: [], unmatched_txns: 0, unmatched_usd: 0 };
|
|
49
|
+
}
|
|
50
|
+
const history = readLocalHistory();
|
|
51
|
+
const stubs = extractLocusTxStubs(report);
|
|
52
|
+
const matched = [];
|
|
53
|
+
let matchedTxns = 0;
|
|
54
|
+
for (const stub of stubs) {
|
|
55
|
+
const dayStart = Math.floor(new Date(`${stub.date}T00:00:00Z`).getTime() / 1000);
|
|
56
|
+
const dayEnd = Math.floor(new Date(`${stub.date}T23:59:59Z`).getTime() / 1000);
|
|
57
|
+
const noonApprox = dateToNoonUtc(stub.date);
|
|
58
|
+
// Filter history entries with known timestamps that fall within this day (±1 day buffer)
|
|
59
|
+
const dayEntries = history.filter(h => h.timestamp > 0 && h.timestamp >= dayStart - 86400 && h.timestamp <= dayEnd + 86400);
|
|
60
|
+
// Group by service for this day
|
|
61
|
+
const serviceHits = new Map();
|
|
62
|
+
for (const entry of dayEntries) {
|
|
63
|
+
const key = entry.service;
|
|
64
|
+
if (!serviceHits.has(key))
|
|
65
|
+
serviceHits.set(key, []);
|
|
66
|
+
serviceHits.get(key).push(entry);
|
|
67
|
+
}
|
|
68
|
+
// For each history service hit, try to match against stub's txns
|
|
69
|
+
let remainingTxns = stub.txns;
|
|
70
|
+
for (const [service, entries] of serviceHits) {
|
|
71
|
+
if (remainingTxns <= 0)
|
|
72
|
+
break;
|
|
73
|
+
// Find the closest-in-time entry
|
|
74
|
+
const closest = entries.reduce((best, e) => {
|
|
75
|
+
const d = Math.abs(e.timestamp - noonApprox);
|
|
76
|
+
const bd = Math.abs(best.timestamp - noonApprox);
|
|
77
|
+
return d < bd ? e : best;
|
|
78
|
+
});
|
|
79
|
+
const delta = closest.timestamp - noonApprox;
|
|
80
|
+
// Price match: within 10% (we only have avg, not per-tx price from API)
|
|
81
|
+
const priceMatch = true; // can't reliably match price from daily avg
|
|
82
|
+
const confidence = calcConfidence(delta, priceMatch);
|
|
83
|
+
if (!confidence)
|
|
84
|
+
continue;
|
|
85
|
+
const serviceUrl = new URL(closest.url).hostname;
|
|
86
|
+
matched.push({
|
|
87
|
+
timestamp: closest.timestamp,
|
|
88
|
+
amount: stub.amount,
|
|
89
|
+
service,
|
|
90
|
+
serviceUrl,
|
|
91
|
+
confidence,
|
|
92
|
+
});
|
|
93
|
+
matchedTxns++;
|
|
94
|
+
remainingTxns--;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const unmatchedTxns = locusService.txns - matchedTxns;
|
|
98
|
+
const unmatchedUsd = unmatchedTxns > 0
|
|
99
|
+
? (locusService.spent / locusService.txns) * unmatchedTxns
|
|
100
|
+
: 0;
|
|
101
|
+
return {
|
|
102
|
+
total_usd: locusService.spent,
|
|
103
|
+
txns: locusService.txns,
|
|
104
|
+
matched,
|
|
105
|
+
unmatched_txns: Math.max(0, unmatchedTxns),
|
|
106
|
+
unmatched_usd: Math.max(0, unmatchedUsd),
|
|
107
|
+
};
|
|
108
|
+
}
|
package/src/enrich.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { ReportResponse, ServiceSummary } from './api.js'
|
|
2
|
+
import { readLocalHistory, type HistoryEntry } from './history.js'
|
|
3
|
+
|
|
4
|
+
// Locus gateway address (all Locus MPP services share this)
|
|
5
|
+
const LOCUS_RECIPIENT = '0x060b0fb0be9d90557577b3aee480711067149ff0'
|
|
6
|
+
|
|
7
|
+
export type Confidence = 'high' | 'medium' | 'low'
|
|
8
|
+
|
|
9
|
+
export interface EnrichedLocusPayment {
|
|
10
|
+
timestamp: number // unix seconds (estimated from block)
|
|
11
|
+
amount: number // USDC
|
|
12
|
+
service: string // matched service name
|
|
13
|
+
serviceUrl: string // e.g. brave.mpp.paywithlocus.com
|
|
14
|
+
confidence: Confidence
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface EnrichResult {
|
|
18
|
+
total_usd: number
|
|
19
|
+
txns: number
|
|
20
|
+
matched: EnrichedLocusPayment[]
|
|
21
|
+
unmatched_txns: number
|
|
22
|
+
unmatched_usd: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Block-time → unix seconds estimate
|
|
27
|
+
// (paylog.dev returns daily_breakdown with date; we approximate block timestamp
|
|
28
|
+
// from the date. In a future version the API could return block timestamps.)
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/** Confidence rules:
|
|
32
|
+
* high: |Δt| ≤ 30s
|
|
33
|
+
* medium: |Δt| ≤ 60s AND price within 0.5% of history entry (same endpoint cost)
|
|
34
|
+
* low: price within 2% but timestamp unknown or >60s
|
|
35
|
+
*/
|
|
36
|
+
function calcConfidence(deltaSec: number, priceMatch: boolean): Confidence | null {
|
|
37
|
+
const absDelta = Math.abs(deltaSec)
|
|
38
|
+
if (absDelta <= 30) return 'high'
|
|
39
|
+
if (absDelta <= 60 && priceMatch) return 'medium'
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// In-memory "Locus payment" extraction from the report
|
|
45
|
+
// The report doesn't include per-tx timestamps yet; we use the daily_breakdown
|
|
46
|
+
// to get approximate block times, then match against history entries.
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
interface LocusTxStub {
|
|
50
|
+
date: string // YYYY-MM-DD
|
|
51
|
+
amount: number // USDC per tx (approximated as avg)
|
|
52
|
+
txns: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function extractLocusTxStubs(report: ReportResponse): LocusTxStub[] {
|
|
56
|
+
const stubs: LocusTxStub[] = []
|
|
57
|
+
for (const day of report.daily_breakdown) {
|
|
58
|
+
// Find "Locus" service in this day's breakdown
|
|
59
|
+
// The API returns service named "Locus (paywithlocus.com)"
|
|
60
|
+
const locusEntry = day.by_service.find(
|
|
61
|
+
s => s.name.toLowerCase().includes('locus') || s.url.includes('paywithlocus'),
|
|
62
|
+
)
|
|
63
|
+
if (!locusEntry || locusEntry.txns === 0) continue
|
|
64
|
+
stubs.push({
|
|
65
|
+
date: day.date,
|
|
66
|
+
amount: locusEntry.spent / locusEntry.txns, // avg per tx
|
|
67
|
+
txns: locusEntry.txns,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
return stubs
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Date string YYYY-MM-DD → approximate unix timestamp (noon UTC) */
|
|
74
|
+
function dateToNoonUtc(date: string): number {
|
|
75
|
+
return Math.floor(new Date(`${date}T12:00:00Z`).getTime() / 1000)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Match history entries to Locus payments
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
export function enrichLocusPayments(report: ReportResponse): EnrichResult {
|
|
82
|
+
const locusService = report.by_service.find(
|
|
83
|
+
s => s.name.toLowerCase().includes('locus') || s.url.includes('paywithlocus'),
|
|
84
|
+
)
|
|
85
|
+
if (!locusService || locusService.txns === 0) {
|
|
86
|
+
return { total_usd: 0, txns: 0, matched: [], unmatched_txns: 0, unmatched_usd: 0 }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const history = readLocalHistory()
|
|
90
|
+
const stubs = extractLocusTxStubs(report)
|
|
91
|
+
|
|
92
|
+
const matched: EnrichedLocusPayment[] = []
|
|
93
|
+
let matchedTxns = 0
|
|
94
|
+
|
|
95
|
+
for (const stub of stubs) {
|
|
96
|
+
const dayStart = Math.floor(new Date(`${stub.date}T00:00:00Z`).getTime() / 1000)
|
|
97
|
+
const dayEnd = Math.floor(new Date(`${stub.date}T23:59:59Z`).getTime() / 1000)
|
|
98
|
+
const noonApprox = dateToNoonUtc(stub.date)
|
|
99
|
+
|
|
100
|
+
// Filter history entries with known timestamps that fall within this day (±1 day buffer)
|
|
101
|
+
const dayEntries = history.filter(
|
|
102
|
+
h => h.timestamp > 0 && h.timestamp >= dayStart - 86400 && h.timestamp <= dayEnd + 86400,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
// Group by service for this day
|
|
106
|
+
const serviceHits = new Map<string, HistoryEntry[]>()
|
|
107
|
+
for (const entry of dayEntries) {
|
|
108
|
+
const key = entry.service
|
|
109
|
+
if (!serviceHits.has(key)) serviceHits.set(key, [])
|
|
110
|
+
serviceHits.get(key)!.push(entry)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// For each history service hit, try to match against stub's txns
|
|
114
|
+
let remainingTxns = stub.txns
|
|
115
|
+
for (const [service, entries] of serviceHits) {
|
|
116
|
+
if (remainingTxns <= 0) break
|
|
117
|
+
|
|
118
|
+
// Find the closest-in-time entry
|
|
119
|
+
const closest = entries.reduce((best, e) => {
|
|
120
|
+
const d = Math.abs(e.timestamp - noonApprox)
|
|
121
|
+
const bd = Math.abs(best.timestamp - noonApprox)
|
|
122
|
+
return d < bd ? e : best
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const delta = closest.timestamp - noonApprox
|
|
126
|
+
// Price match: within 10% (we only have avg, not per-tx price from API)
|
|
127
|
+
const priceMatch = true // can't reliably match price from daily avg
|
|
128
|
+
|
|
129
|
+
const confidence = calcConfidence(delta, priceMatch)
|
|
130
|
+
if (!confidence) continue
|
|
131
|
+
|
|
132
|
+
const serviceUrl = new URL(closest.url).hostname
|
|
133
|
+
|
|
134
|
+
matched.push({
|
|
135
|
+
timestamp: closest.timestamp,
|
|
136
|
+
amount: stub.amount,
|
|
137
|
+
service,
|
|
138
|
+
serviceUrl,
|
|
139
|
+
confidence,
|
|
140
|
+
})
|
|
141
|
+
matchedTxns++
|
|
142
|
+
remainingTxns--
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const unmatchedTxns = locusService.txns - matchedTxns
|
|
147
|
+
const unmatchedUsd = unmatchedTxns > 0
|
|
148
|
+
? (locusService.spent / locusService.txns) * unmatchedTxns
|
|
149
|
+
: 0
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
total_usd: locusService.spent,
|
|
153
|
+
txns: locusService.txns,
|
|
154
|
+
matched,
|
|
155
|
+
unmatched_txns: Math.max(0, unmatchedTxns),
|
|
156
|
+
unmatched_usd: Math.max(0, unmatchedUsd),
|
|
157
|
+
}
|
|
158
|
+
}
|
package/src/format.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// ANSI helpers
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
5
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
6
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
7
|
+
const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
8
|
+
const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
|
|
9
|
+
function usd(n) {
|
|
10
|
+
return `$${n.toFixed(3)}`;
|
|
11
|
+
}
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Report display
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
export function printReport(report, enrich) {
|
|
16
|
+
const { wallet, period, total_spent_usd, service_spent_usd, by_service, session_deposits, network_fees, other } = report;
|
|
17
|
+
console.log();
|
|
18
|
+
console.log(bold('MPP Spending Report'));
|
|
19
|
+
console.log(dim(`Wallet: ${wallet}`));
|
|
20
|
+
console.log(dim(`Period: ${period.from} → ${period.to}`));
|
|
21
|
+
console.log();
|
|
22
|
+
// Total summary
|
|
23
|
+
console.log(`${bold('Total spent')} ${bold(usd(total_spent_usd))}`);
|
|
24
|
+
if (session_deposits.txns > 0) {
|
|
25
|
+
console.log(` Session deposits ${usd(session_deposits.total_usd).padStart(8)} ${dim(`(${session_deposits.txns} txns — ${session_deposits.note})`)}`);
|
|
26
|
+
}
|
|
27
|
+
if (network_fees.txns > 0) {
|
|
28
|
+
console.log(` Network fees ${usd(network_fees.total_usd).padStart(8)} ${dim(`(${network_fees.txns} txns)`)}`);
|
|
29
|
+
}
|
|
30
|
+
console.log();
|
|
31
|
+
// By service
|
|
32
|
+
if (by_service.length > 0) {
|
|
33
|
+
console.log(bold('By service:'));
|
|
34
|
+
const maxName = Math.max(...by_service.map(s => s.name.length), 10);
|
|
35
|
+
for (const svc of by_service) {
|
|
36
|
+
const isLocus = svc.name.toLowerCase().includes('locus') || svc.url.includes('paywithlocus');
|
|
37
|
+
const namePad = svc.name.padEnd(maxName + 2);
|
|
38
|
+
const line = ` ${cyan(namePad)}${usd(svc.spent).padStart(10)} ${dim(`${svc.txns} txns`)}`;
|
|
39
|
+
console.log(line);
|
|
40
|
+
// If this is Locus and we have enrich data, show the breakdown
|
|
41
|
+
if (isLocus && enrich && (enrich.matched.length > 0 || enrich.unmatched_txns > 0)) {
|
|
42
|
+
printEnrichBreakdown(enrich);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (other.txns > 0) {
|
|
47
|
+
console.log(` ${'Other'.padEnd(20)}${usd(other.total_usd).padStart(10)} ${dim(`${other.txns} txns`)}`);
|
|
48
|
+
}
|
|
49
|
+
console.log();
|
|
50
|
+
}
|
|
51
|
+
function printEnrichBreakdown(enrich) {
|
|
52
|
+
console.log(dim(` ┌─ Locus内訳 (ローカル履歴より推定) ─────────────────`));
|
|
53
|
+
// Group matched by service
|
|
54
|
+
const byService = new Map();
|
|
55
|
+
for (const m of enrich.matched) {
|
|
56
|
+
const key = m.service;
|
|
57
|
+
const existing = byService.get(key);
|
|
58
|
+
if (existing) {
|
|
59
|
+
existing.amount += m.amount;
|
|
60
|
+
existing.count++;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
byService.set(key, { amount: m.amount, count: 1, confidence: m.confidence });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const [service, { amount, count, confidence }] of byService) {
|
|
67
|
+
const marker = confidence === 'high' ? green('✓') : confidence === 'medium' ? yellow('?') : dim('?');
|
|
68
|
+
const confLabel = confidence === 'high' ? green('高') : confidence === 'medium' ? yellow('中') : dim('低');
|
|
69
|
+
const line = ` │ ${marker} ${service.padEnd(16)} ${usd(amount).padStart(8)} ${dim(`${count}件 信頼度:`)}${confLabel}`;
|
|
70
|
+
console.log(line);
|
|
71
|
+
}
|
|
72
|
+
if (enrich.unmatched_txns > 0) {
|
|
73
|
+
console.log(dim(` │ ? 未照合 ${usd(enrich.unmatched_usd).padStart(8)} ${enrich.unmatched_txns}件`));
|
|
74
|
+
}
|
|
75
|
+
console.log(dim(` └─ ※ ローカル履歴との照合による推定 ──────────────`));
|
|
76
|
+
}
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Wallet display
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
export function printWallet(wallet, source) {
|
|
81
|
+
console.log();
|
|
82
|
+
console.log(bold('Wallet'));
|
|
83
|
+
console.log(` Address ${cyan(wallet)}`);
|
|
84
|
+
console.log(dim(` Source ${source}`));
|
|
85
|
+
console.log();
|
|
86
|
+
}
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Error display
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
export function printError(msg) {
|
|
91
|
+
console.error(`\x1b[31mError:\x1b[0m ${msg}`);
|
|
92
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { ReportResponse } from './api.js'
|
|
2
|
+
import type { EnrichResult } from './enrich.js'
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// ANSI helpers
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
const bold = (s: string) => `\x1b[1m${s}\x1b[0m`
|
|
8
|
+
const dim = (s: string) => `\x1b[2m${s}\x1b[0m`
|
|
9
|
+
const green = (s: string) => `\x1b[32m${s}\x1b[0m`
|
|
10
|
+
const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`
|
|
11
|
+
const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`
|
|
12
|
+
|
|
13
|
+
function usd(n: number): string {
|
|
14
|
+
return `$${n.toFixed(3)}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Report display
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
export function printReport(
|
|
21
|
+
report: ReportResponse,
|
|
22
|
+
enrich?: EnrichResult,
|
|
23
|
+
): void {
|
|
24
|
+
const { wallet, period, total_spent_usd, service_spent_usd, by_service,
|
|
25
|
+
session_deposits, network_fees, other } = report
|
|
26
|
+
|
|
27
|
+
console.log()
|
|
28
|
+
console.log(bold('MPP Spending Report'))
|
|
29
|
+
console.log(dim(`Wallet: ${wallet}`))
|
|
30
|
+
console.log(dim(`Period: ${period.from} → ${period.to}`))
|
|
31
|
+
console.log()
|
|
32
|
+
|
|
33
|
+
// Total summary
|
|
34
|
+
console.log(`${bold('Total spent')} ${bold(usd(total_spent_usd))}`)
|
|
35
|
+
if (session_deposits.txns > 0) {
|
|
36
|
+
console.log(` Session deposits ${usd(session_deposits.total_usd).padStart(8)} ${dim(`(${session_deposits.txns} txns — ${session_deposits.note})`)}`)
|
|
37
|
+
}
|
|
38
|
+
if (network_fees.txns > 0) {
|
|
39
|
+
console.log(` Network fees ${usd(network_fees.total_usd).padStart(8)} ${dim(`(${network_fees.txns} txns)`)}`)
|
|
40
|
+
}
|
|
41
|
+
console.log()
|
|
42
|
+
|
|
43
|
+
// By service
|
|
44
|
+
if (by_service.length > 0) {
|
|
45
|
+
console.log(bold('By service:'))
|
|
46
|
+
const maxName = Math.max(...by_service.map(s => s.name.length), 10)
|
|
47
|
+
|
|
48
|
+
for (const svc of by_service) {
|
|
49
|
+
const isLocus = svc.name.toLowerCase().includes('locus') || svc.url.includes('paywithlocus')
|
|
50
|
+
|
|
51
|
+
const namePad = svc.name.padEnd(maxName + 2)
|
|
52
|
+
const line = ` ${cyan(namePad)}${usd(svc.spent).padStart(10)} ${dim(`${svc.txns} txns`)}`
|
|
53
|
+
console.log(line)
|
|
54
|
+
|
|
55
|
+
// If this is Locus and we have enrich data, show the breakdown
|
|
56
|
+
if (isLocus && enrich && (enrich.matched.length > 0 || enrich.unmatched_txns > 0)) {
|
|
57
|
+
printEnrichBreakdown(enrich)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (other.txns > 0) {
|
|
63
|
+
console.log(` ${'Other'.padEnd(20)}${usd(other.total_usd).padStart(10)} ${dim(`${other.txns} txns`)}`)
|
|
64
|
+
}
|
|
65
|
+
console.log()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function printEnrichBreakdown(enrich: EnrichResult): void {
|
|
69
|
+
console.log(dim(` ┌─ Locus内訳 (ローカル履歴より推定) ─────────────────`))
|
|
70
|
+
|
|
71
|
+
// Group matched by service
|
|
72
|
+
const byService = new Map<string, { amount: number; count: number; confidence: string }>()
|
|
73
|
+
for (const m of enrich.matched) {
|
|
74
|
+
const key = m.service
|
|
75
|
+
const existing = byService.get(key)
|
|
76
|
+
if (existing) {
|
|
77
|
+
existing.amount += m.amount
|
|
78
|
+
existing.count++
|
|
79
|
+
} else {
|
|
80
|
+
byService.set(key, { amount: m.amount, count: 1, confidence: m.confidence })
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const [service, { amount, count, confidence }] of byService) {
|
|
85
|
+
const marker = confidence === 'high' ? green('✓') : confidence === 'medium' ? yellow('?') : dim('?')
|
|
86
|
+
const confLabel = confidence === 'high' ? green('高') : confidence === 'medium' ? yellow('中') : dim('低')
|
|
87
|
+
const line = ` │ ${marker} ${service.padEnd(16)} ${usd(amount).padStart(8)} ${dim(`${count}件 信頼度:`)}${confLabel}`
|
|
88
|
+
console.log(line)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (enrich.unmatched_txns > 0) {
|
|
92
|
+
console.log(dim(` │ ? 未照合 ${usd(enrich.unmatched_usd).padStart(8)} ${enrich.unmatched_txns}件`))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
console.log(dim(` └─ ※ ローカル履歴との照合による推定 ──────────────`))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Wallet display
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
export function printWallet(wallet: string, source: string): void {
|
|
102
|
+
console.log()
|
|
103
|
+
console.log(bold('Wallet'))
|
|
104
|
+
console.log(` Address ${cyan(wallet)}`)
|
|
105
|
+
console.log(dim(` Source ${source}`))
|
|
106
|
+
console.log()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Error display
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
export function printError(msg: string): void {
|
|
113
|
+
console.error(`\x1b[31mError:\x1b[0m ${msg}`)
|
|
114
|
+
}
|
package/src/history.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
const LOCUS_PATTERN = /paywithlocus\.com/;
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// zsh_history format: ": 1742817152:0;command"
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
function parseZshHistory(text) {
|
|
9
|
+
const entries = [];
|
|
10
|
+
for (const line of text.split('\n')) {
|
|
11
|
+
const m = line.match(/^:\s*(\d+):\d+;(.+)/);
|
|
12
|
+
if (!m)
|
|
13
|
+
continue;
|
|
14
|
+
const ts = parseInt(m[1], 10);
|
|
15
|
+
for (const url of extractLocusUrls(m[2])) {
|
|
16
|
+
entries.push({ timestamp: ts, url, service: urlToService(url), source: 'zsh' });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return entries;
|
|
20
|
+
}
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// bash_history (no timestamps by default)
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
function parseBashHistory(text) {
|
|
25
|
+
const entries = [];
|
|
26
|
+
for (const line of text.split('\n')) {
|
|
27
|
+
for (const url of extractLocusUrls(line)) {
|
|
28
|
+
entries.push({ timestamp: 0, url, service: urlToService(url), source: 'bash' });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return entries;
|
|
32
|
+
}
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// fish_history format: "- cmd: ...\n when: 1742817152"
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
function parseFishHistory(text) {
|
|
37
|
+
const entries = [];
|
|
38
|
+
for (const block of text.split(/^- cmd:/m).slice(1)) {
|
|
39
|
+
const whenMatch = block.match(/when:\s*(\d+)/);
|
|
40
|
+
const ts = whenMatch ? parseInt(whenMatch[1], 10) : 0;
|
|
41
|
+
for (const url of extractLocusUrls(block.split('\n')[0])) {
|
|
42
|
+
entries.push({ timestamp: ts, url, service: urlToService(url), source: 'fish' });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return entries;
|
|
46
|
+
}
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Claude Code ~/.claude/projects/*/chat.jsonl
|
|
49
|
+
// Each line is a JSON message object with optional timestamp field
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
function parseClaudeJsonl(text) {
|
|
52
|
+
const entries = [];
|
|
53
|
+
for (const line of text.split('\n')) {
|
|
54
|
+
if (!line.trim() || !LOCUS_PATTERN.test(line))
|
|
55
|
+
continue;
|
|
56
|
+
try {
|
|
57
|
+
const obj = JSON.parse(line);
|
|
58
|
+
const ts = obj.timestamp
|
|
59
|
+
? Math.floor(new Date(obj.timestamp).getTime() / 1000)
|
|
60
|
+
: 0;
|
|
61
|
+
for (const url of extractLocusUrls(JSON.stringify(obj))) {
|
|
62
|
+
entries.push({ timestamp: ts, url, service: urlToService(url), source: 'claude' });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch { /* malformed line */ }
|
|
66
|
+
}
|
|
67
|
+
return entries;
|
|
68
|
+
}
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Helpers
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
function extractLocusUrls(text) {
|
|
73
|
+
const results = [];
|
|
74
|
+
for (const m of text.matchAll(/https?:\/\/([a-z0-9-]+\.mpp\.paywithlocus\.com[^\s"'`>\]]*)/gi)) {
|
|
75
|
+
results.push(m[0]);
|
|
76
|
+
}
|
|
77
|
+
return results;
|
|
78
|
+
}
|
|
79
|
+
function urlToService(url) {
|
|
80
|
+
try {
|
|
81
|
+
const hostname = new URL(url).hostname; // e.g. brave.mpp.paywithlocus.com
|
|
82
|
+
const sub = hostname.split('.')[0]; // e.g. "brave"
|
|
83
|
+
return sub.charAt(0).toUpperCase() + sub.slice(1);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return 'Unknown';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function tryRead(path) {
|
|
90
|
+
if (!existsSync(path))
|
|
91
|
+
return null;
|
|
92
|
+
try {
|
|
93
|
+
return readFileSync(path, 'utf8');
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Public entry point
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
export function readLocalHistory() {
|
|
103
|
+
const home = homedir();
|
|
104
|
+
const entries = [];
|
|
105
|
+
// zsh
|
|
106
|
+
const zsh = tryRead(join(home, '.zsh_history'));
|
|
107
|
+
if (zsh)
|
|
108
|
+
entries.push(...parseZshHistory(zsh));
|
|
109
|
+
// bash
|
|
110
|
+
const bash = tryRead(join(home, '.bash_history'));
|
|
111
|
+
if (bash)
|
|
112
|
+
entries.push(...parseBashHistory(bash));
|
|
113
|
+
// fish
|
|
114
|
+
const fish = tryRead(join(home, '.local', 'share', 'fish', 'fish_history'));
|
|
115
|
+
if (fish)
|
|
116
|
+
entries.push(...parseFishHistory(fish));
|
|
117
|
+
// Claude Code chat.jsonl — scan ~/.claude/projects/*/chat.jsonl
|
|
118
|
+
const claudeProjects = join(home, '.claude', 'projects');
|
|
119
|
+
if (existsSync(claudeProjects)) {
|
|
120
|
+
try {
|
|
121
|
+
for (const projectDir of readdirSync(claudeProjects)) {
|
|
122
|
+
const jsonlPath = join(claudeProjects, projectDir, 'chat.jsonl');
|
|
123
|
+
const content = tryRead(jsonlPath);
|
|
124
|
+
if (content)
|
|
125
|
+
entries.push(...parseClaudeJsonl(content));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch { /* ignore */ }
|
|
129
|
+
}
|
|
130
|
+
return entries;
|
|
131
|
+
}
|
package/src/history.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { readFileSync, existsSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const LOCUS_PATTERN = /paywithlocus\.com/
|
|
6
|
+
|
|
7
|
+
export interface HistoryEntry {
|
|
8
|
+
timestamp: number // unix seconds (0 = unknown)
|
|
9
|
+
url: string // full matched URL
|
|
10
|
+
service: string // e.g. "Brave" from brave.mpp.paywithlocus.com
|
|
11
|
+
source: string // 'zsh' | 'bash' | 'fish' | 'claude'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// zsh_history format: ": 1742817152:0;command"
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
function parseZshHistory(text: string): HistoryEntry[] {
|
|
18
|
+
const entries: HistoryEntry[] = []
|
|
19
|
+
for (const line of text.split('\n')) {
|
|
20
|
+
const m = line.match(/^:\s*(\d+):\d+;(.+)/)
|
|
21
|
+
if (!m) continue
|
|
22
|
+
const ts = parseInt(m[1], 10)
|
|
23
|
+
for (const url of extractLocusUrls(m[2])) {
|
|
24
|
+
entries.push({ timestamp: ts, url, service: urlToService(url), source: 'zsh' })
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return entries
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// bash_history (no timestamps by default)
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
function parseBashHistory(text: string): HistoryEntry[] {
|
|
34
|
+
const entries: HistoryEntry[] = []
|
|
35
|
+
for (const line of text.split('\n')) {
|
|
36
|
+
for (const url of extractLocusUrls(line)) {
|
|
37
|
+
entries.push({ timestamp: 0, url, service: urlToService(url), source: 'bash' })
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return entries
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// fish_history format: "- cmd: ...\n when: 1742817152"
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
function parseFishHistory(text: string): HistoryEntry[] {
|
|
47
|
+
const entries: HistoryEntry[] = []
|
|
48
|
+
for (const block of text.split(/^- cmd:/m).slice(1)) {
|
|
49
|
+
const whenMatch = block.match(/when:\s*(\d+)/)
|
|
50
|
+
const ts = whenMatch ? parseInt(whenMatch[1], 10) : 0
|
|
51
|
+
for (const url of extractLocusUrls(block.split('\n')[0])) {
|
|
52
|
+
entries.push({ timestamp: ts, url, service: urlToService(url), source: 'fish' })
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return entries
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Claude Code ~/.claude/projects/*/chat.jsonl
|
|
60
|
+
// Each line is a JSON message object with optional timestamp field
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
function parseClaudeJsonl(text: string): HistoryEntry[] {
|
|
63
|
+
const entries: HistoryEntry[] = []
|
|
64
|
+
for (const line of text.split('\n')) {
|
|
65
|
+
if (!line.trim() || !LOCUS_PATTERN.test(line)) continue
|
|
66
|
+
try {
|
|
67
|
+
const obj = JSON.parse(line)
|
|
68
|
+
const ts = obj.timestamp
|
|
69
|
+
? Math.floor(new Date(obj.timestamp as string).getTime() / 1000)
|
|
70
|
+
: 0
|
|
71
|
+
for (const url of extractLocusUrls(JSON.stringify(obj))) {
|
|
72
|
+
entries.push({ timestamp: ts, url, service: urlToService(url), source: 'claude' })
|
|
73
|
+
}
|
|
74
|
+
} catch { /* malformed line */ }
|
|
75
|
+
}
|
|
76
|
+
return entries
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Helpers
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
function extractLocusUrls(text: string): string[] {
|
|
83
|
+
const results: string[] = []
|
|
84
|
+
for (const m of text.matchAll(/https?:\/\/([a-z0-9-]+\.mpp\.paywithlocus\.com[^\s"'`>\]]*)/gi)) {
|
|
85
|
+
results.push(m[0])
|
|
86
|
+
}
|
|
87
|
+
return results
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function urlToService(url: string): string {
|
|
91
|
+
try {
|
|
92
|
+
const hostname = new URL(url).hostname // e.g. brave.mpp.paywithlocus.com
|
|
93
|
+
const sub = hostname.split('.')[0] // e.g. "brave"
|
|
94
|
+
return sub.charAt(0).toUpperCase() + sub.slice(1)
|
|
95
|
+
} catch {
|
|
96
|
+
return 'Unknown'
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function tryRead(path: string): string | null {
|
|
101
|
+
if (!existsSync(path)) return null
|
|
102
|
+
try { return readFileSync(path, 'utf8') } catch { return null }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Public entry point
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
export function readLocalHistory(): HistoryEntry[] {
|
|
109
|
+
const home = homedir()
|
|
110
|
+
const entries: HistoryEntry[] = []
|
|
111
|
+
|
|
112
|
+
// zsh
|
|
113
|
+
const zsh = tryRead(join(home, '.zsh_history'))
|
|
114
|
+
if (zsh) entries.push(...parseZshHistory(zsh))
|
|
115
|
+
|
|
116
|
+
// bash
|
|
117
|
+
const bash = tryRead(join(home, '.bash_history'))
|
|
118
|
+
if (bash) entries.push(...parseBashHistory(bash))
|
|
119
|
+
|
|
120
|
+
// fish
|
|
121
|
+
const fish = tryRead(join(home, '.local', 'share', 'fish', 'fish_history'))
|
|
122
|
+
if (fish) entries.push(...parseFishHistory(fish))
|
|
123
|
+
|
|
124
|
+
// Claude Code chat.jsonl — scan ~/.claude/projects/*/chat.jsonl
|
|
125
|
+
const claudeProjects = join(home, '.claude', 'projects')
|
|
126
|
+
if (existsSync(claudeProjects)) {
|
|
127
|
+
try {
|
|
128
|
+
for (const projectDir of readdirSync(claudeProjects)) {
|
|
129
|
+
const jsonlPath = join(claudeProjects, projectDir, 'chat.jsonl')
|
|
130
|
+
const content = tryRead(jsonlPath)
|
|
131
|
+
if (content) entries.push(...parseClaudeJsonl(content))
|
|
132
|
+
}
|
|
133
|
+
} catch { /* ignore */ }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return entries
|
|
137
|
+
}
|
package/src/wallet.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
function tryReadJson(filePath, field) {
|
|
5
|
+
if (!existsSync(filePath))
|
|
6
|
+
return null;
|
|
7
|
+
try {
|
|
8
|
+
const data = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
9
|
+
const val = data[field];
|
|
10
|
+
if (typeof val === 'string' && /^0x[0-9a-fA-F]{40}$/.test(val))
|
|
11
|
+
return val.toLowerCase();
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// ignore
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
function tryReadToml(filePath) {
|
|
19
|
+
if (!existsSync(filePath))
|
|
20
|
+
return null;
|
|
21
|
+
try {
|
|
22
|
+
const text = readFileSync(filePath, 'utf8');
|
|
23
|
+
const match = text.match(/wallet_address\s*=\s*["']?(0x[0-9a-fA-F]{40})["']?/);
|
|
24
|
+
if (match)
|
|
25
|
+
return match[1].toLowerCase();
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// ignore
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
export function resolveWallet(explicit) {
|
|
33
|
+
if (explicit) {
|
|
34
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(explicit)) {
|
|
35
|
+
throw new Error(`Invalid wallet address: ${explicit}`);
|
|
36
|
+
}
|
|
37
|
+
return explicit.toLowerCase();
|
|
38
|
+
}
|
|
39
|
+
const home = homedir();
|
|
40
|
+
// 1. ~/.agentcash/wallet.json
|
|
41
|
+
const agentcash = tryReadJson(join(home, '.agentcash', 'wallet.json'), 'address');
|
|
42
|
+
if (agentcash)
|
|
43
|
+
return agentcash;
|
|
44
|
+
// 2. ~/.mppx/wallet.json
|
|
45
|
+
const mppx = tryReadJson(join(home, '.mppx', 'wallet.json'), 'address');
|
|
46
|
+
if (mppx)
|
|
47
|
+
return mppx;
|
|
48
|
+
// 3. ~/.tempo/wallet/keys.toml
|
|
49
|
+
const tempo = tryReadToml(join(home, '.tempo', 'wallet', 'keys.toml'));
|
|
50
|
+
if (tempo)
|
|
51
|
+
return tempo;
|
|
52
|
+
return null;
|
|
53
|
+
}
|
package/src/wallet.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
function tryReadJson(filePath: string, field: string): string | null {
|
|
6
|
+
if (!existsSync(filePath)) return null
|
|
7
|
+
try {
|
|
8
|
+
const data = JSON.parse(readFileSync(filePath, 'utf8'))
|
|
9
|
+
const val = data[field]
|
|
10
|
+
if (typeof val === 'string' && /^0x[0-9a-fA-F]{40}$/.test(val)) return val.toLowerCase()
|
|
11
|
+
} catch {
|
|
12
|
+
// ignore
|
|
13
|
+
}
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function tryReadToml(filePath: string): string | null {
|
|
18
|
+
if (!existsSync(filePath)) return null
|
|
19
|
+
try {
|
|
20
|
+
const text = readFileSync(filePath, 'utf8')
|
|
21
|
+
const match = text.match(/wallet_address\s*=\s*["']?(0x[0-9a-fA-F]{40})["']?/)
|
|
22
|
+
if (match) return match[1].toLowerCase()
|
|
23
|
+
} catch {
|
|
24
|
+
// ignore
|
|
25
|
+
}
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveWallet(explicit?: string): string | null {
|
|
30
|
+
if (explicit) {
|
|
31
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(explicit)) {
|
|
32
|
+
throw new Error(`Invalid wallet address: ${explicit}`)
|
|
33
|
+
}
|
|
34
|
+
return explicit.toLowerCase()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const home = homedir()
|
|
38
|
+
|
|
39
|
+
// 1. ~/.agentcash/wallet.json
|
|
40
|
+
const agentcash = tryReadJson(join(home, '.agentcash', 'wallet.json'), 'address')
|
|
41
|
+
if (agentcash) return agentcash
|
|
42
|
+
|
|
43
|
+
// 2. ~/.mppx/wallet.json
|
|
44
|
+
const mppx = tryReadJson(join(home, '.mppx', 'wallet.json'), 'address')
|
|
45
|
+
if (mppx) return mppx
|
|
46
|
+
|
|
47
|
+
// 3. ~/.tempo/wallet/keys.toml
|
|
48
|
+
const tempo = tryReadToml(join(home, '.tempo', 'wallet', 'keys.toml'))
|
|
49
|
+
if (tempo) return tempo
|
|
50
|
+
|
|
51
|
+
return null
|
|
52
|
+
}
|