@profullstack/coinpay 0.26.0 → 0.27.1

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/CHANGELOG.md CHANGED
@@ -5,6 +5,38 @@ All notable changes to `@profullstack/coinpay` will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.27.1] - 2026-10-28
9
+
10
+ ### Fixed
11
+ - `coinpay wallet create`, `wallet import` and wallet sign-in failed on every call with
12
+ `400 INVALID_SIGNATURE`. The proof of ownership was signed over the raw message bytes; the server
13
+ (noble-curves v2) verifies a signature over sha256(message). `signProofMessage` (now exported) signs
14
+ that digest, so an agent can make its own non-custodial CoinPay wallet from a shell again.
15
+ - `WalletClient.authenticate()` POSTed to `/web-wallet/auth/challenge`, which only serves GET
16
+ (`?wallet_id=`); the 405 surfaced as "Unexpected end of JSON input".
17
+ - Wallet API errors printed as `Error: [object Object]`; they now show the server's message and code.
18
+ - `coinpay wallet addresses` was a stub. It now derives the receiving addresses locally from the
19
+ encrypted seed (`--chains SOL,USDC_POL,ETH`, `--index`, `--json`), ready to paste into a marketplace
20
+ payout wallet (e.g. `PUT https://ugig.net/api/profile/wallet-addresses`).
21
+
22
+ ## [0.27.0] - 2026-10-28
23
+
24
+ ### Added
25
+ - Finance goals: save for a house or a car, build an emergency fund, or pay off a debt, by a deadline.
26
+ `coinpay finances goals create --name "Down payment" --kind house --target 60000 --deadline 2031-06-30`
27
+ (`--account <id>` / `--wallet <id>` link real balances; `--start-amount` is what is already saved, or
28
+ the debt at the start of a payoff goal); `goals list|show|contribute <id> <amount>|sources|archive|delete`.
29
+ Each goal says on track, behind, achieved or past deadline, and what it takes per day/week/month/quarter
30
+ to finish on time.
31
+ - `coinpay finances goals report --period daily|weekly|monthly|quarterly`: each goal's change over the
32
+ trailing period, what the period needed to stay on schedule, and the pace.
33
+ - `coinpay finances goals digest on --cadence monthly`: the opt-in goals report email (daily, weekly,
34
+ monthly on the 1st, quarterly on Jan/Apr/Jul/Oct 1st); `digest show|off|send-now`.
35
+ - SDK `listGoals`, `getGoal`, `createGoal`, `updateGoal`, `deleteGoal`, `addGoalContribution`,
36
+ `getGoalSources`, `getGoalsReport`, `getGoalsDigest`, `setGoalsDigest`, `deleteGoalsDigest`,
37
+ `sendGoalsDigestNow`; MCP `finance_goals`, `finance_goal_create`, `finance_goal_contribute`,
38
+ `finance_goals_report`, `finance_goals_digest`. Not financial advice; nothing moves money.
39
+
8
40
  ## [0.26.0] - 2026-10-28
9
41
 
10
42
  ### Added
package/bin/coinpay.js CHANGED
@@ -13,7 +13,9 @@ import {
13
13
  generateMnemonic,
14
14
  validateMnemonic,
15
15
  DEFAULT_CHAINS,
16
+ deriveAddress,
16
17
  } from '../src/wallet.js';
18
+ import { mnemonicToSeedSync } from '@scure/bip39';
17
19
  import { SwapClient, SwapCoins } from '../src/swap.js';
