earnrate 0.2.15 → 0.2.17

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.
Files changed (54) hide show
  1. package/README.md +14 -8
  2. package/data/CHANGELOG.md +14 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-business-platinum.yaml +15 -1
  5. package/data/cards/barclays-jetblue-plus.yaml +19 -1
  6. package/data/cards/chase-freedom-flex.yaml +7 -7
  7. package/data/cards/chase-ihg-one-rewards-business.yaml +19 -3
  8. package/data/cards/chase-ihg-one-rewards-premier-select.yaml +5 -1
  9. package/data/cards/chase-ihg-one-rewards-premier.yaml +5 -1
  10. package/data/cards/chase-ihg-one-rewards.yaml +13 -2
  11. package/data/cards/chase-ink-business-cash.yaml +5 -1
  12. package/data/cards/chase-ink-business-unlimited.yaml +5 -1
  13. package/data/cards/chase-prime-visa.yaml +3 -3
  14. package/data/cards/chase-sapphire-reserve.yaml +12 -3
  15. package/data/cards/chase-united-business.yaml +5 -1
  16. package/data/cards/chase-united-club-business.yaml +5 -1
  17. package/data/cards/chase-united-club.yaml +5 -1
  18. package/data/cards/chase-united-explorer.yaml +5 -1
  19. package/data/cards/chase-united-quest.yaml +5 -1
  20. package/data/cards/citi-strata-premier.yaml +14 -1
  21. package/data/cards/wells-fargo-autograph-journey.yaml +16 -1
  22. package/data/merchants/overrides.yaml +3 -3
  23. package/dist/cli/args.js +36 -1
  24. package/dist/cli/run.js +14 -1
  25. package/dist/cli/wallet.js +18 -8
  26. package/dist/data/integrity.js +13 -0
  27. package/dist/data/wallet-edit.js +5 -0
  28. package/dist/engine/cheat-sheet.js +1 -1
  29. package/dist/engine/checklist.js +26 -6
  30. package/dist/engine/credits.js +56 -7
  31. package/dist/engine/evaluate.js +21 -1
  32. package/dist/engine/explore.js +32 -10
  33. package/dist/engine/memberships.js +22 -10
  34. package/dist/engine/milestones.js +11 -3
  35. package/dist/engine/offers.js +9 -4
  36. package/dist/engine/recommend.js +21 -2
  37. package/dist/engine/rule-text.js +20 -7
  38. package/dist/engine/setup.js +7 -6
  39. package/dist/engine/simulate.js +43 -10
  40. package/dist/engine/text.js +46 -5
  41. package/dist/engine/wallet-change.js +18 -7
  42. package/dist/engine/wallet-summary.js +45 -24
  43. package/dist/format/card-text.js +6 -12
  44. package/dist/format/explore.js +12 -8
  45. package/dist/format/offers.js +1 -1
  46. package/dist/format/wallet.js +2 -0
  47. package/dist/mcp/inputs.js +17 -1
  48. package/dist/mcp/schemas.js +24 -0
  49. package/dist/mcp/tools/account.js +50 -42
  50. package/dist/mcp/tools/wallet.js +207 -150
  51. package/dist/mcp/wallet-api.js +78 -0
  52. package/dist/schema/card.js +11 -0
  53. package/dist/schema/wallet.js +6 -2
  54. package/package.json +1 -1
@@ -1,9 +1,13 @@
1
1
  import { membershipName, membershipNote, } from "../engine/memberships.js";
2
- import { capitalize, choiceName, CREDIT_GROUP, CREDITS_LEFT_OUT, ENROLL_FIRST, hostname, feeText, dollarsAndCents, pct, wholeDollars, words, listed, } from "../engine/text.js";
2
+ import { capitalize, choiceName, CREDIT_GROUP, CREDITS_APART, ENROLL_FIRST, HELD_CREDITS_COUNTED, hostname, feeText, dollarsAndCents, pct, wholeDollars, words, listed, } from "../engine/text.js";
3
3
  const membershipFees = (memberships) => memberships
4
4
  .map((m) => ` + ${wholeDollars(m.annualFee)} for ${m.name}`)
5
5
  .join('');
6
6
  const TOP_BEST = 10;
