Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
a53db10
refactor(knip): clear dead code and fix config false-positives
Kelbie Jun 23, 2026
28e7805
refactor(payments): break two dependency cycles via shared type modules
Kelbie Jun 23, 2026
5d84609
refactor(logger): extract value-compaction and source-location modules
Kelbie Jun 23, 2026
013ff80
refactor(logger): extract pure layout geometry from contentShiftLog
Kelbie Jun 23, 2026
fec47cc
refactor(nostrSigner): extract pure request-shape parsing from nip46E…
Kelbie Jun 23, 2026
806f583
refactor: internalize helpers used only within the extracted modules
Kelbie Jun 23, 2026
a67bb3b
refactor(payments): extract receive/mint operations from sovranPaymen…
Kelbie Jun 23, 2026
ee31eed
refactor(payments): extract scan sources from sovranPaymentConfig
Kelbie Jun 23, 2026
eae79e1
refactor(payments): extract notification handlers from sovranPaymentC…
Kelbie Jun 23, 2026
ff645c3
refactor(payments): extract screen-action handlers from sovranPayment…
Kelbie Jun 23, 2026
4c348d4
refactor(payments): rename sovranPaymentConfig -> sovranHandlers
Kelbie Jun 23, 2026
4981ae9
refactor(mint): extract testable reroute-suggestion from rebalance hook
Kelbie Jun 23, 2026
3d68602
refactor: hoist duplicated mintUrlLogFields to one canonical owner
Kelbie Jun 23, 2026
8fb3129
refactor: hoist duplicated describeImageLoadError to one owner
Kelbie Jun 23, 2026
1b8bed9
refactor(mint): remove abandoned Nostr mint-discovery subsystem
Kelbie Jun 23, 2026
c4a2ba8
refactor: trim dead public surface across feature and shared barrels
Kelbie Jun 23, 2026
7eeac15
chore(knip): make tests entry points and replace broad ignores
Kelbie Jun 23, 2026
6809564
fix(analyze-structure): count dynamic import()/require() edges
Kelbie Jun 23, 2026
e3f6f70
Fix top React Doctor findings + representative migration samples
Kelbie Jun 24, 2026
584186b
refactor: remove redundant manual memoization (React Compiler)
Kelbie Jun 24, 2026
e21c146
chore: remove dead root config files
Kelbie Jun 24, 2026
a53d384
perf: collapse map/filter chains into single pass (react-doctor js-co…
Kelbie Jun 24, 2026
41c229d
chore(lint): add high-value eslint plugins
Kelbie Jun 24, 2026
2731d47
refactor(api): typed routstr error factory, drop any
Kelbie Jun 24, 2026
2bbef28
chore(lint): scope legitimate rule exceptions out of the bulk file
Kelbie Jun 24, 2026
45428d3
refactor(theme): replace hardcoded hex colors with tokens
Kelbie Jun 24, 2026
81df401
fix(lint): place easeGradient disable directive on the import line
Kelbie Jun 24, 2026
94ce07f
refactor(types): remove explicit any outside legacy migrations
Kelbie Jun 24, 2026
4aa419c
refactor(types): replace object-literal type assertions
Kelbie Jun 24, 2026
1cac36e
feat(lint): enable high-value bad-pattern rules, retire suppressions …
Kelbie Jun 24, 2026
bad0ef7
fix(types): repair type regressions from the any/cast cleanup
Kelbie Jun 24, 2026
611e778
chore(lint): drop redundant direct eslint-plugin-import dep + dead ex…
Kelbie Jun 24, 2026
b797d47
refactor: remove 70 more redundant value memos (React Compiler)
Kelbie Jun 24, 2026
8bd4f74
refactor: drop React Compiler-redundant memos; retire react-perf rules
Kelbie Jun 24, 2026
76d9edd
refactor: hoist pure helpers to module scope (React Compiler)
Kelbie Jun 24, 2026
a3e7d91
fix(perf): restore React.memo render-bailouts the compiler can't replace
Kelbie Jun 24, 2026
aec7749
fix(perf): restore stripped gesture memos + cut hot-path log noise
Kelbie Jun 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
41 changes: 39 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,21 @@ jobs:
GITHUB_TOKEN: ${{ secrets.SOVRAN_CI_TOKEN || secrets.GITHUB_TOKEN }}
run: |
bun run deps:sovran-latest
bun install
# --ignore-scripts: the token is needed only to AUTH the
# @sovranbitcoin/* registry fetch, not to run lifecycle code, so no
# package install script executes while the secret is in scope
# (build-pipeline-secret-boundary).
bun install --ignore-scripts

# Run the deferred install scripts with NO secret in scope: trusted
# dependency scripts (e.g. @shopify/react-native-skia's install-skia) via
# `bun pm trust`, then the first-party postinstall (patch-package, bitchat
# patching, local-sibling linking). None of these need the registry token.
- name: Run install scripts (no secrets in scope)
working-directory: ${{ env.APP_DIR }}
run: |
bun pm trust --all
bun run postinstall

- name: Lint
working-directory: ${{ env.APP_DIR }}
Expand All @@ -61,6 +75,15 @@ jobs:
working-directory: ${{ env.APP_DIR }}
run: bun run knip

# Fails when any component bails out of the React Compiler. The compiler
# is the source of truth for memoization (ADR 0006); a bailout means a
# component renders unmemoized and a "redundant" manual memo we removed is
# now actually missing. The eslint-plugin-react-compiler rule that would
# catch this is dead under the repo's zod@4 override, so this is the gate.
- name: React Compiler coverage
working-directory: ${{ env.APP_DIR }}
run: bun run check:react-compiler

- name: Test
working-directory: ${{ env.APP_DIR }}
run: bun run test
Expand Down Expand Up @@ -99,7 +122,21 @@ jobs:
GITHUB_TOKEN: ${{ secrets.SOVRAN_CI_TOKEN || secrets.GITHUB_TOKEN }}
run: |
bun run deps:sovran-latest
bun install
# --ignore-scripts: the token is needed only to AUTH the
# @sovranbitcoin/* registry fetch, not to run lifecycle code, so no
# package install script executes while the secret is in scope
# (build-pipeline-secret-boundary).
bun install --ignore-scripts

# Run the deferred install scripts with NO secret in scope: trusted
# dependency scripts (e.g. @shopify/react-native-skia's install-skia) via
# `bun pm trust`, then the first-party postinstall (patch-package, bitchat
# patching, local-sibling linking). None of these need the registry token.
- name: Run install scripts (no secrets in scope)
working-directory: ${{ env.APP_DIR }}
run: |
bun pm trust --all
bun run postinstall

- name: Run analyze-structure
working-directory: ${{ env.APP_DIR }}
Expand Down
2 changes: 1 addition & 1 deletion __tests__/dmDecryptNip04.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/
import { finalizeEvent, generateSecretKey, getPublicKey, nip04 } from 'nostr-tools';
import { decryptDmEnvelopes } from '@/features/payments/data/dmDecryptPipeline';
import type { DmEnvelope } from '@/features/payments/data/dmEnvelopeClient';
import type { DmEnvelope } from '@/features/payments/data/dmEnvelopeTypes';

jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock')
Expand Down
14 changes: 12 additions & 2 deletions __tests__/facadeFeedAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ describe('resolvedFeedPageToParseResult', () => {
tier: 'relay' as const,
items: [
{ type: 'note' as const, event: event(NOTE, 200, [['q', QUOTED]]) },
{ type: 'repost' as const, repostEvent: event(REPOST, 150), originalEvent: event(ORIG, 100), originalEventId: ORIG },
{
type: 'repost' as const,
repostEvent: event(REPOST, 150),
originalEvent: event(ORIG, 100),
originalEventId: ORIG,
},
],
stats: { [NOTE]: { likes: 5, reposts: 2, replies: 1, zaps: 3, satsZapped: 2100 } },
profiles: { [PUB]: { name: 'alice', picture: 'http://x/a.png' } },
Expand All @@ -40,7 +45,12 @@ describe('resolvedFeedPageToParseResult', () => {
expect(repost.type === 'repost' && repost.originalEventId).toBe(ORIG);
expect(repost.timestamp).toBe(150);
// stats → NoteMetrics field rename
expect(result.metricsMap.get(NOTE)).toEqual({ likeCount: 5, repostCount: 2, replyCount: 1, satsZapped: 2100 });
expect(result.metricsMap.get(NOTE)).toEqual({
likeCount: 5,
repostCount: 2,
replyCount: 1,
satsZapped: 2100,
});
// profiles + cursor
expect(result.profilesMap.get(PUB)).toEqual({ name: 'alice', picture: 'http://x/a.png' });
expect(result.paginationUntil).toBe(100);
Expand Down
2 changes: 1 addition & 1 deletion __tests__/loggerChild.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ describe('logger redaction safety (audit 56.json F-001 / F-008 / F-012)', () =>
dedupWindowMs: 1000,
});
log.debug('evt');
const firstSnapshot = JSON.parse(JSON.stringify(log.getRecentLogs()));
const firstSnapshot = structuredClone(log.getRecentLogs());
log.debug('evt');
log.debug('evt');
log.debug('evt');
Expand Down
78 changes: 78 additions & 0 deletions __tests__/loggerValueCompact.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { compactValue, redactKnownSecretSubstrings } from '@/shared/lib/loggerValueCompact';

const OPTS = { maxStringLength: 120, maxArrayItems: 5, maxDepth: 4, maxObjectKeys: 15 };

describe('compactValue — structural compaction', () => {
it('passes primitives through untouched', () => {
expect(compactValue(42, OPTS)).toBe(42);
expect(compactValue(true, OPTS)).toBe(true);
expect(compactValue(null, OPTS)).toBe(null);
expect(compactValue(undefined, OPTS)).toBe(undefined);
expect(compactValue('short', OPTS)).toBe('short');
});

it('truncates long arrays and notes the remainder', () => {
const result = compactValue([1, 2, 3, 4, 5, 6, 7], OPTS) as unknown[];
expect(result.slice(0, 5)).toEqual([1, 2, 3, 4, 5]);
expect(result[5]).toBe('…2 more');
});

it('caps object keys at maxObjectKeys with a _more marker', () => {
const obj: Record<string, number> = {};
for (let i = 0; i < 20; i++) obj[`k${i}`] = i;
const result = compactValue(obj, { ...OPTS, maxObjectKeys: 3 }) as Record<string, unknown>;
expect(Object.keys(result)).toEqual(['k0', 'k1', 'k2', '…17_more']);
});

it('summarizes a Uint8Array as a buffer brand (never the bytes)', () => {
expect(compactValue(new Uint8Array([1, 2, 3, 4]), OPTS)).toEqual({ _kind: 'buffer', bytes: 4 });
});

it('summarizes Error / Date / Map / Set without leaking structure', () => {
expect(compactValue(new Date('2026-01-01T00:00:00.000Z'), OPTS)).toEqual({
_kind: 'date',
iso: '2026-01-01T00:00:00.000Z',
});
const err = compactValue(new Error('boom'), OPTS) as { _kind: string; message: string };
expect(err._kind).toBe('error');
expect(err.message).toBe('boom');
expect(compactValue(new Set([1, 2]), OPTS)).toMatchObject({ _kind: 'set', size: 2 });
expect(compactValue(new Map([['a', 1]]), OPTS)).toMatchObject({ _kind: 'map', size: 1 });
});

it('collapses objects past maxDepth instead of recursing forever', () => {
const deep = { a: { b: { c: { d: { e: 1 } } } } };
const result = compactValue(deep, { ...OPTS, maxDepth: 2 });
expect(JSON.stringify(result)).toContain('_kind');
});
});

describe('compactValue — field-name driven redaction', () => {
it('brands a value under a sensitive field name even when the value looks innocent', () => {
expect(compactValue('whatever', OPTS, 0, 'privateKey')).toEqual({
_kind: 'private_key',
len: 8,
});
expect(compactValue('seedphrase here', OPTS, 0, 'mnemonic')).toEqual({
_kind: 'secret',
len: 15,
});
expect(compactValue('Bearer abc', OPTS, 0, 'authorization')).toEqual({
_kind: 'secret',
len: 10,
});
});

it('prefers a specifically branded secret value over the field-derived kind', () => {
const nsec = 'nsec1' + 'q'.repeat(58);
expect(compactValue(nsec, OPTS, 0, 'someKey')).toMatchObject({ _kind: 'nsec' });
});
});

describe('redactKnownSecretSubstrings', () => {
it('replaces embedded secrets but leaves plain text intact', () => {
expect(redactKnownSecretSubstrings('all good here')).toBe('all good here');
const out = redactKnownSecretSubstrings(`token=${'eyJabc.eyJdef.sig'}`);
expect(out).toContain('<REDACTED:jwt>');
});
});
4 changes: 1 addition & 3 deletions __tests__/mintInfoScreenSource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,7 @@ describe('mint info screen source', () => {
});

it('uses Nostr profile data to pretty-display the first Nostr contact', () => {
expect(source).toContain(
'const contactRows = useMemo(() => getSortedMintInfoContacts(contact), [contact]);'
);
expect(source).toContain('const contactRows = getSortedMintInfoContacts(contact);');
expect(source).toContain('useNostrProfile(');
expect(source).toContain('getMintInfoNostrDisplayName(rowProfile, fallbackNpub ?? c.info)');
expect(source).toContain('picture={rowPicture}');
Expand Down
14 changes: 12 additions & 2 deletions __tests__/naggFeedClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,13 @@ describe('createNaggFeedClient (app-view REST)', () => {
mockFetch.mockResolvedValueOnce(
restResponse({
notifications: {
nodes: [{ event: note({ id: 'n1', pubkey: 'carol' }), reason: 'mention', actorVertexScore: 0.5 }],
nodes: [
{
event: note({ id: 'n1', pubkey: 'carol' }),
reason: 'mention',
actorVertexScore: 0.5,
},
],
pageInfo: { hasNextPage: false, endCursor: null },
},
metrics: { n1: emptyStats },
Expand Down Expand Up @@ -253,7 +259,11 @@ describe('createNaggFeedClient (app-view REST)', () => {
})
);

await loadClient().getFeed({ spec: JSON.stringify({ id: 'feed' }), userPubkey: 'viewer', limit: 5 });
await loadClient().getFeed({
spec: JSON.stringify({ id: 'feed' }),
userPubkey: 'viewer',
limit: 5,
});

for (const call of mockFetch.mock.calls) {
expect(String(call[0])).not.toContain('/graphql');
Expand Down
115 changes: 115 additions & 0 deletions __tests__/rebalanceRouting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* @jest-environment node
*/
import type { GetInfoResponse } from '@cashu/cashu-ts';

import { computeRouteSuggestion } from '@/features/mint/lib/rebalanceRouting';
import type { AuditMintResponse } from '@/shared/lib/apiClient';
import type { MiddlemanRoutingSettings } from '@/shared/stores/global/settingsStore';

const mockBuildSwapGraph = jest.fn();
const mockAddLocalHistoryEdges = jest.fn();
const mockGetLocalCandidates = jest.fn((..._args: unknown[]) => [] as string[]);
const mockPickIntermediaryPath = jest.fn();

jest.mock('@/features/mint/components/rebalance', () => ({
buildSwapGraph: (...args: unknown[]) => mockBuildSwapGraph(...args),
addLocalHistoryEdges: (...args: unknown[]) => mockAddLocalHistoryEdges(...args),
getLocalCandidatesForDestination: (...args: unknown[]) => mockGetLocalCandidates(...args),
pickIntermediaryPath: (...args: unknown[]) => mockPickIntermediaryPath(...args),
}));

// Variable-based casts (the repo bans object-literal type assertions).
function fakeAudit(url: string): AuditMintResponse {
const audit = { mintUrl: url };
return audit as unknown as AuditMintResponse;
}
function mintInfo(name: string): GetInfoResponse {
const info = { name };
return info as unknown as GetInfoResponse;
}
const emptyRouting = (() => {
const r = {};
return r as unknown as MiddlemanRoutingSettings;
})();
const emptyMintInfo: Record<string, GetInfoResponse | null> = {};

const baseInput = () => ({
fromMintUrl: 'https://a.mint',
toMintUrl: 'https://b.mint',
planMintUrls: ['https://a.mint', 'https://b.mint'],
trustedMintUrls: ['https://t.mint'],
mintInfoMap: emptyMintInfo,
middlemanRouting: emptyRouting,
groups: [],
fetchAudit: jest.fn(async (url: string) => fakeAudit(url)),
});

describe('computeRouteSuggestion', () => {
beforeEach(() => {
mockBuildSwapGraph.mockReset().mockReturnValue({});
mockAddLocalHistoryEdges.mockReset();
mockGetLocalCandidates.mockReset().mockReturnValue([]);
mockPickIntermediaryPath.mockReset();
});

it('returns null when no intermediary path is found', async () => {
mockPickIntermediaryPath.mockReturnValue({ path: null });
const result = await computeRouteSuggestion(baseInput());
expect(result).toBeNull();
});

it('maps a found path to display names, falling back to the url', async () => {
mockPickIntermediaryPath.mockReturnValue({ path: ['https://a.mint', 'https://x.mint'] });
const result = await computeRouteSuggestion({
...baseInput(),
mintInfoMap: { 'https://a.mint': mintInfo('Alpha') },
});
expect(result).toEqual({
path: ['https://a.mint', 'https://x.mint'],
pathNames: ['Alpha', 'https://x.mint'],
});
});

it('dedupes candidates and audits each via the injected port', async () => {
mockGetLocalCandidates.mockReturnValue(['https://local.mint', 'https://a.mint']);
mockPickIntermediaryPath.mockReturnValue({ path: null });
const input = baseInput();
await computeRouteSuggestion(input);
const auditedUrls = input.fetchAudit.mock.calls.map((c) => c[0]);
// 'a' appears in both plan and local candidates — must be audited once.
expect(new Set(auditedUrls).size).toBe(auditedUrls.length);
expect(auditedUrls).toEqual(
expect.arrayContaining([
'https://a.mint',
'https://b.mint',
'https://t.mint',
'https://local.mint',
])
);
});

it('passes only successful audits to the graph builder', async () => {
mockPickIntermediaryPath.mockReturnValue({ path: null });
const input = {
...baseInput(),
fetchAudit: jest.fn(async (url: string) =>
url === 'https://b.mint' ? null : fakeAudit(url)
),
};
await computeRouteSuggestion(input);
const audits = mockBuildSwapGraph.mock.calls[0][0] as { mintUrl: string }[];
expect(audits.some((a) => a.mintUrl === 'https://b.mint')).toBe(false);
expect(audits.length).toBeGreaterThan(0);
});

it('caps the candidate set at 12 audits', async () => {
mockGetLocalCandidates.mockReturnValue(
Array.from({ length: 30 }, (_, i) => `https://m${i}.mint`)
);
mockPickIntermediaryPath.mockReturnValue({ path: null });
const input = baseInput();
await computeRouteSuggestion(input);
expect(input.fetchAudit.mock.calls.length).toBeLessThanOrEqual(12);
});
});
6 changes: 2 additions & 4 deletions __tests__/sovranPaymentConfig.profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@

import type { PaymentMachine } from '@sovranbitcoin/colada';
import type { Manager } from '@cashu/coco-core';
import {
createSovranHandlers,
createSovranNotifications,
} from '@/features/send/lib/sovranPaymentConfig';
import { createSovranHandlers } from '@/features/send/lib/sovranHandlers';
import { createSovranNotifications } from '@/features/send/lib/sovranNotifications';
import { sendMemoPopup } from '@/shared/lib/popup';
import { getEncodedToken } from '@cashu/cashu-ts';
import { sendBLEPrivateMessageWhole } from '@/features/bitchat/lib/blePrivateDelivery';
Expand Down
10 changes: 8 additions & 2 deletions __tests__/threadItems.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,16 @@ describe('thread item builders', () => {
it('initial fetch keeps the seeded reply order as a stable prefix (no reshuffle)', () => {
const root = note({ id: 'root', content: 'root', tags: [], createdAt: 100 });
const seededReply = note({
id: 'seeded-reply', content: 'seeded', tags: [['e', 'root', '', 'reply']], createdAt: 101,
id: 'seeded-reply',
content: 'seeded',
tags: [['e', 'root', '', 'reply']],
createdAt: 101,
});
const rankedFirst = note({
id: 'ranked-first', content: 'ranked higher by the server', tags: [['e', 'root', '', 'reply']], createdAt: 102,
id: 'ranked-first',
content: 'ranked higher by the server',
tags: [['e', 'root', '', 'reply']],
createdAt: 102,
});
const result: ThreadResult = {
allEvents: mapEvents([root, seededReply, rankedFirst]),
Expand Down
2 changes: 0 additions & 2 deletions app-env.d.ts

This file was deleted.

Loading
Loading