18
20
  import {
19
21
  createInvoice,
@@ -728,6 +730,10 @@ ${colors.cyan}Commands:${colors.reset}
728
730
  toCoinPayCosts rows (--source opendevice, --header "K: V" for a URL)
729
731
  due-day <id> <1-31|clear> A card/loan's statement due day (--amount 810.46, or balance)
730
732
  daily [show|on|off|send-now] Daily summary email: spend vs target, top cuts, due soon, position (--hour 7 --to …, --target 100)
733
+ goals [list|show|create|contribute|report|digest|sources|archive|delete]
734
+ Savings and payoff goals (house, car, emergency fund, debt) by a deadline;
735
+ progress from linked accounts/wallets or recorded contributions.
736
+ report --period daily|weekly|monthly|quarterly · digest on --cadence monthly
731
737
 
732
738
  ${colors.bright}escrow${colors.reset}
733
739
  create Create a new escrow
@@ -2827,8 +2833,41 @@ async function handleWallet(subcommand, args, flags) {
2827
2833
  return;
2828
2834
  }
2829
2835
 
2830
- print.info(`Wallet: ${config.walletId}`);
2831
- print.info('Note: Full address list requires API authentication.');
2836
+ // Derived locally from the encrypted seed: nothing leaves this machine.
2837
+ // These are the receiving addresses to hand a marketplace (ugig.net
2838
+ // profile payout wallets, invoices, bounties) so it pays you directly.
2839
+ if (!hasEncryptedWallet(flags)) {
2840
+ print.info(`Wallet: ${config.walletId}`);
2841
+ print.error('No encrypted wallet file found, so no addresses can be derived here.');
2842
+ return;
2843
+ }
2844
+ try {
2845
+ const mnemonic = await getDecryptedMnemonic(flags);
2846
+ const seed = mnemonicToSeedSync(mnemonic.trim());
2847
+ clearString(mnemonic);
2848
+ const chains = (flags.chains ? String(flags.chains).split(',') : DEFAULT_CHAINS)
2849
+ .map((c) => c.trim().toUpperCase())
2850
+ .filter(Boolean);
2851
+ const index = parseInt(flags.index || '0', 10);
2852
+ const addresses = [];
2853
+ for (const chain of chains) {
2854
+ try {
2855
+ addresses.push({ chain, index, address: deriveAddress(seed, chain, index) });
2856
+ } catch (err) {
2857
+ addresses.push({ chain, index, error: err.message });
2858
+ }
2859
+ }
2860
+ if (flags.json) {
2861
+ print.json({ walletId: config.walletId, addresses });
2862
+ } else {
2863
+ print.info(`Wallet: ${config.walletId}`);
2864
+ for (const a of addresses) {
2865
+ console.log(` ${a.chain.padEnd(9)} ${a.address || a.error}`);
2866
+ }
2867
+ }
2868
+ } catch (error) {
2869
+ print.error(error.message);
2870
+ }
2832
2871
  break;
2833
2872
  }
2834
2873
 
