Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
226 changes: 226 additions & 0 deletions src/utils/__tests__/addressBookQDN.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,5 +294,231 @@ describe('syncAllAddressBooksOnStartup', () => {

expect(wasQortPublished()).toBe(false);
});

it('aligns the local timestamp with QDN after publishing', async () => {
// Root cause 1 fix: the timestamp stored locally must equal the timestamp
// that was written into QDN so the next startup sees equal timestamps.
setLocalStorage([ENTRY_ALICE], 1000);

const before = Date.now();
await syncAllAddressBooksOnStartup('TestUser');
const after = Date.now();

const stored = getStoredData();
expect(stored.lastUpdated).toBeGreaterThanOrEqual(before);
expect(stored.lastUpdated).toBeLessThanOrEqual(after);
});

it('records the published hash after a successful publish', async () => {
// Root cause 2 fix: publishToQDN must write the published-hash sentinel
// so future startups can detect a propagation-delay scenario.
setLocalStorage([ENTRY_ALICE], 1000);

await syncAllAddressBooksOnStartup('TestUser');

const hash = localStorage.getItem('q-wallets-addressbook-published-QORT');
expect(hash).toBeTruthy();
});
});

// -------------------------------------------------------------------------
// Steady-state: QDN and local are already in sync → never publish
// -------------------------------------------------------------------------

describe('Steady-state — QDN and local are already in sync', () => {
it('does not publish when QDN carries the exact same entries and equal timestamp (hash field present)', async () => {
// Run a first startup to capture the real hash that publishToQDN generates.
setLocalStorage([ENTRY_ALICE], 1000);
await syncAllAddressBooksOnStartup('TestUser');

const publishedHash = localStorage.getItem(
'q-wallets-addressbook-published-QORT'
)!;
const alignedTimestamp = getStoredData().lastUpdated;

// QDN now has the published data (propagation complete).
qdnDataForQort = {
entries: [ENTRY_ALICE],
lastUpdated: alignedTimestamp,
hash: publishedHash,
};
mockQortalRequest.mockClear();

// Second startup: content and timestamp match → must stay silent.
await syncAllAddressBooksOnStartup('TestUser');

expect(wasQortPublished()).toBe(false);
});

it('does not modify localStorage when already in sync', async () => {
setLocalStorage([ENTRY_ALICE], 1000);
await syncAllAddressBooksOnStartup('TestUser');

const publishedHash = localStorage.getItem(
'q-wallets-addressbook-published-QORT'
)!;
const alignedTimestamp = getStoredData().lastUpdated;

qdnDataForQort = {
entries: [ENTRY_ALICE],
lastUpdated: alignedTimestamp,
hash: publishedHash,
};
mockQortalRequest.mockClear();

await syncAllAddressBooksOnStartup('TestUser');

const stored = getStoredData();
expect(stored.lastUpdated).toBe(alignedTimestamp);
expect(stored.entries).toHaveLength(1);
expect(stored.entries[0].id).toBe(ENTRY_ALICE.id);
});

it('does not publish when timestamps are equal but QDN has no hash field', async () => {
// Regression: when qdnData.hash is absent the branch is skipped; verify
// the fallback "in sync" path never triggers a publish either.
setLocalStorage([ENTRY_ALICE], 3000);
qdnDataForQort = { entries: [ENTRY_ALICE], lastUpdated: 3000 }; // no hash

await syncAllAddressBooksOnStartup('TestUser');

expect(wasQortPublished()).toBe(false);
});
});

// -------------------------------------------------------------------------
// Fresh install: no local data, QDN has data → download only, never publish
// -------------------------------------------------------------------------

describe('Fresh install — no local data, QDN has data', () => {
it('downloads QDN data without publishing', async () => {
// localStorage is empty; QDN has existing entries from another device.
qdnDataForQort = { entries: [ENTRY_ALICE, ENTRY_BOB], lastUpdated: 5000 };

await syncAllAddressBooksOnStartup('TestUser');

expect(wasQortPublished()).toBe(false);
});

it('stores QDN entries and timestamp in localStorage after download', async () => {
qdnDataForQort = { entries: [ENTRY_ALICE, ENTRY_BOB], lastUpdated: 5000 };

await syncAllAddressBooksOnStartup('TestUser');

const stored = getStoredData();
expect(stored.lastUpdated).toBe(5000);
expect(stored.entries).toHaveLength(2);
});

it('does not publish on the second startup after the QDN download', async () => {
// First startup: no local → download from QDN.
qdnDataForQort = { entries: [ENTRY_ALICE], lastUpdated: 5000 };
await syncAllAddressBooksOnStartup('TestUser');
expect(wasQortPublished()).toBe(false);
expect(getStoredData().lastUpdated).toBe(5000);

mockQortalRequest.mockClear();

// Second startup: local now has data aligned with QDN.
await syncAllAddressBooksOnStartup('TestUser');

expect(wasQortPublished()).toBe(false);
});
});