7
+ // " · $X if you'd use all its credits ($424 a year)"
8
+ const ifCredits = (credits, net, sign = '', its = 'its') => credits > 0
9
+ ? ` · ${sign}${wholeDollars(net)} if you'd use all ${its} credits (${wholeDollars(credits)} a year)`
10
+ : '';
7
11
  export function formatExploration(r, catalog, all) {
8
12
  const m = r.merchant;
9
13
  const title = m.source === 'category'
@@ -83,15 +87,15 @@ export function formatCatalogSimulation(r, catalog, all) {
83
87
  const card = s.cards[0];
84
88
  if (!card)
85
89
  return;
86
- lines.push(`${String(i + 1).padStart(2)}. ${name(card.cardId)} ${wholeDollars(s.net)} (${wholeDollars(s.rewards)} rewards, ${wholeDollars(s.annualFees)} fee${membershipFees(s.memberships)})${picksText(card.picks)}`);
90
+ lines.push(`${String(i + 1).padStart(2)}. ${name(card.cardId)} ${wholeDollars(s.net)} (${wholeDollars(s.rewards)} rewards, ${wholeDollars(s.annualFees)} fee${membershipFees(s.memberships)})${ifCredits(s.otherCredits, s.netIfCreditsUsed)}${picksText(card.picks)}`);
87
91
  });
88
92
  const pairs = r.pairs.slice(0, top);
89
93
  if (pairs.length > 0) {
90
94
  lines.push('', 'Best two-card pairs:');
91
95
  pairs.forEach((s, i) => {
92
- lines.push(`${String(i + 1).padStart(2)}. ${s.cards.map((c) => `${name(c.cardId)}${picksText(c.picks)}`).join(' + ')} ${wholeDollars(s.net)}`);
96
+ lines.push(`${String(i + 1).padStart(2)}. ${s.cards.map((c) => `${name(c.cardId)}${picksText(c.picks)}`).join(' + ')} ${wholeDollars(s.net)}${ifCredits(s.otherCredits, s.netIfCreditsUsed, '', 'their')}`);
93
97
  if (s.alternatives.length > 0)
94
- lines.push(` Same result with ${s.alternatives.map(name).join(' or ')} as the second card.`);
98
+ lines.push(` Same result with ${listed(s.alternatives.map(name), 'or')} as the second card.`);
95
99
  for (const split of splitText(s, name))
96
100
  lines.push(` ${split}`);
97
101
  });
@@ -105,13 +109,13 @@ export function formatCatalogSimulation(r, catalog, all) {
105
109
  ]);
106
110
  if (notes.size > 0)
107
111
  lines.push('', ...notes);
108
- lines.push('', `Assumes each card's best picks, activations done each quarter, and no welcome bonuses. ${CREDITS_LEFT_OUT}`);
112
+ lines.push('', `Assumes each card's best picks, activations done each quarter, and no welcome bonuses. ${CREDITS_APART}`);
109
113
  return `${lines.join('\n')}\n`;
110
114
  }