@@ -5258,7 +5297,9 @@ async function handleFinances(subcommand, args, flags) {
5258
5297
  case 'recurring':
5259
5298
  case 'due-day':
5260
5299
  case 'daily':
5261
- case 'costs': {
5300
+ case 'costs':
5301
+ case 'goals':
5302
+ case 'goal': {
5262
5303
  const { client } = financesClient();
5263
5304
  const { runFinancesCommand } = await import('../src/finances-commands.js');
5264
5305
  const code = await runFinancesCommand(subcommand, args, flags, {
@@ -5272,7 +5313,7 @@ async function handleFinances(subcommand, args, flags) {
5272
5313
 
5273
5314
  default:
5274
5315
  print.error(`Unknown finances command: ${subcommand}`);
5275
- console.log('Usage: coinpay finances [tui|summary|accounts|ledger|connections|sync|connect|disconnect|backfill|jobs|coverage|report|reports|statements|books|pnl|payloads|digest|due|recurring|due-day|daily|costs]');
5316
+ console.log('Usage: coinpay finances [tui|summary|accounts|ledger|connections|sync|connect|disconnect|backfill|jobs|coverage|report|reports|statements|books|pnl|payloads|digest|due|recurring|due-day|daily|costs|goals]');
5276
5317
  process.exit(1);
5277
5318
  }
5278
5319
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/coinpay",
3
- "version": "0.26.0",
3
+ "version": "0.27.1",
4
4
  "description": "CoinPay SDK & CLI — Accept cryptocurrency and card payments (BTC, ETH, SOL, POL, BCH, USDC) with wallet, swap and a live finances dashboard",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -965,6 +965,126 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
965
965
  throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances costs [list [--source s]|import <file|url> [--source opendevice] [--header "K: V"]|clear --source s --yes]');
966
966
  }
967
967
 
968
+ case 'goals':
969
+ case 'goal': {
970
+ const action = args[0] || 'list';
971
+ const usage = 'Usage: coinpay finances goals [list|show <id>|create --name "Down payment" --kind house|car|emergency_fund|debt_payoff|custom --target 60000 --deadline 2031-06-30 [--start-amount 5000] [--account <id>,<id>] [--wallet <id>]|contribute <id> <amount> [--date 2026-10-01] [--note "..."]|report [--period daily|weekly|monthly|quarterly]|digest [show|on --cadence monthly [--hour 8]|off|send-now]|sources|archive <id>|delete <id> --yes]';
972
+ const money = (cur, n) => new Intl.NumberFormat('en-US', { style: 'currency', currency: cur || 'USD', maximumFractionDigits: 2 }).format(n);
973
+ const STATUS = { achieved: 'achieved', on_track: 'on track', behind: 'BEHIND', overdue: 'PAST DEADLINE' };
974
+ const line = (g) => {
975
+ const s = g.snapshot;
976
+ const pace = s.status === 'achieved' ? '' : ` · ${money(g.currency, s.required.perMonth)}/mo to finish`;
977
+ return ` ${g.id.slice(0, 8)} ${g.name.padEnd(24).slice(0, 24)} ${money(g.currency, s.progress).padStart(12)} / ${money(g.currency, g.targetAmount).padEnd(12)} ${String(s.percent).padStart(5)}% ${STATUS[s.status].padEnd(13)} by ${g.deadline}${pace}`;
978
+ };
979
+ const resolveId = async (prefix) => {
980
+ if (!prefix) throw new CliExit(EXIT.INVALID, usage);
981
+ if (/^[0-9a-f-]{36}$/i.test(prefix)) return prefix;
982
+ const { goals } = await api.listGoals(client, { archived: true });
983
+ const hits = goals.filter((g) => g.id.startsWith(prefix));
984
+ if (hits.length !== 1) throw new CliExit(EXIT.INVALID, hits.length ? `"${prefix}" matches ${hits.length} goals; give more of the id` : `No goal ${prefix}`);
985
+ return hits[0].id;
986
+ };
987
+ if (action === 'list') {
988
+ const data = await api.listGoals(client, { archived: Boolean(flags.archived) });
989
+ emit(data, data.goals.length ? [`${data.goals.length} goal(s)`, ...data.goals.map(line), '', data.disclaimer].join('\n') : 'No goals yet. Create one: coinpay finances goals create --name "Down payment" --kind house --target 60000 --deadline 2031-06-30');
990
+ return EXIT.OK;
991
+ }
992
+ if (action === 'show') {
993
+ const data = await api.getGoal(client, await resolveId(args[1]));
994
+ const g = data.goal;
995
+ const s = g.snapshot;
996
+ const text = [
997
+ `${g.name} (${g.kind}) · ${STATUS[s.status]}`,
998
+ ` ${money(g.currency, s.progress)} of ${money(g.currency, g.targetAmount)} (${s.percent}%) · started ${g.startDate} · deadline ${g.deadline} (${s.daysLeft} days)`,
999
+ ` Expected by now: ${money(g.currency, s.expectedByNow)} · remaining ${money(g.currency, s.remaining)}`,
1000
+ ` To finish on time: ${money(g.currency, s.required.perDay)}/day · ${money(g.currency, s.required.perWeek)}/week · ${money(g.currency, s.required.perMonth)}/month · ${money(g.currency, s.required.perQuarter)}/quarter`,
1001
+ s.recentPerMonth !== null ? ` Last 90 days: ${money(g.currency, s.recentPerMonth)}/month${s.projectedDate ? `, lands ${s.projectedDate}` : ''}` : null,
1002
+ data.contributions.length ? ` Contributions: ${data.contributions.slice(0, 10).map((c) => `${c.occurredOn} ${money(g.currency, c.amount)}`).join(', ')}` : null,
1003
+ ].filter(Boolean).join('\n');
1004
+ emit(data, text);
1005
+ return EXIT.OK;
1006
+ }
1007
+ if (action === 'create' || action === 'add') {
1008
+ const goal = {
1009
+ name: typeof flags.name === 'string' ? flags.name : args.slice(1).join(' '),
1010
+ kind: typeof flags.kind === 'string' ? flags.kind : 'custom',
1011
+ targetAmount: flags.target !== undefined ? String(flags.target) : undefined,
1012
+ deadline: typeof flags.deadline === 'string' ? flags.deadline : undefined,
1013
+ currency: typeof flags.currency === 'string' ? flags.currency : undefined,
1014
+ startingAmount: flags['start-amount'] !== undefined ? String(flags['start-amount']) : undefined,
1015
+ linkedAccountIds: flags.account ? listFlag(flags.account) : undefined,
1016
+ linkedWalletIds: flags.wallet ? listFlag(flags.wallet) : undefined,
1017
+ notes: typeof flags.notes === 'string' ? flags.notes : undefined,
1018
+ };
1019
+ if (!goal.name || !goal.deadline) throw new CliExit(EXIT.INVALID, usage);
1020
+ const g = await api.createGoal(client, goal);
1021
+ emit({ goal: g }, `Created ${g.id}\n${g.snapshot ? line(g) : ''}`);
1022
+ return EXIT.OK;
1023
+ }
1024
+ if (action === 'contribute' || action === 'pay') {
1025
+ const id = await resolveId(args[1]);
1026
+ if (args[2] === undefined) throw new CliExit(EXIT.INVALID, usage);
1027
+ const c = await api.addGoalContribution(client, id, { amount: args[2], occurredOn: typeof flags.date === 'string' ? flags.date : undefined, note: typeof flags.note === 'string' ? flags.note : undefined });
1028
+ emit({ contribution: c }, `Recorded ${c.amount} on ${c.occurredOn}. (Recorded only; nothing was moved.)`);
1029
+ return EXIT.OK;
1030
+ }
1031
+ if (action === 'report') {
1032
+ const period = typeof flags.period === 'string' ? flags.period : args[1] || 'weekly';
1033
+ if (!['daily', 'weekly', 'monthly', 'quarterly'].includes(period)) throw new CliExit(EXIT.INVALID, '--period must be daily, weekly, monthly or quarterly');
1034
+ const data = await api.getGoalsReport(client, { period });
1035
+ if (json) { out(JSON.stringify(data.report, null, 2)); return EXIT.OK; }
1036
+ out(data.text);
1037
+ return EXIT.OK;
1038
+ }
1039
+ if (action === 'sources') {
1040
+ const data = await api.getGoalSources(client);
1041
+ emit(data, [
1042
+ 'Accounts:',
1043
+ ...data.accounts.map((a) => ` ${a.id} ${(a.institution ? `${a.institution} · ` : '') + a.name}${a.liability ? ' (debt)' : ''}${a.balance !== null ? ` ${money(a.currency, a.balance)}` : ''}`),
1044
+ 'Wallets:',
1045
+ ...(data.wallets.length ? data.wallets.map((w) => ` ${w.walletId} ${w.label ?? ''}`) : [' none']),
1046
+ ].join('\n'));
1047
+ return EXIT.OK;
1048
+ }
1049
+ if (action === 'digest') {
1050
+ const sub = args[1] || 'show';
1051
+ const describe = (s) => s
1052
+ ? `Goals report ${s.active ? 'on' : 'off'} · ${s.cadence ?? 'weekly'} at ${String(s.hour).padStart(2, '0')}:00 ${s.timezone} · to ${s.recipients.join(', ')}${s.nextRunAt ? ` · next ${s.nextRunAt}` : ''}`
1053
+ : 'Goals report email is off. Turn it on: coinpay finances goals digest on --cadence monthly';
1054
+ if (sub === 'show') { const s = await api.getGoalsDigest(client); emit({ schedule: s }, describe(s)); return EXIT.OK; }
1055
+ if (sub === 'on') {
1056
+ const s = await api.setGoalsDigest(client, {
1057
+ cadence: typeof flags.cadence === 'string' ? flags.cadence : undefined,
1058
+ hour: flags.hour !== undefined ? Number(flags.hour) : undefined,
1059
+ timezone: typeof flags.timezone === 'string' ? flags.timezone : Intl.DateTimeFormat().resolvedOptions().timeZone,
1060
+ recipients: flags.to ? listFlag(flags.to) : undefined,
1061
+ });
1062
+ emit({ schedule: s }, describe(s));
1063
+ return EXIT.OK;
1064
+ }
1065
+ if (sub === 'off') { const d = await api.deleteGoalsDigest(client); emit(d, 'Goals report email turned off.'); return EXIT.OK; }
1066
+ if (sub === 'send-now') {
1067
+ const d = await api.sendGoalsDigestNow(client);
1068
+ emit(d, `Sent the ${d.period} goals report to ${d.sent.join(', ') || 'nobody'}${d.failed.length ? `; failed: ${d.failed.map((f) => `${f.to} (${f.error})`).join(', ')}` : ''}`);
1069
+ return d.sent.length > 0 ? EXIT.OK : EXIT.FAILURE;
1070
+ }
1071
+ throw new CliExit(EXIT.INVALID, usage);
1072
+ }
1073
+ if (action === 'archive') {
1074
+ const g = await api.updateGoal(client, await resolveId(args[1]), { archived: true });
1075
+ emit({ goal: g }, `Archived ${g.name}.`);
1076
+ return EXIT.OK;
1077
+ }
1078
+ if (action === 'delete') {
1079
+ const id = await resolveId(args[1]);
1080
+ if (!flags.yes) throw new CliExit(EXIT.INVALID, 'This deletes the goal and its recorded contributions. Re-run with --yes to confirm.');
1081
+ const d = await api.deleteGoal(client, id);
1082
+ emit(d, 'Goal deleted.');
1083
+ return EXIT.OK;
1084
+ }
1085
+ throw new CliExit(EXIT.INVALID, usage);
1086
+ }
1087
+
968
1088
  case 'due-day': {
969
1089
  const id = args[0];
970
1090
  const value = args[1];
@@ -1056,4 +1176,4 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
1056
1176
  }
1057
1177
  }
1058
1178
 
1059
- export const EXTENDED_SUBCOMMANDS = ['connect', 'disconnect', 'consent', 'backfill', 'jobs', 'coverage', 'report', 'reports', 'statements', 'books', 'pnl', 'profit-loss', 'payloads', 'digest', 'due', 'due-soon', 'recurring', 'due-day', 'daily', 'costs'];
1179
+ export const EXTENDED_SUBCOMMANDS = ['connect', 'disconnect', 'consent', 'backfill', 'jobs', 'coverage', 'report', 'reports', 'statements', 'books', 'pnl', 'profit-loss', 'payloads', 'digest', 'due', 'due-soon', 'recurring', 'due-day', 'daily', 'costs', 'goals', 'goal'];
@@ -465,3 +465,59 @@ export function setDailySummary(
465
465
  export function deleteDailySummary(client: CoinPayClient): Promise<{ success: boolean }>;
466
466
  export function sendDailySummaryNow(client: CoinPayClient): Promise<{ sent: string[]; failed: Array<{ to: string; error: string }>; due: number; overdue: number }>;
467
467
  export function setAccountDueDay(client: CoinPayClient, accountId: string, opts: { day: number | null; amount?: number | string | null }): Promise<Record<string, unknown>>;
468
+
469
+ export type GoalKind = 'house' | 'car' | 'emergency_fund' | 'debt_payoff' | 'custom';
470
+ export type GoalPeriod = 'daily' | 'weekly' | 'monthly' | 'quarterly';
471
+ export type GoalStatus = 'achieved' | 'on_track' | 'behind' | 'overdue';
472
+ export interface GoalSnapshot {
473
+ progress: number;
474
+ remaining: number;
475
+ percent: number;
476
+ expectedByNow: number;
477
+ status: GoalStatus;
478
+ daysLeft: number;
479
+ required: { perDay: number; perWeek: number; perMonth: number; perQuarter: number };
480
+ recentPerMonth: number | null;
481
+ projectedDate: string | null;
482
+ exact: boolean;
483
+ }
484
+ export interface FinanceGoal {
485
+ id: string;
486
+ name: string;
487
+ kind: GoalKind;
488
+ targetAmount: number;
489
+ currency: string;
490
+ startDate: string;
491
+ deadline: string;
492
+ startingAmount: number | null;
493
+ startProgress: number;
494
+ linkedAccountIds: string[];
495
+ linkedWalletIds: string[];
496
+ notes: string | null;
497
+ direction: 'save' | 'payoff';
498
+ snapshot: GoalSnapshot;
499
+ }
500
+ export interface GoalInput {
501
+ name: string;
502
+ kind: GoalKind;
503
+ targetAmount?: number | string;
504
+ deadline: string;
505
+ currency?: string;
506
+ startDate?: string;
507
+ startingAmount?: number | string | null;
508
+ linkedAccountIds?: string[];
509
+ linkedWalletIds?: string[];
510
+ notes?: string | null;
511
+ }
512
+ export function listGoals(client: CoinPayClient, opts?: { archived?: boolean }): Promise<{ goals: FinanceGoal[]; timezone: string; disclaimer: string }>;
513
+ export function getGoal(client: CoinPayClient, id: string): Promise<{ goal: FinanceGoal; contributions: Array<{ id: string; amount: number; occurredOn: string; note: string | null }> }>;
514
+ export function createGoal(client: CoinPayClient, goal: GoalInput): Promise<FinanceGoal>;
515
+ export function updateGoal(client: CoinPayClient, id: string, patch: Partial<GoalInput> & { archived?: boolean; startProgress?: number }): Promise<FinanceGoal>;
516
+ export function deleteGoal(client: CoinPayClient, id: string): Promise<{ success: boolean }>;
517
+ export function addGoalContribution(client: CoinPayClient, id: string, c: { amount: number | string; occurredOn?: string; note?: string }): Promise<{ id: string; amount: number; occurredOn: string; note: string | null }>;
518
+ export function getGoalSources(client: CoinPayClient): Promise<{ accounts: Array<{ id: string; name: string; institution: string | null; liability: boolean; currency: string; balance: number | null }>; wallets: Array<{ walletId: string; label: string | null }> }>;
519
+ export function getGoalsReport(client: CoinPayClient, opts?: { period?: GoalPeriod }): Promise<{ report: Record<string, unknown>; text: string }>;
520
+ export function getGoalsDigest(client: CoinPayClient): Promise<(WeeklyDigest & { cadence?: GoalPeriod }) | null>;
521
+ export function setGoalsDigest(client: CoinPayClient, opts?: { cadence?: GoalPeriod; hour?: number; timezone?: string; recipients?: string[]; active?: boolean }): Promise<WeeklyDigest & { cadence?: GoalPeriod }>;
522
+ export function deleteGoalsDigest(client: CoinPayClient): Promise<{ success: boolean }>;
523
+ export function sendGoalsDigestNow(client: CoinPayClient): Promise<{ sent: string[]; failed: Array<{ to: string; error: string }>; goals: number; period: GoalPeriod }>;
@@ -530,3 +530,72 @@ export async function setAccountDueDay(client, accountId, { day, amount } = {})
530
530
  const data = await call(client, `/finances/accounts/${encodeURIComponent(accountId)}`, { method: 'PATCH', body: JSON.stringify(body) });
531
531
  return data.account;
532
532
  }
533
+
534
+ /**
535
+ * Finance goals: saving toward something or paying something off, by a
536
+ * deadline. Progress follows linked finance accounts and web wallets, or
537
+ * contributions recorded by hand. Records only; nothing moves money.
538
+ */
539
+ export async function listGoals(client, { archived = false } = {}) {
540
+ return call(client, `/finances/goals${archived ? '?archived=1' : ''}`);
541
+ }
542
+
543
+ export async function getGoal(client, id) {
544
+ return call(client, `/finances/goals/${encodeURIComponent(id)}`);
545
+ }
546
+
547
+ /** `{ name, kind, targetAmount, deadline, currency?, startDate?, startingAmount?, linkedAccountIds?, linkedWalletIds?, notes? }` */
548
+ export async function createGoal(client, goal) {
549
+ const data = await call(client, '/finances/goals', { method: 'POST', body: JSON.stringify(goal) });
550
+ return data.goal;
551
+ }
552
+
553
+ export async function updateGoal(client, id, patch) {
554
+ const data = await call(client, `/finances/goals/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(patch) });
555
+ return data.goal;
556
+ }
557
+
558
+ export async function deleteGoal(client, id) {
559
+ return call(client, `/finances/goals/${encodeURIComponent(id)}`, { method: 'DELETE' });
560
+ }
561
+
562
+ /** Record an amount toward a goal (negative = withdrawal). `occurredOn` defaults to today. */
563
+ export async function addGoalContribution(client, id, { amount, occurredOn, note } = {}) {
564
+ const data = await call(client, `/finances/goals/${encodeURIComponent(id)}/contributions`, {
565
+ method: 'POST',
566
+ body: JSON.stringify({ amount, occurredOn, note }),
567
+ });
568
+ return data.contribution;
569
+ }
570
+
571
+ /** What a goal can be linked to: finance accounts and CoinPay web wallets. */
572
+ export async function getGoalSources(client) {
573
+ return call(client, '/finances/goals/sources');
574
+ }
575
+
576
+ /** The daily, weekly, monthly or quarterly goals report: `{ report, text }`. */
577
+ export async function getGoalsReport(client, { period = 'weekly' } = {}) {
578
+ return call(client, `/finances/goals/report?period=${encodeURIComponent(period)}`);
579
+ }
580
+
581
+ export async function getGoalsDigest(client) {
582
+ const data = await call(client, '/finances/email-schedules?kind=goals_digest');
583
+ return data.schedule;
584
+ }
585
+
586
+ /** Turn the goals report email on at a cadence (daily, weekly, monthly, quarterly). */
587
+ export async function setGoalsDigest(client, { cadence, hour, timezone, recipients, active = true } = {}) {
588
+ const data = await call(client, '/finances/email-schedules', {
589
+ method: 'POST',
590
+ body: JSON.stringify({ kind: 'goals_digest', cadence, hour, timezone, recipients, active }),
591
+ });
592
+ return data.schedule;
593
+ }
594
+
595
+ export async function deleteGoalsDigest(client) {
596
+ return call(client, '/finances/email-schedules?kind=goals_digest', { method: 'DELETE' });
597
+ }
598
+
599
+ export async function sendGoalsDigestNow(client) {
600
+ return call(client, '/finances/email-schedules/send-now?kind=goals_digest', { method: 'POST', body: '{}' });
601
+ }
package/src/mcp.js CHANGED
@@ -79,6 +79,77 @@ export const TOOLS = [
79
79
  return text(await reports.importDeclaredCosts(client, body, { source: args.source || 'opendevice' }));
80
80
  },
81
81
  },
82
+ {
83
+ name: 'finance_goals',
84
+ description: 'Savings and payoff goals (buy a house or car, emergency fund, pay off debt): each with progress, percent, on track/behind/achieved/past deadline, and what it takes per day/week/month/quarter to finish by the deadline. Not financial advice.',
85
+ inputSchema: { type: 'object', properties: { archived: { type: 'boolean', description: 'Include archived goals' } }, additionalProperties: false },
86
+ handler: async (args, { client }) => text(await reports.listGoals(client, { archived: args.archived === true })),
87
+ },
88
+ {
89
+ name: 'finance_goal_create',
90
+ description: 'Create a goal. kind: house, car, emergency_fund, debt_payoff or custom. Link finance account ids (finance_accounts; debt accounts for a payoff goal) or web wallet ids so progress follows real balances; otherwise progress is startingAmount plus recorded contributions. A payoff goal linked to debt accounts with no startingAmount starts at what they owe now.',
91
+ inputSchema: {
92
+ type: 'object',
93
+ properties: {
94
+ name: { type: 'string' },
95
+ kind: { type: 'string', enum: ['house', 'car', 'emergency_fund', 'debt_payoff', 'custom'] },
96
+ targetAmount: { type: ['number', 'string'] },
97
+ deadline: { type: 'string', description: 'YYYY-MM-DD' },
98
+ currency: { type: 'string', description: 'Default USD' },
99
+ startDate: { type: 'string', description: 'YYYY-MM-DD, default today' },
100
+ startingAmount: { type: ['number', 'string'], description: 'Already saved (save goal) or the debt at the start (payoff goal)' },
101
+ linkedAccountIds: { type: 'array', items: { type: 'string' } },
102
+ linkedWalletIds: { type: 'array', items: { type: 'string' } },
103
+ notes: { type: 'string' },
104
+ },
105
+ required: ['name', 'kind', 'deadline'],
106
+ additionalProperties: false,
107
+ },
108
+ handler: async (args, { client }) => text({ goal: await reports.createGoal(client, args) }),
109
+ },
110
+ {
111
+ name: 'finance_goal_contribute',
112
+ description: 'Record an amount put toward a goal, or a payment on a payoff goal (negative = withdrawal). Records only; moves no money.',
113
+ inputSchema: {
114
+ type: 'object',
115
+ properties: {
116
+ goalId: { type: 'string' },
117
+ amount: { type: ['number', 'string'] },
118
+ occurredOn: { type: 'string', description: 'YYYY-MM-DD, default today' },
119
+ note: { type: 'string' },
120
+ },
121
+ required: ['goalId', 'amount'],
122
+ additionalProperties: false,
123
+ },
124
+ handler: async (args, { client }) => text({ contribution: await reports.addGoalContribution(client, args.goalId, { amount: args.amount, occurredOn: args.occurredOn, note: args.note }) }),
125
+ },
126
+ {
127
+ name: 'finance_goals_report',
128
+ description: 'The goals progress report for the trailing day, week, month or quarter: each goal\'s change in the period, what the period needed to stay on schedule, pace (ahead/on pace/short), and overall status.',
129
+ inputSchema: { type: 'object', properties: { period: { type: 'string', enum: ['daily', 'weekly', 'monthly', 'quarterly'] } }, additionalProperties: false },
130
+ handler: async (args, { client }) => text((await reports.getGoalsReport(client, { period: args.period || 'weekly' })).text),
131
+ },
132
+ {
133
+ name: 'finance_goals_digest',
134
+ description: 'The opt-in goals report email: show it, turn it on at a cadence (daily, weekly, monthly, quarterly; optional hour 0-23 and recipients), turn it off, or send one now.',
135
+ inputSchema: {
136
+ type: 'object',
137
+ properties: {
138
+ action: { type: 'string', enum: ['show', 'on', 'off', 'send_now'] },
139
+ cadence: { type: 'string', enum: ['daily', 'weekly', 'monthly', 'quarterly'] },
140
+ hour: { type: 'integer', minimum: 0, maximum: 23 },
141
+ to: { type: 'array', items: { type: 'string' }, maxItems: 5 },
142
+ },
143
+ required: ['action'],
144
+ additionalProperties: false,
145
+ },
146
+ handler: async (args, { client }) => {
147
+ if (args.action === 'on') return text({ schedule: await reports.setGoalsDigest(client, { cadence: args.cadence, hour: args.hour, recipients: args.to }) });
148
+ if (args.action === 'off') return text(await reports.deleteGoalsDigest(client));
149
+ if (args.action === 'send_now') return text(await reports.sendGoalsDigestNow(client));
150
+ return text({ schedule: await reports.getGoalsDigest(client) });
151
+ },
152
+ },
82
153
  {
83
154
  name: 'finance_summary',
84
155
  description: 'Balance sheet (assets, debt, net by currency), cashflow for the window, top spending categories, and the debt-and-income position.',
package/src/wallet.js CHANGED
@@ -403,34 +403,36 @@ function getSecp256k1PublicKey(seed) {
403
403
  }
404
404
 
405
405
  /**
406
- * Sign a message with secp256k1 private key
407
- * @private
406
+ * Sign a proof-of-ownership / auth-challenge message with a secp256k1 key,
407
+ * the way the CoinPay server verifies it.
408
+ *
409
+ * The server (src/lib/web-wallet/auth.ts::verifySecp256k1Signature) runs
410
+ * noble-curves v2, whose `verify(sig, message, pub)` sha256-hashes the message
411
+ * first (prehash defaults to true). This SDK pins noble-curves v1, whose
412
+ * `sign(x, key)` treats `x` as an already-hashed digest. So the SDK must hash
413
+ * explicitly: sign(sha256(message), { prehash: false }) is byte-for-byte what
414
+ * the server checks, on either major version.
415
+ *
416
+ * History: 0.x signed sha256(message) and was right while the server ran v1
417
+ * (raw verify); a later "fix" switched to raw bytes; the server then moved to
418
+ * v2 and every `wallet create`, `wallet import` and wallet sign-in from the
419
+ * SDK/CLI failed with 400 INVALID_SIGNATURE (2026-10).
408
420
  */
409
- function signMessage(message, privateKey) {
410
- // Signed over the RAW message bytes, not sha256(message).
411
- //
412
- // This used to hash first. The server verifies proofs by passing the raw
413
- // encoded message straight to `secp256k1.verify` (see
414
- // web-wallet/auth.ts::verifySecp256k1Signature), and noble treats that
415
- // argument as the thing that was signed — so a signature over the digest
416
- // verifies false against the message. Every proof of ownership this SDK
417
- // produced was rejected, which meant `WalletClient.fromSeed()` could not
418
- // import a wallet at all. Found while wiring the same proof into
419
- // `create()` for V-04.
420
- const signature = secp256k1.sign(new TextEncoder().encode(message), privateKey);
421
- // Handle different noble-curves versions:
422
- // v1.x returns Signature object with toCompactHex()
423
- // v2.x returns raw Uint8Array directly
421
+ export function signProofMessage(message, privateKey) {
422
+ const digest = sha256(new TextEncoder().encode(message));
423
+ const signature = secp256k1.sign(digest, privateKey, { prehash: false });
424
+ // v1.x returns a Signature object, v2.x a compact Uint8Array.
424
425
  if (signature instanceof Uint8Array) {
425
426
  return bytesToHex(signature);
426
427
  }
427
428
  if (typeof signature.toCompactHex === 'function') {
428
429
  return signature.toCompactHex();
429
430
  }
430
- // Fallback: try toCompactRawBytes
431
431
  return bytesToHex(signature.toCompactRawBytes());
432
432
  }
433
433
 
434
+ const signMessage = signProofMessage;
435
+
434
436
  // ================================================================
435
437
  // Arbitrary tx / message signing
436
438
  // ----------------------------------------------------------------
@@ -795,9 +797,13 @@ export class WalletClient {
795
797
  const data = await response.json();
796
798
 
797
799
  if (!response.ok) {
798
- const error = new Error(data.error || `HTTP ${response.status}`);
800
+ // Web-wallet routes answer { error: { code, message } }; older ones a string.
801
+ const apiError = data && typeof data.error === 'object' && data.error ? data.error : null;
802
+ const error = new Error(
803
+ (apiError ? apiError.message || apiError.code : data.error) || `HTTP ${response.status}`
804
+ );
799
805
  error.status = response.status;
800
- error.code = data.code;
806
+ error.code = apiError?.code ?? data.code;
801
807
  error.response = data;
802
808
  throw error;
803
809
  }
@@ -823,10 +829,11 @@ export class WalletClient {
823
829
  }
824
830
 
825
831
  // Get challenge
826
- const challengeRes = await this.#request(`/web-wallet/auth/challenge`, {
827
- method: 'POST',
828
- body: JSON.stringify({ wallet_id: this.#walletId }),
829
- });
832
+ // The server serves challenges on GET ?wallet_id= (a POST is a 405 with
833
+ // an empty body, which surfaced as "Unexpected end of JSON input").
834
+ const challengeRes = await this.#request(
835
+ `/web-wallet/auth/challenge?wallet_id=${encodeURIComponent(this.#walletId)}`
836
+ );
830
837
  const challenge = challengeRes.data?.challenge || challengeRes.challenge;
831
838
  const challengeId = challengeRes.data?.challenge_id || challengeRes.challenge_id;
832
839