// -------------------------------------------------------------------------
// BUG: optional `updatedAt` field causes hash divergence → spurious publish
// even though the user-visible content is unchanged.
//
// Scenario: the user calls updateAddress (which stamps updatedAt on the
// entry and advances localLastUpdated). QDN was published before updatedAt
// existed, so the stored QDN hash was computed without that field.
// At the next startup: localLastUpdated > qdnLastUpdated (Path B), hashes
// differ only because of updatedAt → the code publishes unnecessarily.
// -------------------------------------------------------------------------

describe('BUG — updatedAt field in local entry causes hash mismatch with QDN', () => {
it('publishes even though only the internal updatedAt field differs (bug: should NOT publish)', async () => {
// Simulate updateAddress having been called: entry now has updatedAt and
// local timestamp is newer than QDN.
const entryWithUpdatedAt = { ...ENTRY_ALICE, updatedAt: 99999 };
setLocalStorage([entryWithUpdatedAt], 5000); // local is newer

// QDN was last published at 3000, before updatedAt was introduced.
// Its stored hash was computed from the entry WITHOUT updatedAt.
// We simulate that by computing what the QDN hash *would* have been
// (we use the sentinel approach: publish the old entry once to capture
// its real hash, then set QDN accordingly).
setLocalStorage([ENTRY_ALICE], 1000);
await syncAllAddressBooksOnStartup('TestUser'); // publishes ENTRY_ALICE
const qdnHash = localStorage.getItem(
'q-wallets-addressbook-published-QORT'
)!;
const qdnTimestamp = getStoredData().lastUpdated;
mockQortalRequest.mockClear();
localStorage.clear();

// Now restore the "post-updateAddress" local state.
setLocalStorage([entryWithUpdatedAt], qdnTimestamp + 1000); // local > QDN
qdnDataForQort = {
entries: [ENTRY_ALICE],
lastUpdated: qdnTimestamp,
hash: qdnHash,
};

await syncAllAddressBooksOnStartup('TestUser');

// BUG: the code currently DOES publish because generateHash(localEntries)
// includes updatedAt while qdnHash was computed without it.
// Once the bug is fixed (e.g. by excluding updatedAt from the hash),
// change this expectation to toBe(false).
expect(wasQortPublished()).toBe(true); // documents current (buggy) behaviour
});
});

// -------------------------------------------------------------------------
// BUG FIX: skip publish when QDN is null but content matches last publish
// (root cause 2 — QDN propagation delay)
// -------------------------------------------------------------------------

describe('BUG FIX — QDN unavailable but content matches last publish → no publish', () => {
it('does NOT publish on the second startup when content is unchanged', async () => {
// First startup: QDN is null, local has entries → publishes and records hash.
setLocalStorage([ENTRY_ALICE], 1000);
await syncAllAddressBooksOnStartup('TestUser');
expect(wasQortPublished()).toBe(true);

// Reset call recorder.
mockQortalRequest.mockClear();

// Second startup: QDN still null (propagation delay), same content.
await syncAllAddressBooksOnStartup('TestUser');

expect(wasQortPublished()).toBe(false);
});

it('DOES publish when content has changed since the last publish', async () => {
// Simulate a prior publish of ENTRY_ALICE.
setLocalStorage([ENTRY_ALICE], 1000);
await syncAllAddressBooksOnStartup('TestUser');
mockQortalRequest.mockClear();

// User adds ENTRY_BOB locally; QDN is still null.
setLocalStorage([ENTRY_ALICE, ENTRY_BOB], 2000);

await syncAllAddressBooksOnStartup('TestUser');

expect(wasQortPublished()).toBe(true);
});

it('publishes on first startup when the published-hash sentinel is absent', async () => {
// No prior publish recorded.
setLocalStorage([ENTRY_ALICE], 1000);
// qdnDataForQort remains null.

await syncAllAddressBooksOnStartup('TestUser');

expect(wasQortPublished()).toBe(true);
});
});
});
23 changes: 23 additions & 0 deletions src/utils/__tests__/addressBookStorage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,29 @@ describe('addressBookStorage', () => {
expect(parsed).toHaveProperty('entries');
expect(parsed).toHaveProperty('lastUpdated');
});

it('migration sets lastUpdated to 0 so QDN is always treated as newer', () => {
// Root cause 3 fix: old-format data must migrate with lastUpdated:0 so
// that any valid QDN timestamp (> 0) takes precedence on startup sync,
// avoiding a spurious "local is newer" publish proposal.
const oldFormatData = [
{
id: 'test-1',
name: 'Alice',
address: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa',
note: '',
coinType: Coin.BTC,
createdAt: 1000,
},
];
localStorage.setItem('q-wallets-addressbook-BTC', JSON.stringify(oldFormatData));

getAddressBook(Coin.BTC);

const stored = localStorage.getItem('q-wallets-addressbook-BTC');
const parsed = JSON.parse(stored!);
expect(parsed.lastUpdated).toBe(0);
});
});

