mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
fix: drain accepted NWC work before wallet lock
This commit is contained in:
@@ -0,0 +1,149 @@
|
|||||||
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
|
import { buildNoteUrl, fetchNoteInfo } from 'lnurlcash-kit';
|
||||||
|
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { NWC_RESPONSE_KIND, createConnection, readNwcConnections } from '@/lnurlcash/nwc';
|
||||||
|
import type { NwcTransport } from '@/lnurlcash/nwc';
|
||||||
|
import { stubLocalStorage } from '@/lnurlcash/test-utils';
|
||||||
|
import {
|
||||||
|
CLIENT_SECRET,
|
||||||
|
RELAYS,
|
||||||
|
createFakeRelay,
|
||||||
|
deferred,
|
||||||
|
methodRequest,
|
||||||
|
} from '@/lnurlcash/nwc.testProtocol';
|
||||||
|
import { setNwcTransportForTests, useNwcStore } from './nwc';
|
||||||
|
import { useWalletStore } from './wallet';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.stubGlobal('navigator', {});
|
||||||
|
stubLocalStorage();
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
setNwcTransportForTests(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('NWC wallet lifecycle drain', () => {
|
||||||
|
it('commits an accepted melted payment before ordinary lock invalidates its owner fence', async () => {
|
||||||
|
// Given a real encrypted wallet and NWC service whose payment is held
|
||||||
|
// after the mint burned its note but before the wallet commits the delta
|
||||||
|
const mint = await createMockMint();
|
||||||
|
try {
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
await wallet.create('password');
|
||||||
|
const ownerId = wallet.pubkey;
|
||||||
|
if (ownerId === null) throw new Error('Expected an unlocked owner.');
|
||||||
|
const k1 = 'd7'.repeat(32);
|
||||||
|
mint.state.creditNote(k1, 21_000);
|
||||||
|
const url = buildNoteUrl(`${mint.url}/w`, k1, 21_000);
|
||||||
|
const info = await fetchNoteInfo(url);
|
||||||
|
await wallet.addBearers(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
url,
|
||||||
|
callback: info.callback,
|
||||||
|
amount: info.maxWithdrawable,
|
||||||
|
verified: true,
|
||||||
|
mintPubkey: mint.state.pubkey,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
wallet.captureOwnerFence(),
|
||||||
|
);
|
||||||
|
const connection = createConnection(wallet.requireLinkingKey(), {
|
||||||
|
relays: RELAYS,
|
||||||
|
budget: { maxMsat: 50_000, periodMs: 86_400_000 },
|
||||||
|
clientSecret: CLIENT_SECRET,
|
||||||
|
});
|
||||||
|
const relay = createFakeRelay();
|
||||||
|
let accepted = false;
|
||||||
|
const transport = {
|
||||||
|
...relay.transport,
|
||||||
|
subscribe: (relays, filter, onEvent) =>
|
||||||
|
relay.transport.subscribe(relays, filter, (event) => {
|
||||||
|
accepted = true;
|
||||||
|
onEvent(event);
|
||||||
|
}),
|
||||||
|
} satisfies NwcTransport;
|
||||||
|
setNwcTransportForTests(transport);
|
||||||
|
const nwc = useNwcStore();
|
||||||
|
await nwc.setEnabled(true);
|
||||||
|
expect(relay.subscriptionCount()).toBe(1);
|
||||||
|
|
||||||
|
const commit = deferred();
|
||||||
|
let commitStarted = false;
|
||||||
|
const applyChangeset = wallet.applyChangeset.bind(wallet);
|
||||||
|
vi.spyOn(wallet, 'applyChangeset').mockImplementation(async (changeset, ownerFence) => {
|
||||||
|
commitStarted = true;
|
||||||
|
await commit.promise;
|
||||||
|
return applyChangeset(changeset, ownerFence);
|
||||||
|
});
|
||||||
|
const request = methodRequest(
|
||||||
|
connection.walletServicePubkey,
|
||||||
|
'pay_invoice',
|
||||||
|
{
|
||||||
|
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||||
|
},
|
||||||
|
'nip44_v2',
|
||||||
|
Math.floor(Date.now() / 1000),
|
||||||
|
);
|
||||||
|
relay.emit(request);
|
||||||
|
expect(accepted).toBe(true);
|
||||||
|
await vi.waitFor(() => expect(commitStarted || nwc.lastError !== '').toBe(true), {
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
|
expect(commitStarted, nwc.lastError).toBe(true);
|
||||||
|
expect(mint.state.noteState(k1)).toBe('burned');
|
||||||
|
expect(readNwcConnections(ownerId)[0]?.spent.msat).toBe(21_000);
|
||||||
|
|
||||||
|
// When lock begins, it closes admission immediately but drains the
|
||||||
|
// already accepted payment while that payment's fence remains valid
|
||||||
|
let lockSettled = false;
|
||||||
|
const locking = wallet.lock().then(() => {
|
||||||
|
lockSettled = true;
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(relay.subscriptionCount()).toBe(0));
|
||||||
|
expect(nwc.running).toBe(false);
|
||||||
|
expect(lockSettled).toBe(false);
|
||||||
|
expect(() => wallet.captureOwnerFence()).toThrow();
|
||||||
|
expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.');
|
||||||
|
const rejected = methodRequest(
|
||||||
|
connection.walletServicePubkey,
|
||||||
|
'get_balance',
|
||||||
|
{},
|
||||||
|
'nip44_v2',
|
||||||
|
Math.floor(Date.now() / 1000),
|
||||||
|
);
|
||||||
|
relay.emitAfterClose(rejected);
|
||||||
|
expect(
|
||||||
|
relay.published.some(
|
||||||
|
(event) =>
|
||||||
|
event.kind === NWC_RESPONSE_KIND &&
|
||||||
|
event.tags.some((tag) => tag[0] === 'e' && tag[1] === rejected.id),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
// Then the accepted payment commits and responds before lock clears
|
||||||
|
// runtime keys; after unlock, durable budget and bearer state agree
|
||||||
|
commit.resolve();
|
||||||
|
await locking;
|
||||||
|
expect(wallet.state).toBe('locked');
|
||||||
|
expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.');
|
||||||
|
await wallet.unlock('password');
|
||||||
|
expect(wallet.bearers).toHaveLength(1);
|
||||||
|
expect(wallet.bearers[0]?.spent).toBe(true);
|
||||||
|
expect(readNwcConnections(ownerId)[0]?.spent.msat).toBe(21_000);
|
||||||
|
expect(
|
||||||
|
relay.published.some(
|
||||||
|
(event) =>
|
||||||
|
event.kind === NWC_RESPONSE_KIND &&
|
||||||
|
event.tags.some((tag) => tag[0] === 'e' && tag[1] === request.id),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
} finally {
|
||||||
|
setNwcTransportForTests(null);
|
||||||
|
await mint.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
// Lock vs NWC drain integration: an accepted pay_invoice that already
|
||||||
|
// crossed its irreversible melt must reach its truthful durable outcome
|
||||||
|
// (budget debit, spent bearer, success response) BEFORE wallet.lock()
|
||||||
|
// invalidates the lifecycle fence and clears runtime keys. Uses the real
|
||||||
|
// wallet store, the real NWC store, the real service, and the real owner
|
||||||
|
// fence - only the relay transport is fake and the mint is the local
|
||||||
|
// conformance mock. Regresses the ordering where lock invalidated the
|
||||||
|
// captured fence first, leaving a debited budget, a locally unspent burned
|
||||||
|
// bearer, and no response.
|
||||||
|
|
||||||
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { buildNoteUrl, fetchNoteInfo } from 'lnurlcash-kit';
|
||||||
|
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
|
||||||
|
import { v2 as nip44v2 } from 'nostr-tools/nip44';
|
||||||
|
|
||||||
|
import { NWC_RESPONSE_KIND, createConnection, readNwcConnections } from '@/lnurlcash/nwc';
|
||||||
|
import type { NostrEvent } from '@/lnurlcash/nwc';
|
||||||
|
import { isJsonObject } from '@/lnurlcash/jsonParsing';
|
||||||
|
import { requiredValue, stubLocalStorage } from '@/lnurlcash/test-utils';
|
||||||
|
import {
|
||||||
|
CLIENT_SECRET,
|
||||||
|
RELAYS,
|
||||||
|
createFakeRelay,
|
||||||
|
deferred,
|
||||||
|
methodRequest,
|
||||||
|
waitFor,
|
||||||
|
} from '@/lnurlcash/nwc.testProtocol';
|
||||||
|
import { setNwcTransportForTests, useNwcStore } from './nwc';
|
||||||
|
import { useWalletStore } from './wallet';
|
||||||
|
|
||||||
|
const PASSWORD = 'correct horse battery staple';
|
||||||
|
// decodes to exactly 21_000 msat, matching the credited note (exact carve)
|
||||||
|
const INVOICE_21K = 'lnbc210n1pjqrstuvwxyz';
|
||||||
|
|
||||||
|
type NwcResponsePayload = {
|
||||||
|
result_type: string;
|
||||||
|
error: { code: string; message: string } | null;
|
||||||
|
result: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isNwcResponsePayload = (value: unknown): value is NwcResponsePayload =>
|
||||||
|
isJsonObject(value) &&
|
||||||
|
typeof value.result_type === 'string' &&
|
||||||
|
(value.error === null ||
|
||||||
|
(isJsonObject(value.error) &&
|
||||||
|
typeof value.error.code === 'string' &&
|
||||||
|
typeof value.error.message === 'string')) &&
|
||||||
|
(value.result === null || isJsonObject(value.result));
|
||||||
|
|
||||||
|
const readResponse = (published: NostrEvent[], requestId: string): NwcResponsePayload | null => {
|
||||||
|
const event = published.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.kind === NWC_RESPONSE_KIND &&
|
||||||
|
candidate.tags.some((tag) => tag[0] === 'e' && tag[1] === requestId),
|
||||||
|
);
|
||||||
|
if (!event) return null;
|
||||||
|
const plaintext = nip44v2.decrypt(
|
||||||
|
event.content,
|
||||||
|
nip44v2.utils.getConversationKey(CLIENT_SECRET, event.pubkey),
|
||||||
|
);
|
||||||
|
const parsed: unknown = JSON.parse(plaintext);
|
||||||
|
if (!isNwcResponsePayload(parsed)) throw new TypeError('Expected a valid NWC response payload.');
|
||||||
|
return parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Mint = Awaited<ReturnType<typeof createMockMint>>;
|
||||||
|
const mints: Mint[] = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.stubGlobal('navigator', {});
|
||||||
|
stubLocalStorage();
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
setNwcTransportForTests(null);
|
||||||
|
await Promise.all(mints.splice(0).map((mint) => mint.close()));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wallet lock with an in-flight NWC payment', () => {
|
||||||
|
it('drains an accepted post-melt pay to its durable outcome before invalidating the lifecycle', async () => {
|
||||||
|
// Given an encrypted unlocked wallet holding one exact-match note, served
|
||||||
|
// by a running NWC service over the fake relay
|
||||||
|
const mint = await createMockMint();
|
||||||
|
mints.push(mint);
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
await wallet.create(PASSWORD);
|
||||||
|
const k1 = 'e1'.repeat(32);
|
||||||
|
mint.state.creditNote(k1, 21_000);
|
||||||
|
const noteUrl = buildNoteUrl(`${mint.url}/w`, k1, 21_000);
|
||||||
|
const noteInfo = await fetchNoteInfo(noteUrl);
|
||||||
|
const [bearer] = await wallet.addBearers(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
url: noteUrl,
|
||||||
|
callback: noteInfo.callback,
|
||||||
|
amount: noteInfo.maxWithdrawable,
|
||||||
|
verified: true,
|
||||||
|
mintPubkey: mint.state.pubkey,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
wallet.captureOwnerFence(),
|
||||||
|
);
|
||||||
|
if (!bearer) throw new Error('Expected the added bearer.');
|
||||||
|
const ownerId = wallet.pubkey;
|
||||||
|
if (ownerId === null) throw new Error('Expected an unlocked owner.');
|
||||||
|
const connection = createConnection(wallet.requireLinkingKey(), {
|
||||||
|
relays: RELAYS,
|
||||||
|
budget: { maxMsat: 1_000_000_000, periodMs: 86_400_000 },
|
||||||
|
clientSecret: CLIENT_SECRET,
|
||||||
|
});
|
||||||
|
const relay = createFakeRelay();
|
||||||
|
setNwcTransportForTests(relay.transport);
|
||||||
|
const nwc = useNwcStore();
|
||||||
|
await nwc.setEnabled(true);
|
||||||
|
expect(nwc.running).toBe(true);
|
||||||
|
|
||||||
|
// ... and a pay that crosses the irreversible melt, then pauses at the
|
||||||
|
// durable bearer commit
|
||||||
|
const releaseCommit = deferred();
|
||||||
|
let commitReached = false;
|
||||||
|
const applyChangeset = wallet.applyChangeset;
|
||||||
|
vi.spyOn(wallet, 'applyChangeset').mockImplementation(async (changeset, ownerFence) => {
|
||||||
|
commitReached = true;
|
||||||
|
await releaseCommit.promise;
|
||||||
|
return applyChangeset(changeset, ownerFence);
|
||||||
|
});
|
||||||
|
const request = methodRequest(
|
||||||
|
connection.walletServicePubkey,
|
||||||
|
'pay_invoice',
|
||||||
|
{ invoice: INVOICE_21K },
|
||||||
|
'nip44_v2',
|
||||||
|
Math.floor(Date.now() / 1000),
|
||||||
|
);
|
||||||
|
relay.emit(request);
|
||||||
|
await waitFor(() => commitReached);
|
||||||
|
expect(mint.state.noteState(k1)).toBe('burned');
|
||||||
|
|
||||||
|
// When the holder locks the wallet mid-flight
|
||||||
|
let lockSettled = false;
|
||||||
|
const locking = wallet.lock().then(() => {
|
||||||
|
lockSettled = true;
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||||
|
|
||||||
|
// Then the lock waits for the accepted pay instead of invalidating its
|
||||||
|
// commit: the lifecycle stays commit-capable while subscriptions are
|
||||||
|
// already closed, so no new work is accepted once the lock began
|
||||||
|
expect(lockSettled).toBe(false);
|
||||||
|
expect(wallet.state).toBe('unlocked');
|
||||||
|
expect(relay.subscriptionCount()).toBe(0);
|
||||||
|
const lateRequest = methodRequest(
|
||||||
|
connection.walletServicePubkey,
|
||||||
|
'get_balance',
|
||||||
|
{},
|
||||||
|
'nip44_v2',
|
||||||
|
Math.floor(Date.now() / 1000),
|
||||||
|
);
|
||||||
|
relay.emitAfterClose(lateRequest);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||||
|
expect(readResponse(relay.published, lateRequest.id)).toBeNull();
|
||||||
|
|
||||||
|
// When the paused payment resumes
|
||||||
|
releaseCommit.resolve();
|
||||||
|
|
||||||
|
// Then the client receives its deterministic success BEFORE the lock
|
||||||
|
// completes, and budget and bearer state commit consistently
|
||||||
|
await waitFor(() => readResponse(relay.published, request.id) !== null);
|
||||||
|
const response = requiredValue(readResponse(relay.published, request.id));
|
||||||
|
expect(response.error).toBeNull();
|
||||||
|
expect(typeof response.result?.preimage).toBe('string');
|
||||||
|
expect(readNwcConnections(ownerId)[0]?.spent.msat).toBe(21_000);
|
||||||
|
await locking;
|
||||||
|
expect(wallet.state).toBe('locked');
|
||||||
|
expect(wallet.bearers).toEqual([]);
|
||||||
|
expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.');
|
||||||
|
expect(nwc.running).toBe(false);
|
||||||
|
|
||||||
|
// ... and the spent note survives the lock durably
|
||||||
|
await wallet.unlock(PASSWORD);
|
||||||
|
expect(wallet.bearers.find((candidate) => candidate.id === bearer.id)?.spent).toBe(true);
|
||||||
|
await nwc.setEnabled(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+221
-263
@@ -1,245 +1,263 @@
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, onScopeDispose, ref } from 'vue';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { serverOf } from 'lnurlcash-kit';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deriveWalletLinkingKey,
|
|
||||||
deriveBearerAesKey,
|
deriveBearerAesKey,
|
||||||
saveLinkingKey,
|
|
||||||
savedKeyExists,
|
savedKeyExists,
|
||||||
savedKeyIsEncrypted,
|
savedKeyIsEncrypted,
|
||||||
getPlainLinkingKey,
|
savedKeyOwnerId,
|
||||||
decryptSavedLinkingKey,
|
|
||||||
clearSavedLinkingKey,
|
clearSavedLinkingKey,
|
||||||
generateSeedPhrase,
|
|
||||||
isValidSeedPhrase,
|
|
||||||
linkingPubKeyHex,
|
|
||||||
} from '@/lnurlcash/keys';
|
} from '@/lnurlcash/keys';
|
||||||
import type { Bearer, NewBearer } from '@/lnurlcash/types';
|
|
||||||
import {
|
import {
|
||||||
loadBearers,
|
loadBearers,
|
||||||
persistBearer,
|
|
||||||
deleteBearerRecord,
|
|
||||||
clearAllBearers,
|
clearAllBearers,
|
||||||
newBearerId,
|
applyBackup,
|
||||||
mergeBearers,
|
parseBackupFile,
|
||||||
|
clearSettings,
|
||||||
} from '@/lnurlcash/storage';
|
} from '@/lnurlcash/storage';
|
||||||
import {
|
import type { RestoreResult } from '@/lnurlcash/storage';
|
||||||
grandfatherTrustedMint,
|
import { disableBiometricUnlock } from '@/capabilities/biometricUnlock';
|
||||||
lockTrustedMint,
|
import { restoreFromNostr as restoreFromNostrEngine } from '@/lnurlcash/nostrBackup';
|
||||||
clearTrustedMints,
|
|
||||||
} from '@/lnurlcash/trustedMints';
|
|
||||||
import { clearSettings } from '@/lnurlcash/storage';
|
|
||||||
import { unlockWithPasskey as unlockWithPasskeyEngine } from '@/lnurlcash/passkeys';
|
|
||||||
import { disableBiometricUnlock, unlockWithBiometrics } from '@/capabilities/biometricUnlock';
|
|
||||||
import { msatToSats } from '@/lnurlcash/units';
|
|
||||||
import { useActivityStore } from './activity';
|
import { useActivityStore } from './activity';
|
||||||
|
import { createWalletFunds } from './walletFunds';
|
||||||
|
import { createWalletIdleWatch } from './walletIdle';
|
||||||
|
import { createWalletAccess } from './walletAccess';
|
||||||
|
import { restoreHeldMintTrust } from './walletActivation';
|
||||||
|
import { startWalletOwnerMonitor } from './walletOwnerMonitor';
|
||||||
|
import { createWalletOwnerFence } from './walletOwnerFence';
|
||||||
|
import type { WalletState } from './walletOwnerFence';
|
||||||
|
import {
|
||||||
|
clearOwnerAuthorizations,
|
||||||
|
clearUnownedAuthorizations,
|
||||||
|
createSeedInstaller,
|
||||||
|
createWalletTransitionQueue,
|
||||||
|
migrateProvenLegacyOwner,
|
||||||
|
ownerOf,
|
||||||
|
stopWalletNwcSession,
|
||||||
|
WalletLifecycleError,
|
||||||
|
} from './walletLifecycle';
|
||||||
|
|
||||||
|
export { TrustedMintPostCommitError } from './walletFunds';
|
||||||
|
|
||||||
// 'none': no wallet on this device yet -> setup
|
// 'none': no wallet on this device yet -> setup
|
||||||
// 'locked': linking key present but password-encrypted -> unlock
|
// 'locked': linking key present but password-encrypted -> unlock
|
||||||
// 'unlocked': linking key (and thus the bearer AES key) in memory
|
// 'unlocked': linking key (and thus the bearer AES key) in memory
|
||||||
export type WalletState = 'none' | 'locked' | 'unlocked';
|
export type { WalletState } from './walletOwnerFence';
|
||||||
|
|
||||||
// idle-timeout auto-lock: only meaningful for a password-encrypted key (see
|
|
||||||
// lock(), which no-ops otherwise) - 5 minutes with no activity anywhere in
|
|
||||||
// the tab locks the wallet. lockWarningSecondsLeft goes non-null 30s ahead
|
|
||||||
// of that so the UI can warn, and postponeLock() is the "stay unlocked"
|
|
||||||
// hook it offers.
|
|
||||||
const AUTO_LOCK_MS = 5 * 60 * 1000;
|
|
||||||
const LOCK_WARNING_MS = 30 * 1000;
|
|
||||||
|
|
||||||
// a plaintext-stored key also starts 'locked' - init() unlocks it
|
// a plaintext-stored key also starts 'locked' - init() unlocks it
|
||||||
// immediately without a password, keeping a single code path for deriving
|
// immediately without a password, keeping a single code path for deriving
|
||||||
// the AES key and loading bearers
|
// the AES key and loading bearers
|
||||||
const initialState = (): WalletState => (savedKeyExists() ? 'locked' : 'none');
|
|
||||||
|
|
||||||
export const useWalletStore = defineStore('wallet', () => {
|
export const useWalletStore = defineStore('wallet', () => {
|
||||||
const state = ref<WalletState>(initialState());
|
const state = ref<WalletState>(savedKeyExists() ? 'locked' : 'none');
|
||||||
const bearers = ref<Bearer[]>([]);
|
|
||||||
const pubkey = ref<string | null>(null);
|
const pubkey = ref<string | null>(null);
|
||||||
|
const auxiliaryError = ref('');
|
||||||
|
const lifecycleError = ref('');
|
||||||
let aesKey: CryptoKey | null = null;
|
let aesKey: CryptoKey | null = null;
|
||||||
// the linking key itself, only while unlocked - needed by backup/passkey
|
// the linking key itself, only while unlocked - needed by backup/passkey
|
||||||
// operations (nostrBackup derives the backup key from it, passkey
|
// operations (nostrBackup derives the backup key from it, passkey
|
||||||
// registration wraps it). Never exposed reactively; cleared on lock/forget
|
// registration wraps it). Never exposed reactively; cleared on lock/forget
|
||||||
let currentLinkingKey: Uint8Array | null = null;
|
let currentLinkingKey: Uint8Array | null = null;
|
||||||
|
let lifecycleToken = 0;
|
||||||
|
let acceptingOwnerWork = false;
|
||||||
|
|
||||||
// ---- idle auto-lock bookkeeping ----
|
|
||||||
let lastActivity = Date.now();
|
|
||||||
let idleTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
const lockWarningSecondsLeft = ref<number | null>(null);
|
const lockWarningSecondsLeft = ref<number | null>(null);
|
||||||
|
const runTransition = createWalletTransitionQueue({
|
||||||
|
onStart: () => (lifecycleError.value = ''),
|
||||||
|
onError: (error) => {
|
||||||
|
lifecycleError.value = error instanceof Error ? error.message : 'Wallet transition failed.';
|
||||||
|
},
|
||||||
|
}).run;
|
||||||
|
|
||||||
const encrypted = computed(() => savedKeyIsEncrypted());
|
const encrypted = computed(() => savedKeyIsEncrypted());
|
||||||
|
|
||||||
// ---- balances: protocol layer is msat; sats are a display helper ----
|
const ownerFence = createWalletOwnerFence({
|
||||||
const unspentBearers = computed(() => bearers.value.filter((b) => !b.spent));
|
state: () => state.value,
|
||||||
const balanceMsat = computed(() => unspentBearers.value.reduce((sum, b) => sum + b.amount, 0));
|
ownerId: () => pubkey.value,
|
||||||
const balanceSats = computed(() => msatToSats(balanceMsat.value));
|
lifecycleToken: () => lifecycleToken,
|
||||||
const balanceByMintMsat = computed(() => {
|
accepting: () => acceptingOwnerWork,
|
||||||
const byMint = new Map<string, number>();
|
|
||||||
for (const b of unspentBearers.value) {
|
|
||||||
const server = serverOf(b.url);
|
|
||||||
byMint.set(server, (byMint.get(server) ?? 0) + b.amount);
|
|
||||||
}
|
|
||||||
return byMint;
|
|
||||||
});
|
|
||||||
const balanceByMintSats = computed(() => {
|
|
||||||
const byMint = new Map<string, number>();
|
|
||||||
for (const [server, msat] of balanceByMintMsat.value) {
|
|
||||||
byMint.set(server, msatToSats(msat));
|
|
||||||
}
|
|
||||||
return byMint;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const stopIdleWatch = () => {
|
const clearRuntime = (): void => {
|
||||||
if (idleTimer) clearInterval(idleTimer);
|
|
||||||
idleTimer = null;
|
|
||||||
lockWarningSecondsLeft.value = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const lock = () => {
|
|
||||||
// only meaningful for a password-encrypted key - a plaintext one would
|
|
||||||
// just auto-unlock again, so the UI only offers Lock when encrypted
|
|
||||||
if (!savedKeyIsEncrypted()) return;
|
|
||||||
aesKey = null;
|
aesKey = null;
|
||||||
currentLinkingKey = null;
|
currentLinkingKey = null;
|
||||||
pubkey.value = null;
|
pubkey.value = null;
|
||||||
bearers.value = [];
|
funds.clear();
|
||||||
useActivityStore().unload();
|
useActivityStore().unload();
|
||||||
stopIdleWatch();
|
};
|
||||||
|
|
||||||
|
// ends every captured fence and drops the reactive owner identity. Only
|
||||||
|
// ever runs after accepted NWC work has drained (or failed to): an
|
||||||
|
// operation past its irreversible melt must stay commit-capable until
|
||||||
|
// then, and stop() rejects new requests the moment it is called, so no
|
||||||
|
// post-lock work is accepted while the fence stays valid
|
||||||
|
const invalidateLifecycle = (): void => {
|
||||||
|
acceptingOwnerWork = false;
|
||||||
|
lifecycleToken += 1;
|
||||||
state.value = 'locked';
|
state.value = 'locked';
|
||||||
|
pubkey.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// any real interaction restarts the 5-minute clock and clears the
|
const deactivateSession = async (): Promise<void> => {
|
||||||
// warning - the UI's "Stay unlocked" button calls this
|
acceptingOwnerWork = false;
|
||||||
const postponeLock = () => {
|
stopOwnerChanges();
|
||||||
lastActivity = Date.now();
|
idleWatch.stop();
|
||||||
lockWarningSecondsLeft.value = null;
|
try {
|
||||||
};
|
await stopWalletNwcSession();
|
||||||
|
} finally {
|
||||||
// ticks once a second while unlocked and encrypted (the only state
|
// even a rejected drain ends the session: 'locked' never holds key
|
||||||
// auto-lock applies to), comparing wall-clock time against lastActivity
|
// material and no captured fence stays valid
|
||||||
// rather than relying on a single setTimeout, since a backgrounded tab
|
invalidateLifecycle();
|
||||||
// throttles timers but Date.now() still reflects real elapsed time
|
clearRuntime();
|
||||||
// whenever this next gets to run
|
|
||||||
const startIdleWatch = () => {
|
|
||||||
stopIdleWatch();
|
|
||||||
if (typeof window === 'undefined' || !savedKeyIsEncrypted()) return;
|
|
||||||
lastActivity = Date.now();
|
|
||||||
const registerActivity = () => {
|
|
||||||
// once the warning is up, passive activity is deliberately ignored -
|
|
||||||
// only postponeLock() dismisses it, so the "stay unlocked" button
|
|
||||||
// can't vanish out from under the pointer before the click lands
|
|
||||||
if (state.value === 'unlocked' && lockWarningSecondsLeft.value === null) {
|
|
||||||
lastActivity = Date.now();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
for (const event of ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll']) {
|
|
||||||
window.addEventListener(event, registerActivity, { passive: true });
|
|
||||||
}
|
}
|
||||||
idleTimer = setInterval(() => {
|
|
||||||
if (state.value !== 'unlocked') return;
|
|
||||||
const elapsed = Date.now() - lastActivity;
|
|
||||||
if (elapsed >= AUTO_LOCK_MS) {
|
|
||||||
lock();
|
|
||||||
} else if (elapsed >= AUTO_LOCK_MS - LOCK_WARNING_MS) {
|
|
||||||
lockWarningSecondsLeft.value = Math.ceil((AUTO_LOCK_MS - elapsed) / 1000);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const activate = async (linkingKey: Uint8Array) => {
|
let stopOwnerChanges = (): void => {};
|
||||||
|
const observeOwnerChanges = (): void => {
|
||||||
|
stopOwnerChanges();
|
||||||
|
stopOwnerChanges = startWalletOwnerMonitor({
|
||||||
|
snapshot: () => ({ token: lifecycleToken, state: state.value, ownerId: pubkey.value }),
|
||||||
|
deactivate: deactivateSession,
|
||||||
|
runTransition,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
onScopeDispose(() => stopOwnerChanges());
|
||||||
|
|
||||||
|
const lock = (): Promise<void> =>
|
||||||
|
runTransition(async () => {
|
||||||
|
if (!savedKeyIsEncrypted()) return;
|
||||||
|
await deactivateSession();
|
||||||
|
});
|
||||||
|
|
||||||
|
const idleWatch = createWalletIdleWatch({
|
||||||
|
isEncrypted: savedKeyIsEncrypted,
|
||||||
|
isUnlocked: () => state.value === 'unlocked',
|
||||||
|
isLockWarningVisible: () => lockWarningSecondsLeft.value !== null,
|
||||||
|
lock,
|
||||||
|
setWarningSecondsLeft: (seconds) => {
|
||||||
|
lockWarningSecondsLeft.value = seconds;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const activate = async (linkingKey: Uint8Array, ownerWasMissing: boolean): Promise<void> => {
|
||||||
|
auxiliaryError.value = '';
|
||||||
|
await migrateProvenLegacyOwner(linkingKey, ownerWasMissing);
|
||||||
const key = await deriveBearerAesKey(linkingKey);
|
const key = await deriveBearerAesKey(linkingKey);
|
||||||
aesKey = key;
|
|
||||||
currentLinkingKey = linkingKey;
|
|
||||||
pubkey.value = linkingPubKeyHex(linkingKey);
|
|
||||||
const loaded = await loadBearers(key);
|
const loaded = await loadBearers(key);
|
||||||
bearers.value = loaded;
|
|
||||||
const activity = useActivityStore();
|
const activity = useActivityStore();
|
||||||
await activity.loadFor(key);
|
await activity.loadFor(key);
|
||||||
// grandfather in every mint already backing a held bearer as trusted -
|
const ownerId = ownerOf(linkingKey);
|
||||||
// holding funds there already implied trusting it. Storage-sourced
|
await restoreHeldMintTrust(loaded, ownerId, (message) => {
|
||||||
// claims only, though: grandfathering never locks and marks new pins
|
auxiliaryError.value = message;
|
||||||
// unconfirmed - both are (re-)earned by live responses during actual
|
});
|
||||||
// bearer operations
|
aesKey = key;
|
||||||
for (const bearer of loaded) {
|
currentLinkingKey = linkingKey;
|
||||||
if (bearer.mintPubkey) {
|
lifecycleToken += 1;
|
||||||
grandfatherTrustedMint(serverOf(bearer.url), bearer.mintPubkey);
|
observeOwnerChanges();
|
||||||
}
|
pubkey.value = ownerId;
|
||||||
}
|
funds.replace(loaded);
|
||||||
|
acceptingOwnerWork = true;
|
||||||
state.value = 'unlocked';
|
state.value = 'unlocked';
|
||||||
startIdleWatch();
|
idleWatch.start();
|
||||||
};
|
};
|
||||||
|
|
||||||
// generates a fresh seed phrase, derives and saves the linking key, and
|
const teardownCurrentOwner = async (resetRegistry = false): Promise<void> => {
|
||||||
// unlocks. The phrase is returned exactly once - it is never stored, so
|
const ownerId = savedKeyOwnerId() ?? pubkey.value;
|
||||||
// the caller MUST show it to the holder before letting them move on.
|
acceptingOwnerWork = false;
|
||||||
const create = async (password?: string): Promise<string> => {
|
stopOwnerChanges();
|
||||||
const phrase = generateSeedPhrase();
|
idleWatch.stop();
|
||||||
await restoreFromSeed(phrase, password);
|
try {
|
||||||
return phrase;
|
// the drain runs before the fence is invalidated and the runtime is
|
||||||
};
|
// cleared so an in-flight fund-critical changeset can still commit
|
||||||
|
// (its applyChangeset needs the live fence and key)
|
||||||
const restoreFromSeed = async (seedPhrase: string, password?: string): Promise<void> => {
|
await stopWalletNwcSession();
|
||||||
if (!isValidSeedPhrase(seedPhrase)) {
|
invalidateLifecycle();
|
||||||
throw new Error('Not a valid seed phrase.');
|
clearRuntime();
|
||||||
}
|
if (ownerId === null) await clearUnownedAuthorizations();
|
||||||
const linkingKey = deriveWalletLinkingKey(seedPhrase);
|
else await clearOwnerAuthorizations(ownerId, resetRegistry);
|
||||||
await saveLinkingKey(linkingKey, password);
|
await disableBiometricUnlock();
|
||||||
await activate(linkingKey);
|
clearAllBearers();
|
||||||
};
|
useActivityStore().unloadAndClear();
|
||||||
|
clearSettings();
|
||||||
const unlock = async (password?: string): Promise<void> => {
|
clearSavedLinkingKey();
|
||||||
const linkingKey = savedKeyIsEncrypted()
|
state.value = 'none';
|
||||||
? await decryptSavedLinkingKey(password || '')
|
} finally {
|
||||||
: getPlainLinkingKey();
|
// a failed teardown still ends the session: 'locked' must never hold
|
||||||
if (!linkingKey) throw new Error('No wallet on this device.');
|
// key material in memory, and no captured fence may stay usable
|
||||||
await activate(linkingKey);
|
if (state.value !== 'none') invalidateLifecycle();
|
||||||
};
|
clearRuntime();
|
||||||
|
|
||||||
// passkey unlock (passkeys.ts): the ceremony unwraps the SAME linking key
|
|
||||||
// the password path protects, so activation is identical either way
|
|
||||||
const unlockWithPasskey = async (): Promise<void> => {
|
|
||||||
await activate(await unlockWithPasskeyEngine());
|
|
||||||
};
|
|
||||||
|
|
||||||
// native biometric unlock (capabilities/biometricUnlock.ts): a third wrap
|
|
||||||
// of the same linking key, behind the device credential prompt
|
|
||||||
const unlockWithBiometric = async (): Promise<void> => {
|
|
||||||
await activate(await unlockWithBiometrics());
|
|
||||||
};
|
|
||||||
|
|
||||||
// app-start entry point (boot/wallet.ts): a plaintext-stored key unlocks
|
|
||||||
// without a password; an encrypted one waits on the unlock screen
|
|
||||||
const init = async (): Promise<void> => {
|
|
||||||
if (state.value === 'locked' && !savedKeyIsEncrypted()) {
|
|
||||||
await unlock();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const prepareInstallation = async (nextOwnerId: string): Promise<void> => {
|
||||||
|
const installedOwner = savedKeyOwnerId() ?? pubkey.value;
|
||||||
|
if (savedKeyExists() && installedOwner === nextOwnerId) {
|
||||||
|
if (state.value === 'unlocked') await deactivateSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (savedKeyExists() || state.value === 'unlocked') {
|
||||||
|
await teardownCurrentOwner(true);
|
||||||
|
}
|
||||||
|
await clearUnownedAuthorizations();
|
||||||
|
};
|
||||||
|
|
||||||
|
const installSeed = createSeedInstaller({
|
||||||
|
prepareInstallation,
|
||||||
|
activate: (linkingKey) => activate(linkingKey, false),
|
||||||
|
});
|
||||||
|
|
||||||
|
const access = createWalletAccess({
|
||||||
|
runTransition,
|
||||||
|
installSeed,
|
||||||
|
activate,
|
||||||
|
canInit: () => state.value === 'locked',
|
||||||
|
});
|
||||||
|
|
||||||
|
const restoreFromBackup = (data: unknown): Promise<RestoreResult> =>
|
||||||
|
runTransition(async () => {
|
||||||
|
const backup = parseBackupFile(data);
|
||||||
|
const hadSavedKey = savedKeyExists();
|
||||||
|
const activeOwner = pubkey.value;
|
||||||
|
const activeKey = state.value === 'unlocked' ? requireLinkingKey() : null;
|
||||||
|
if (activeKey !== null) await deactivateSession();
|
||||||
|
if (!hadSavedKey) await clearUnownedAuthorizations();
|
||||||
|
const result = await applyBackup(backup, activeOwner ?? undefined);
|
||||||
|
if (activeKey !== null) await activate(activeKey, false);
|
||||||
|
else if (result.linkingKeyRestored) state.value = 'locked';
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
const restoreFromNostr = (
|
||||||
|
seedPhrase: string,
|
||||||
|
relays: string[],
|
||||||
|
password?: string,
|
||||||
|
): Promise<void> =>
|
||||||
|
runTransition(() =>
|
||||||
|
installSeed(seedPhrase, password, async (linkingKey) => {
|
||||||
|
await restoreFromNostrEngine(linkingKey, relays);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const restoreCurrentFromNostr = (relays: string[]) =>
|
||||||
|
runTransition(async () => {
|
||||||
|
const linkingKey = requireLinkingKey();
|
||||||
|
await deactivateSession();
|
||||||
|
const result = await restoreFromNostrEngine(linkingKey, relays);
|
||||||
|
await activate(linkingKey, false);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
// wipes this wallet from the device entirely - the linking key, every
|
// wipes this wallet from the device entirely - the linking key, every
|
||||||
// bearer record, the activity log, and the non-secret registries that
|
// bearer record, the activity log, and the non-secret registries that
|
||||||
// would otherwise linger as a fingerprint of it. Not recoverable by
|
// would otherwise linger as a fingerprint of it. Not recoverable by
|
||||||
// restoring the same seed afterward (the ciphertexts themselves are
|
// restoring the same seed afterward (the ciphertexts themselves are
|
||||||
// gone); only a backup downloaded before this runs can bring the notes
|
// gone); only a backup downloaded before this runs can bring the notes
|
||||||
// back - the UI should prompt for one
|
// back - the UI should prompt for one
|
||||||
const forgetWallet = () => {
|
const forgetWallet = (): Promise<void> =>
|
||||||
clearSavedLinkingKey();
|
runTransition(() =>
|
||||||
// the biometric wrap belongs to this wallet's linking key - drop it too.
|
teardownCurrentOwner().catch((error) => {
|
||||||
// The record removal is synchronous inside; the secure-storage delete
|
throw new WalletLifecycleError('forget', error);
|
||||||
// trails behind, and an orphaned secret there is unusable without the
|
}),
|
||||||
// record, so a failed delete is safe to swallow
|
);
|
||||||
void disableBiometricUnlock().catch(() => {});
|
|
||||||
clearAllBearers();
|
|
||||||
clearTrustedMints();
|
|
||||||
clearSettings();
|
|
||||||
useActivityStore().unloadAndClear();
|
|
||||||
aesKey = null;
|
|
||||||
currentLinkingKey = null;
|
|
||||||
pubkey.value = null;
|
|
||||||
bearers.value = [];
|
|
||||||
stopIdleWatch();
|
|
||||||
state.value = 'none';
|
|
||||||
};
|
|
||||||
|
|
||||||
const requireKey = (): CryptoKey => {
|
const requireKey = (): CryptoKey => {
|
||||||
if (!aesKey) throw new Error('Wallet is locked.');
|
if (!aesKey) throw new Error('Wallet is locked.');
|
||||||
@@ -250,99 +268,39 @@ export const useWalletStore = defineStore('wallet', () => {
|
|||||||
// (nostr backup key derivation, passkey registration) - never reactive,
|
// (nostr backup key derivation, passkey registration) - never reactive,
|
||||||
// throws when locked, so callers can't accidentally hold a stale key
|
// throws when locked, so callers can't accidentally hold a stale key
|
||||||
const requireLinkingKey = (): Uint8Array => {
|
const requireLinkingKey = (): Uint8Array => {
|
||||||
if (!currentLinkingKey) throw new Error('Wallet is locked.');
|
if (!acceptingOwnerWork || !currentLinkingKey) throw new Error('Wallet is locked.');
|
||||||
return currentLinkingKey;
|
return currentLinkingKey;
|
||||||
};
|
};
|
||||||
|
|
||||||
// the one entry point for new notes (minted, received, carved outputs):
|
const funds = createWalletFunds({
|
||||||
// persists first, then updates state. Holding a bearer from a mint
|
requireKey,
|
||||||
// trusts it by default - this is the one path that never asks (see
|
ownerId: () => pubkey.value ?? undefined,
|
||||||
// trustedMints.ts); a DIFFERENT advertised key comes back as
|
setAuxiliaryError: (message) => {
|
||||||
// 'rekey-pending' and is staged on the mints store for review, never
|
auxiliaryError.value = message;
|
||||||
// auto-applied.
|
},
|
||||||
const addBearers = async (notes: NewBearer[]): Promise<Bearer[]> => {
|
});
|
||||||
const now = Date.now();
|
|
||||||
const added: Bearer[] = [];
|
|
||||||
for (const note of notes) {
|
|
||||||
const bearer: Bearer = {
|
|
||||||
id: newBearerId(),
|
|
||||||
...note,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
};
|
|
||||||
await persistBearer(requireKey(), bearer);
|
|
||||||
added.push(bearer);
|
|
||||||
}
|
|
||||||
bearers.value = [...added, ...bearers.value];
|
|
||||||
for (const bearer of added) {
|
|
||||||
if (bearer.mintPubkey) {
|
|
||||||
lockTrustedMint(serverOf(bearer.url), bearer.mintPubkey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return added;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateBearer = async (id: string, changes: Partial<Omit<Bearer, 'id'>>): Promise<void> => {
|
|
||||||
const current = bearers.value.find((b) => b.id === id);
|
|
||||||
if (!current) return;
|
|
||||||
const updated: Bearer = { ...current, ...changes, updatedAt: Date.now() };
|
|
||||||
await persistBearer(requireKey(), updated);
|
|
||||||
bearers.value = bearers.value.map((b) => (b.id === id ? updated : b));
|
|
||||||
if (updated.mintPubkey) {
|
|
||||||
lockTrustedMint(serverOf(updated.url), updated.mintPubkey);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const markSpent = async (id: string, spent = true): Promise<void> => {
|
|
||||||
await updateBearer(id, { spent });
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeNote = async (id: string): Promise<void> => {
|
|
||||||
bearers.value = bearers.value.filter((b) => b.id !== id);
|
|
||||||
await deleteBearerRecord(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
// merges externally-produced bearers (a decrypted backup restore, later a
|
|
||||||
// nostr restore) into the live list: union by note identity, spent-wins -
|
|
||||||
// see storage.ts's mergeBearers. Persists every survivor.
|
|
||||||
const mergeExternalBearers = async (incoming: Bearer[]): Promise<void> => {
|
|
||||||
const merged = mergeBearers(bearers.value, incoming);
|
|
||||||
for (const bearer of merged) {
|
|
||||||
await persistBearer(requireKey(), bearer);
|
|
||||||
}
|
|
||||||
bearers.value = merged;
|
|
||||||
};
|
|
||||||
|
|
||||||
const reloadBearers = async (): Promise<void> => {
|
|
||||||
bearers.value = await loadBearers(requireKey());
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
bearers,
|
|
||||||
pubkey,
|
pubkey,
|
||||||
|
auxiliaryError,
|
||||||
|
lifecycleError,
|
||||||
encrypted,
|
encrypted,
|
||||||
lockWarningSecondsLeft,
|
lockWarningSecondsLeft,
|
||||||
balanceMsat,
|
...funds.public,
|
||||||
balanceSats,
|
create: access.create,
|
||||||
balanceByMintMsat,
|
restoreFromSeed: access.restoreFromSeed,
|
||||||
balanceByMintSats,
|
restoreFromBackup,
|
||||||
unspentBearers,
|
restoreFromNostr,
|
||||||
create,
|
restoreCurrentFromNostr,
|
||||||
restoreFromSeed,
|
unlock: access.unlock,
|
||||||
unlock,
|
unlockWithPasskey: access.unlockWithPasskey,
|
||||||
unlockWithPasskey,
|
unlockWithBiometric: access.unlockWithBiometric,
|
||||||
unlockWithBiometric,
|
|
||||||
lock,
|
lock,
|
||||||
init,
|
init: access.init,
|
||||||
forgetWallet,
|
forgetWallet,
|
||||||
postponeLock,
|
postponeLock: idleWatch.postpone,
|
||||||
requireLinkingKey,
|
requireLinkingKey,
|
||||||
addBearers,
|
captureOwnerFence: ownerFence.capture,
|
||||||
updateBearer,
|
|
||||||
markSpent,
|
|
||||||
removeNote,
|
|
||||||
mergeExternalBearers,
|
|
||||||
reloadBearers,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user