111
115
  export function formatAdditions(r, catalog, all) {
112
116
  const name = cardNamer(catalog);
113
117
  const lines = [
114
- `Your wallet earns about ${wholeDollars(r.current.rewards)} a year on this spending${r.current.annualFees > 0 ? `, with ${wholeDollars(r.current.annualFees)} in annual fees: ${wholeDollars(r.current.net)} after fees` : ''}.`,
118
+ `Your wallet earns about ${wholeDollars(r.current.rewards)} a year on this spending${r.current.annualFees > 0 ? `, with ${wholeDollars(r.current.annualFees)} in annual fees${r.current.heldCredits > 0 ? ` and ${wholeDollars(r.current.heldCredits)} of credits you use` : ''}: ${wholeDollars(r.current.net)} after fees` : ''}.`,
115
119
  ];
116
120
  for (const split of splitText(r.current, name))
117
121
  lines.push(` ${split}`);
@@ -122,7 +126,7 @@ export function formatAdditions(r, catalog, all) {
122
126
  else {
123
127
  lines.push('', 'Cards that would add the most (after their annual fee and any membership they need):');
124
128
  gains.slice(0, all ? 20 : TOP_SIMULATED).forEach((a, i) => {
125
- lines.push(`${String(i + 1).padStart(2)}. ${name(a.cardId)} +${wholeDollars(a.gain)} a year (+${wholeDollars(a.extraRewards)} rewards, ${wholeDollars(a.annualFee)} fee${membershipFees(a.simulation.memberships.filter((m) => m.cardIds.includes(a.cardId)))})${picksText(a.picks)}`);
129
+ lines.push(`${String(i + 1).padStart(2)}. ${name(a.cardId)} +${wholeDollars(a.gain)} a year (+${wholeDollars(a.extraRewards)} rewards, ${wholeDollars(a.annualFee)} fee${membershipFees(a.simulation.memberships.filter((m) => m.cardIds.includes(a.cardId)))})${ifCredits(a.credits, a.gainIfCreditsUsed, '+')}${picksText(a.picks)}`);
126
130
  });
127
131
  }
128
132
  const notes = new Set([
@@ -133,6 +137,6 @@ export function formatAdditions(r, catalog, all) {
133
137
  ]);
134
138
  if (notes.size > 0)
135
139
  lines.push('', ...notes);
136
- lines.push('', `Assumes your picks carry over, rotating categories get activated, and caps start fresh. Annual fees are counted in full: statement credits and perks that offset them are left out. ${CREDITS_LEFT_OUT} Each kind of spending lists the card that earns the most on it over the whole year, so a one-quarter bonus may not show.`);
140
+ lines.push('', `Assumes your picks carry over, rotating categories get activated, and caps start fresh. ${r.current.heldCredits > 0 ? `${HELD_CREDITS_COUNTED} ` : ''}${CREDITS_APART} Perks like lounge access aren't counted. Each kind of spending lists the card that earns the most on it over the whole year, so a one-quarter bonus may not show.`);
137
141
  return `${lines.join('\n')}\n`;
138
142
  }
@@ -11,7 +11,7 @@ export function formatOffers(c, currencyName, all) {
11
11
  shown.forEach((o, i) => {
12
12
  const weeks = (o.months * 52) / 12;
13
13
  const pace = o.spend > 0 ? ` (about ${wholeDollars(o.spend / weeks)} a week)` : '';
14
- lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${wholeDollars(o.firstYearValue)} the first year`, ` ${offerTerms(o, currencyName(o.currency))}${pace}. ${capitalize(feeText(o))}.${o.membershipFee > 0 ? ` Needs a ${money(o.membershipFee)} membership, counted in the first year.` : ''}${o.held ? ' In your wallet.' : ''}`);
14
+ lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${wholeDollars(o.firstYearValue)} the first year${o.credits > 0 ? `, ${wholeDollars(o.firstYearValueIfCreditsUsed)} if you'd use all its credits (${wholeDollars(o.credits)} a year)` : ''}`, ` ${offerTerms(o, currencyName(o.currency))}${pace}. ${capitalize(feeText(o))}.${o.membershipFee > 0 ? ` Needs a ${money(o.membershipFee)} membership, counted in the first year.` : ''}${o.held ? ' In your wallet.' : ''}`);
15
15
  lines.push(` ${o.validTo ? `Offer ends ${shortDate(o.validTo)}. ` : ''}Public offer as of ${shortDate(o.lastVerified)}; yours may differ.`);
16
16
  if (o.ineligible)
17
17
  lines.push(` ${o.ineligible}`);
@@ -41,6 +41,8 @@ export function formatWalletSummary(summary) {
41
41
  lines.push(` Spend bonus (${m.id}): ${m.text}`);
42
42
  for (const credit of c.credits)
43
43
  lines.push(` Credit (${credit.id}): ${credit.text}`);
44
+ if (c.usedCreditsText !== null)
45
+ lines.push(` ${c.usedCreditsText}`);
44
46
  if (c.anniversary !== null)
45
47
  lines.push(` Account anniversary: ${c.anniversary}.`);
46
48
  if (c.intro?.state === 'active' && c.intro.until !== null)
@@ -49,6 +49,18 @@ const NamedCard = z.object({
49
49
  .array(z.string())
50
50
  .optional()
51
51
  .describe('Bonuses the user has activated with the issuer, as rule ids from search_cards.'),
52
+ enrolled: z
53
+ .array(z.string())
54
+ .optional()
55
+ .describe("Statement credits the user has enrolled in with the issuer, as credit ids from search_cards. A credit that needs enrolling counts only when it's here. Ask; never assume."),
56
+ skippedCredits: z
57
+ .array(z.string())
58
+ .optional()
59
+ .describe("Statement credit ids the user says they don't use. They don't count toward a pick or against the card's annual fee."),
60
+ opened: z
61
+ .string()
62
+ .optional()
63
+ .describe('The day the account opened (YYYY-MM-DD), for a card with a first-year rate. Optional; without it the card is ranked at its standard rates.'),
52
64
  });
53
65
  const cardFields = {
54
66
  checkoutLabel: z
@@ -84,11 +96,15 @@ const cardFields = {
84
96
  enrolled: z
85
97
  .array(z.string())
86
98
  .optional()
87
- .describe('Statement credit ids the user has enrolled in with the issuer (search_cards and list_wallet list each card\'s credits, e.g. "lululemon" on Amex Platinum). A credit that needs enrolling counts in answers only once it\'s here. Replaces the current list. Ask; never assume.'),
99
+ .describe("Statement credit ids the user has enrolled in with the issuer, or, for a credit that needs no enrolling, says they use (search_cards and list_wallet list each card's credits, e.g. \"lululemon\" on Amex Platinum). A credit that needs enrolling counts in answers only once it's here, and any credit counts against the annual fee only once it's here (or has been used). Replaces the current list. Ask; never assume."),
88
100
  creditUsed: z
89
101
  .record(z.string(), z.number().nonnegative())
90
102
  .optional()
91
103
  .describe('Dollars of a statement credit already received this period: credit id → dollars, e.g. {"lululemon": 40}. Ask; recorded purchases add to it from then on.'),
104
+ skippedCredits: z
105
+ .array(z.string())
106
+ .optional()
107
+ .describe("Statement credit ids the user doesn't use, e.g. [\"dunkin\"] on Amex Gold. They're left out of picks, the to-do list and the annual-fee math; every other credit on the card counts against its fee once it's in enrolled (or, for one that needs no enrolling, has been used). Replaces the current list; [] counts them all again."),
92
108
  bonus: z
93
109
  .object({
94
110
  opened: IsoDate.describe('The day the account opened, YYYY-MM-DD.'),
@@ -179,6 +179,8 @@ export const WalletSummarySchema = z.object({
179
179
  period: CreditPeriod,
180
180
  enroll: z.boolean(),
181
181
  enrolled: z.boolean(),
182
+ skipped: z.boolean(), // the user doesn't use it: not counted anywhere
183
+ confirmed: z.boolean(), // said in use: counts against the annual fee
182
184
  currentPeriod: z.string().nullable(),
183
185
  until: z.string().nullable(),
184
186
  used: z.number(),
@@ -186,6 +188,8 @@ export const WalletSummarySchema = z.object({
186
188
  tracked: z.boolean(),
187
189
  text: z.string(),
188
190
  })),
191
+ usedCredits: z.number(), // a year, counted against the annual fee
192
+ usedCreditsText: z.string().nullable(),
189
193
  chosen: z
190
194
  .object({
191
195
  period: z.string(),
@@ -282,6 +286,10 @@ export const OfferComparisonSchema = z.object({
282
286
  ineligible: z.string().nullable(),
283
287
  bonusValue: z.number(),
284
288
  firstYearValue: z.number(),
289
+ // Statement credits, a year at face value, and the first-year value
290
+ // if all were used. Not ranked on.
291
+ credits: z.number(),
292
+ firstYearValueIfCreditsUsed: z.number(),
285
293
  held: z.boolean(),
286
294
  lastVerified: z.string(),
287
295
  verifiedBy: z.string(),
@@ -381,6 +389,7 @@ export const ExplorationSchema = z.object({
381
389
  enroll: z.boolean(),
382
390
  note: z.string().nullable(),
383
391
  back: z.string(),
392
+ terms: z.string(),
384
393
  earn: z.string(),
385
394
  text: z.string(),
386
395
  evidence: z.object({ quote: z.string(), url: z.string() }).nullable(),
@@ -413,6 +422,7 @@ export const ExplorationSchema = z.object({
413
422
  conditions: z.array(z.string()),
414
423
  evidence: z.object({ quote: z.string(), url: z.string() }).nullable(),
415
424
  withMembership: z.array(z.object({ membership: z.string(), effectiveReturnPct: z.number() })),
425
+ needsMembership: z.string().nullable(),
416
426
  })),
417
427
  excluded: z.array(ExcludedCardSchema),
418
428
  assumptions: z.array(z.string()),
@@ -430,7 +440,19 @@ const SimulationSchema = z.object({
430
440
  cardIds: z.array(z.string()),
431
441
  })),
432
442
  membershipFees: z.number(),
443
+ // Credits the user uses on cards they hold, a year: counted in net.
444
+ heldCredits: z.number(),
433
445
  net: z.number(),
446
+ // Credits on cards not held, a year at face value: not in net.
447
+ otherCredits: z.number(),
448
+ netIfCreditsUsed: z.number(),
449
+ credits: z.array(z.object({
450
+ cardId: z.string(),
451
+ yearly: z.number(),
452
+ creditIds: z.array(z.string()),
453
+ names: z.array(z.string()),
454
+ held: z.boolean(),
455
+ })),
434
456
  lines: z.array(z.object({
435
457
  label: z.string(),
436
458
  spend: z.number(),
@@ -458,6 +480,8 @@ export const SpendingSimulationSchema = z.object({
458
480
  annualFee: z.number(),
459
481
  membershipFee: z.number(),
460
482
  gain: z.number(),
483
+ credits: z.number(), // its statement credits, a year: not in gain
484
+ gainIfCreditsUsed: z.number(),
461
485
  })),
462
486
  });
463
487
  export const CheatSheetSchema = z.object({
@@ -5,22 +5,59 @@ import { listed } from "../../engine/text.js";
5
5
  import { savedSettingsText } from "../../engine/wallet-summary.js";
6
6
  import { fail } from "../context.js";
7
7
  import { WalletChangeSchema } from "../schemas.js";
8
+ const cardsText = (n) => `${String(n)} ${n === 1 ? 'card' : 'cards'}`;
9
+ // Everything a saved wallet holds, in words; empty when there's nothing.
10
+ const contentsOf = (catalog, w) => [
11
+ ...(w.cards.length > 0
12
+ ? [`${cardsText(w.cards.length)} (${w.cards.map((c) => c.id).join(', ')})`]
13
+ : []),
14
+ ...savedSettingsText(catalog, w),
15
+ ];
16
+ const ImportInput = z.object({
17
+ yaml: z
18
+ .string()
19
+ .min(1)
20
+ .max(100_000)
21
+ .describe('The wallet.yaml text, as the user gave it.'),
22
+ replace: z
23
+ .boolean()
24
+ .optional()
25
+ .describe('true to replace what the wallet already holds, after the user confirms.'),
26
+ });
27
+ export function importHandler(catalog, store, ctx) {
28
+ const contents = (w) => contentsOf(catalog, w);
29
+ return {
30
+ input: ImportInput,
31
+ run: async ({ yaml, replace }) => {
32
+ const parsed = parseWalletYaml(yaml, catalog);
33
+ if (typeof parsed === 'string')
34
+ return fail(parsed);
35
+ let current;
36
+ try {
37
+ current = await store.load();
38
+ }
39
+ catch (e) {
40
+ if (replace !== true)
41
+ return fail(`Couldn't read the saved wallet to check what it holds (${e instanceof Error ? e.message : String(e)}). Ask the user, then call again with replace: true to overwrite it.`);
42
+ current = null;
43
+ }
44
+ const before = current === null ? [] : contents(current);
45
+ if (before.length > 0 && replace !== true)
46
+ return fail(`This would replace what's saved: ${listed(before)}. Ask the user, then call again with replace: true.`);
47
+ return ctx.changed(await store.replace(parsed), before.length > 0
48
+ ? `Replaced what was saved (${listed(before)}) with the imported wallet: ${listed(contents(parsed))}.`
49
+ : `Imported a wallet with ${listed(contents(parsed)) || 'nothing in it'}.`, { notes: false });
50
+ },
51
+ };
52
+ }
8
53
  // A saved wallet on the hosted server has no file to copy or delete, so
9
54
  // these do it.
10
55
  export function registerAccountTools(server, catalog, store, ctx) {
11
- const cardsText = (n) => `${String(n)} ${n === 1 ? 'card' : 'cards'}`;
12
- // Everything a saved wallet holds, in words; empty when there's nothing.
13
- const contents = (w) => [
14
- ...(w.cards.length > 0
15
- ? [
16
- `${cardsText(w.cards.length)} (${w.cards.map((c) => c.id).join(', ')})`,
17
- ]
18
- : []),
19
- ...savedSettingsText(catalog, w),
20
- ];
56
+ const contents = (w) => contentsOf(catalog, w);
57
+ const importWallet = importHandler(catalog, store, ctx);
21
58
  server.registerTool('export_wallet', {
22
59
  title: 'Export the wallet',
23
- description: 'The saved wallet as a wallet.yaml file: card ids, checkout labels, picks, activations, opening dates and anniversaries, memberships, point values, cap usage, welcome-bonus progress, yearly spending toward spend bonuses, and statement credits enrolled in and used. earnrate.dev imports it, and the earnrate command line reads it saved as ~/.earnrate/wallet.yaml. Never card numbers.',
60
+ description: 'The saved wallet as a wallet.yaml file: card ids, checkout labels, picks, activations, opening dates and anniversaries, memberships, point values, cap usage, welcome-bonus progress, yearly spending toward spend bonuses, and statement credits enrolled in, used and not used. earnrate.dev imports it, and the earnrate command line reads it saved as ~/.earnrate/wallet.yaml. Never card numbers.',
24
61
  inputSchema: z.object({}),
25
62
  outputSchema: z.object({ yaml: z.string() }),
26
63
  annotations: {
@@ -42,17 +79,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
42
79
  server.registerTool('import_wallet', {
43
80
  title: 'Import a wallet',
44
81
  description: 'Replaces the saved wallet with a wallet.yaml from the earnrate command line or earnrate.dev, when the user pastes or attaches one. Wallet files only: never statements or card numbers. If anything is already saved (cards, memberships or point values), it refuses unless replace is true: confirm with the user first.',
45
- inputSchema: z.object({
46
- yaml: z
47
- .string()
48
- .min(1)
49
- .max(100_000)
50
- .describe('The wallet.yaml text, as the user gave it.'),
51
- replace: z
52
- .boolean()
53
- .optional()
54
- .describe('true to replace what the wallet already holds, after the user confirms.'),
55
- }),
82
+ inputSchema: importWallet.input,
56
83
  outputSchema: WalletChangeSchema,
57
84
  annotations: {
58
85
  title: 'Import a wallet',
@@ -61,26 +88,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
61
88
  idempotentHint: true,
62
89
  openWorldHint: false,
63
90
  },
64
- }, async ({ yaml, replace }) => {
65
- const parsed = parseWalletYaml(yaml, catalog);
66
- if (typeof parsed === 'string')
67
- return fail(parsed);
68
- let current;
69
- try {
70
- current = await store.load();
71
- }
72
- catch (e) {
73
- if (replace !== true)
74
- return fail(`Couldn't read the saved wallet to check what it holds (${e instanceof Error ? e.message : String(e)}). Ask the user, then call again with replace: true to overwrite it.`);
75
- current = null;
76
- }
77
- const before = current === null ? [] : contents(current);
78
- if (before.length > 0 && replace !== true)
79
- return fail(`This would replace what's saved: ${listed(before)}. Ask the user, then call again with replace: true.`);
80
- return ctx.changed(await store.replace(parsed), before.length > 0
81
- ? `Replaced what was saved (${listed(before)}) with the imported wallet: ${listed(contents(parsed))}.`
82
- : `Imported a wallet with ${listed(contents(parsed)) || 'nothing in it'}.`, { notes: false });
83
- });
91
+ }, (args) => importWallet.run(args));
84
92
  server.registerTool('delete_wallet', {
85
93
  title: 'Delete the wallet',
86
94
  description: "Deletes the saved wallet and its recovery code now and for good, and signs out every app connected to it, this one included. earnrate.dev and the earnrate command line keep their own wallets and aren't affected. Only when the user asks to delete it: show it with list_wallet, offer export_wallet, and pass confirm, cards and saved only after the user confirms. It refuses if the wallet no longer holds what list_wallet showed, so a retried call can't delete a different wallet.",