describe('addAddress()', () => {
Expand Down
78 changes: 64 additions & 14 deletions src/utils/addressBookQDN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,25 +74,39 @@ function generateHash(entries: AddressBookEntry[]): string {
* @param entries - The address book entries to publish
* @param userName - Optional username (if not provided, will attempt to fetch)
*/
/**
* Key that records the hash of the last successfully published snapshot.
* Used to skip re-publishing when QDN is temporarily unavailable (propagation
* delay) but the local content has not changed since the last publish.
*/
function publishedHashKey(coinType: string): string {
return `q-wallets-addressbook-published-${coinType}`;
}

async function publishToQDN(
coinType: string,
entries: AddressBookEntry[],
userName?: string
): Promise<void> {
): Promise<number | null> {
try {
// Get user name from parameter or fetch it
const actualUserName = userName || (await getCurrentUserName());

if (!actualUserName) {
console.error('QDN Sync: No authenticated user found');
return;
return null;
}

// Capture the timestamp now so QDN data and the returned value share
// the same value — callers use it to align localStorage with QDN.
const lastUpdated = Date.now();
const hash = generateHash(entries);

// Prepare data object with metadata
const qdnData: AddressBookQDNData = {
entries,
lastUpdated: Date.now(),
hash: generateHash(entries),
lastUpdated,
hash,
};

// Convert to base64 (UTF-8 safe)
Expand All @@ -113,12 +127,18 @@ async function publishToQDN(
identifier: `q-wallets-addressbook-${coinType}`,
});

// Record the published hash so future startup syncs can detect when QDN
// is temporarily unavailable vs genuinely missing.
localStorage.setItem(publishedHashKey(coinType), hash);

console.log(
`QDN Sync: Published ${coinType} address book for user ${actualUserName}`
);
return lastUpdated;
} catch (error) {
console.error(`QDN Sync Error (Publish ${coinType}):`, error);
// Don't throw - allow localStorage to continue working
return null;
}
}

Expand Down Expand Up @@ -258,8 +278,30 @@ async function syncAddressBookOnStartup(
// If no QDN data exists, publish local data if any
if (!qdnData) {
if (localEntries.length > 0) {
// Root cause 2 fix: skip re-publishing when QDN is temporarily
// unavailable (propagation delay) but content hasn't changed since the
// last successful publish — prevents a repeated permission dialog.
const lastPublishedHash = localStorage.getItem(
publishedHashKey(coinType)
);
if (lastPublishedHash === generateHash(localEntries)) {
console.log(
`QDN Sync: ${coinType} QDN unavailable but content matches last publish, skipping`
);
return;
}
console.log(`QDN Sync: No QDN data, publishing local ${coinType} data`);
await publishToQDN(coinType, localEntries, userName);
const publishedAt = await publishToQDN(coinType, localEntries, userName);
if (publishedAt !== null) {
// Align local timestamp with QDN so next startup goes to the
// equal-timestamp path rather than re-entering the "local is newer" path.
const localStorageKey = `q-wallets-addressbook-${coinType}`;
const dataToStore: AddressBookLocalStorage = {
entries: localEntries,
lastUpdated: publishedAt,
};
localStorage.setItem(localStorageKey, JSON.stringify(dataToStore));
}
}
return;
}
Expand Down Expand Up @@ -314,14 +356,17 @@ async function syncAddressBookOnStartup(
console.log(
`QDN Sync: Local data is newer for ${coinType}, publishing to QDN`
);
const publishedAt = Date.now();
await publishToQDN(coinType, localEntries, userName);
// Sync local timestamp forward so next startup sees equal timestamps
const dataToStore: AddressBookLocalStorage = {
entries: localEntries,
lastUpdated: publishedAt,
};
localStorage.setItem(localStorageKey, JSON.stringify(dataToStore));
// Root cause 1 fix: use the timestamp actually stored inside QDN so
// local and QDN are identical after publish — next startup goes
// straight to the equal-timestamp path, no hash comparison needed.
const publishedAt = await publishToQDN(coinType, localEntries, userName);
if (publishedAt !== null) {
const dataToStore: AddressBookLocalStorage = {
entries: localEntries,
lastUpdated: publishedAt,
};
localStorage.setItem(localStorageKey, JSON.stringify(dataToStore));
}
}
} else {
// Same timestamp - use hash comparison if available
Expand Down Expand Up @@ -386,7 +431,12 @@ export function debouncedPublishToQDN(
clearTimeout(publishTimeouts[coinType]);
}

// Set new timeout
// Set new timeout.
// publishToQDN never throws (errors are caught internally), but keep the
// .catch() as a safety net. The returned timestamp is intentionally ignored
// here: after a successful debounced publish QDN's timestamp is always
// greater than the local lastUpdated (which was set at mutation time), so
// the next startup sync correctly treats QDN as newer and refreshes local.
publishTimeouts[coinType] = setTimeout(() => {
publishToQDN(coinType, entries).catch((err) =>
console.error('QDN Sync: Failed to publish:', err)
Expand Down
Loading