@profullstack/coinpay 0.27.0 → 0.28.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/CHANGELOG.md +26 -0
- package/bin/coinpay.js +37 -2
- package/package.json +1 -1
- package/src/finances-commands.js +31 -5
- package/src/finances-reports.d.ts +14 -0
- package/src/mcp.js +30 -0
- package/src/wallet.js +31 -24
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,32 @@ 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.28.0] - 2026-10-29
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `coinpay finances goals edit <id>` changes any field of a goal (`--name`, `--kind`, `--target`,
|
|
12
|
+
`--deadline`, `--start`, `--start-amount n|none`, `--notes "..."|none`) and which accounts or wallets
|
|
13
|
+
count (`--account <id>,<id>` replaces the list, `--account none` unlinks all).
|
|
14
|
+
- `coinpay finances goals unarchive <id>`. Archived goals are hidden from `goals list` (unless
|
|
15
|
+
`--archived`) and left out of the report and the goals email.
|
|
16
|
+
- `goals show` prints what the progress is made of: every linked account with its amount, flagged when
|
|
17
|
+
its balance is over a week old. `FinanceGoal.sources` / `GoalSource` in the types.
|
|
18
|
+
- MCP: `finance_goal_update` (edit, relink, archive/unarchive) and `finance_goal_delete`.
|
|
19
|
+
|
|
20
|
+
## [0.27.1] - 2026-10-28
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- `coinpay wallet create`, `wallet import` and wallet sign-in failed on every call with
|
|
24
|
+
`400 INVALID_SIGNATURE`. The proof of ownership was signed over the raw message bytes; the server
|
|
25
|
+
(noble-curves v2) verifies a signature over sha256(message). `signProofMessage` (now exported) signs
|
|
26
|
+
that digest, so an agent can make its own non-custodial CoinPay wallet from a shell again.
|
|
27
|
+
- `WalletClient.authenticate()` POSTed to `/web-wallet/auth/challenge`, which only serves GET
|
|
28
|
+
(`?wallet_id=`); the 405 surfaced as "Unexpected end of JSON input".
|
|
29
|
+
- Wallet API errors printed as `Error: [object Object]`; they now show the server's message and code.
|
|
30
|
+
- `coinpay wallet addresses` was a stub. It now derives the receiving addresses locally from the
|
|
31
|
+
encrypted seed (`--chains SOL,USDC_POL,ETH`, `--index`, `--json`), ready to paste into a marketplace
|
|
32
|
+
payout wallet (e.g. `PUT https://ugig.net/api/profile/wallet-addresses`).
|
|
33
|
+
|
|
8
34
|
## [0.27.0] - 2026-10-28
|
|
9
35
|
|
|
10
36
|
### 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,
|
|
@@ -2831,8 +2833,41 @@ async function handleWallet(subcommand, args, flags) {
|
|
|
2831
2833
|
return;
|
|
2832
2834
|
}
|
|
2833
2835
|
|
|
2834
|
-
|
|
2835
|
-
|
|
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
|
+
}
|
|
2836
2871
|
break;
|
|
2837
2872
|
}
|
|
2838
2873
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/coinpay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
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",
|
package/src/finances-commands.js
CHANGED
|
@@ -968,7 +968,7 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
968
968
|
case 'goals':
|
|
969
969
|
case 'goal': {
|
|
970
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]';
|
|
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]|edit <id> [--name ..] [--target ..] [--deadline ..] [--account <id>,<id>|none] ...|sources|archive <id>|unarchive <id>|delete <id> --yes]';
|
|
972
972
|
const money = (cur, n) => new Intl.NumberFormat('en-US', { style: 'currency', currency: cur || 'USD', maximumFractionDigits: 2 }).format(n);
|
|
973
973
|
const STATUS = { achieved: 'achieved', on_track: 'on track', behind: 'BEHIND', overdue: 'PAST DEADLINE' };
|
|
974
974
|
const line = (g) => {
|
|
@@ -1000,6 +1000,10 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
1000
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
1001
|
s.recentPerMonth !== null ? ` Last 90 days: ${money(g.currency, s.recentPerMonth)}/month${s.projectedDate ? `, lands ${s.projectedDate}` : ''}` : null,
|
|
1002
1002
|
data.contributions.length ? ` Contributions: ${data.contributions.slice(0, 10).map((c) => `${c.occurredOn} ${money(g.currency, c.amount)}`).join(', ')}` : null,
|
|
1003
|
+
g.archivedAt ? ` Archived ${g.archivedAt.slice(0, 10)} (unarchive: coinpay finances goals unarchive ${g.id.slice(0, 8)})` : null,
|
|
1004
|
+
...(g.sources?.length
|
|
1005
|
+
? [' Made of:', ...g.sources.map((src) => ` ${(src.counted ? money(g.currency, src.amount) : 'not counted').padStart(12)} ${src.institution && src.type === 'account' ? `${src.institution} · ` : ''}${src.name}${src.id ? ` [${src.id.slice(0, 8)}]` : ''}${src.stale && src.asOf ? ` (balance from ${src.asOf.slice(0, 10)})` : ''}`)]
|
|
1006
|
+
: []),
|
|
1003
1007
|
].filter(Boolean).join('\n');
|
|
1004
1008
|
emit(data, text);
|
|
1005
1009
|
return EXIT.OK;
|
|
@@ -1040,7 +1044,7 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
1040
1044
|
const data = await api.getGoalSources(client);
|
|
1041
1045
|
emit(data, [
|
|
1042
1046
|
'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)}` : ''}`),
|
|
1047
|
+
...data.accounts.map((a) => ` ${a.id} ${(a.institution ? `${a.institution} · ` : '') + a.name}${a.liability ? ' (debt)' : ''}${a.hidden ? ' (hidden)' : ''}${a.balance !== null ? ` ${money(a.currency, a.balance)}` : ''}`),
|
|
1044
1048
|
'Wallets:',
|
|
1045
1049
|
...(data.wallets.length ? data.wallets.map((w) => ` ${w.walletId} ${w.label ?? ''}`) : [' none']),
|
|
1046
1050
|
].join('\n'));
|
|
@@ -1070,9 +1074,31 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
1070
1074
|
}
|
|
1071
1075
|
throw new CliExit(EXIT.INVALID, usage);
|
|
1072
1076
|
}
|
|
1073
|
-
if (action === '
|
|
1074
|
-
const
|
|
1075
|
-
|
|
1077
|
+
if (action === 'edit' || action === 'update') {
|
|
1078
|
+
const id = await resolveId(args[1]);
|
|
1079
|
+
const idsFlag = (v) => (v === 'none' ? [] : listFlag(v));
|
|
1080
|
+
const patch = {
|
|
1081
|
+
name: typeof flags.name === 'string' ? flags.name : undefined,
|
|
1082
|
+
kind: typeof flags.kind === 'string' ? flags.kind : undefined,
|
|
1083
|
+
targetAmount: flags.target !== undefined ? String(flags.target) : undefined,
|
|
1084
|
+
deadline: typeof flags.deadline === 'string' ? flags.deadline : undefined,
|
|
1085
|
+
startDate: typeof flags.start === 'string' ? flags.start : undefined,
|
|
1086
|
+
currency: typeof flags.currency === 'string' ? flags.currency : undefined,
|
|
1087
|
+
startingAmount: flags['start-amount'] === 'none' ? null : flags['start-amount'] !== undefined ? String(flags['start-amount']) : undefined,
|
|
1088
|
+
linkedAccountIds: flags.account ? idsFlag(flags.account) : undefined,
|
|
1089
|
+
linkedWalletIds: flags.wallet ? idsFlag(flags.wallet) : undefined,
|
|
1090
|
+
notes: flags.notes === 'none' ? null : typeof flags.notes === 'string' ? flags.notes : undefined,
|
|
1091
|
+
};
|
|
1092
|
+
for (const k of Object.keys(patch)) if (patch[k] === undefined) delete patch[k];
|
|
1093
|
+
if (Object.keys(patch).length === 0) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances goals edit <id> [--name ..] [--kind ..] [--target ..] [--deadline ..] [--start YYYY-MM-DD] [--start-amount n|none] [--account <id>,<id>|none] [--wallet <id>|none] [--notes "..."|none]');
|
|
1094
|
+
const g = await api.updateGoal(client, id, patch);
|
|
1095
|
+
emit({ goal: g }, `Saved ${g.name}\n${g.snapshot ? line(g) : ''}`);
|
|
1096
|
+
return EXIT.OK;
|
|
1097
|
+
}
|
|
1098
|
+
if (action === 'archive' || action === 'unarchive') {
|
|
1099
|
+
const archived = action === 'archive';
|
|
1100
|
+
const g = await api.updateGoal(client, await resolveId(args[1]), { archived });
|
|
1101
|
+
emit({ goal: g }, archived ? `Archived ${g.name}. It is hidden from goals list and left out of reports (list --archived shows it).` : `${g.name} is active again.`);
|
|
1076
1102
|
return EXIT.OK;
|
|
1077
1103
|
}
|
|
1078
1104
|
if (action === 'delete') {
|
|
@@ -494,8 +494,22 @@ export interface FinanceGoal {
|
|
|
494
494
|
linkedAccountIds: string[];
|
|
495
495
|
linkedWalletIds: string[];
|
|
496
496
|
notes: string | null;
|
|
497
|
+
archivedAt: string | null;
|
|
497
498
|
direction: 'save' | 'payoff';
|
|
498
499
|
snapshot: GoalSnapshot;
|
|
500
|
+
/** What progress is made of; counted amounts sum to `snapshot.progress`. */
|
|
501
|
+
sources: GoalSource[];
|
|
502
|
+
}
|
|
503
|
+
export interface GoalSource {
|
|
504
|
+
type: 'account' | 'wallets' | 'contributions' | 'starting';
|
|
505
|
+
id: string | null;
|
|
506
|
+
name: string;
|
|
507
|
+
institution: string | null;
|
|
508
|
+
kind: string | null;
|
|
509
|
+
amount: number;
|
|
510
|
+
asOf: string | null;
|
|
511
|
+
stale: boolean;
|
|
512
|
+
counted: boolean;
|
|
499
513
|
}
|
|
500
514
|
export interface GoalInput {
|
|
501
515
|
name: string;
|
package/src/mcp.js
CHANGED
|
@@ -107,6 +107,36 @@ export const TOOLS = [
|
|
|
107
107
|
},
|
|
108
108
|
handler: async (args, { client }) => text({ goal: await reports.createGoal(client, args) }),
|
|
109
109
|
},
|
|
110
|
+
{
|
|
111
|
+
name: 'finance_goal_update',
|
|
112
|
+
description: 'Edit a goal (any field), choose which linked accounts/wallets count (the full list replaces the old one; [] unlinks all), or archive/unarchive it. Archived goals are hidden from finance_goals unless archived=true and left out of reports and the goals email. Each goal\'s `sources` lists what its progress is made of.',
|
|
113
|
+
inputSchema: {
|
|
114
|
+
type: 'object',
|
|
115
|
+
properties: {
|
|
116
|
+
goalId: { type: 'string' },
|
|
117
|
+
name: { type: 'string' },
|
|
118
|
+
kind: { type: 'string', enum: ['house', 'car', 'emergency_fund', 'debt_payoff', 'custom'] },
|
|
119
|
+
targetAmount: { type: ['number', 'string'] },
|
|
120
|
+
deadline: { type: 'string', description: 'YYYY-MM-DD' },
|
|
121
|
+
startDate: { type: 'string', description: 'YYYY-MM-DD' },
|
|
122
|
+
currency: { type: 'string' },
|
|
123
|
+
startingAmount: { type: ['number', 'string', 'null'] },
|
|
124
|
+
linkedAccountIds: { type: 'array', items: { type: 'string' } },
|
|
125
|
+
linkedWalletIds: { type: 'array', items: { type: 'string' } },
|
|
126
|
+
notes: { type: ['string', 'null'] },
|
|
127
|
+
archived: { type: 'boolean' },
|
|
128
|
+
},
|
|
129
|
+
required: ['goalId'],
|
|
130
|
+
additionalProperties: false,
|
|
131
|
+
},
|
|
132
|
+
handler: async ({ goalId, ...patch }, { client }) => text({ goal: await reports.updateGoal(client, goalId, patch) }),
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: 'finance_goal_delete',
|
|
136
|
+
description: 'Delete a goal and its recorded contributions for good. Prefer finance_goal_update with archived=true to keep it.',
|
|
137
|
+
inputSchema: { type: 'object', properties: { goalId: { type: 'string' } }, required: ['goalId'], additionalProperties: false },
|
|
138
|
+
handler: async (args, { client }) => text(await reports.deleteGoal(client, args.goalId)),
|
|
139
|
+
},
|
|
110
140
|
{
|
|
111
141
|
name: 'finance_goal_contribute',
|
|
112
142
|
description: 'Record an amount put toward a goal, or a payment on a payoff goal (negative = withdrawal). Records only; moves no money.',
|
package/src/wallet.js
CHANGED
|
@@ -403,34 +403,36 @@ function getSecp256k1PublicKey(seed) {
|
|
|
403
403
|
}
|
|
404
404
|
|
|
405
405
|
/**
|
|
406
|
-
* Sign a message with secp256k1
|
|
407
|
-
*
|
|
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
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
|