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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@
- changed: Replace the flat `ENV` singleton with separate `CONFIG`, `KEYS`/`globalKeys`, and `pluginMaps` accessors (no top-level globalKeys flatten)
- changed: WalletConnect Smart Contract Call warning restated in the solid UI4 card
- changed: WalletConnect Connect button pinned to the bottom of Confirm Connection
- fixed: Paybis and Simplex going missing from buy and sell on the launch after a slow or retried device attestation
- fixed: Missing wrong-device-clock warning on a launch that runs no device attestation
- fixed: Missing exchange rates when the device clock is set ahead
- fixed: Export Logs failing with a bare status 400 when the device clock is wrong
- fixed: A send whose funds are not spendable yet says so instead of reporting a network error
- fixed: A swap retried after a failure fetches a fresh quote instead of failing with a "closed proxy" error
- fixed: WalletConnect Bitcoin message signing returns a BIP-322 signature when the dapp requests `bip322`
Expand Down
61 changes: 56 additions & 5 deletions docs/CONFIG_KEYS_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,8 +436,10 @@ outright has nothing to wait for and simply does nothing.

The on-disk cache does **not** expire. Any mergeable `keysCache` is used as a
warm start so later launches never block on the network; a background refresh
updates the cache for the _next_ launch. `fetchedAt` may still be recorded for
diagnostics, but it does not gate the warm path. There is no TTL.
updates the cache for the _next_ launch. `fetchedAt` does not gate the warm
path, and there is no TTL. Its one use is to bound how long an attested entry is
kept over an unattested answer (see
[Which answer the cache keeps](#which-answer-the-cache-keeps)).

The baked-in file is the **base layer** of a `deepMerge`, not a wholesale
replacement, so a partial remote payload degrades gracefully instead of blanking
Expand Down Expand Up @@ -675,9 +677,58 @@ to win **this** launch:

If attestation finishes inside its budget the fetch goes out attested and
receives the full payload immediately; otherwise it goes out unattested and takes
the `default` tier, and the background refresh upgrades the cached payload for the
next launch. A feature needing a key absent from the current payload can trigger
an on-demand foreground escalation.
the `default` tier. A token that arrives later in the launch refreshes the
cache for the next one (see below).

#### A token that arrives after the fetch

The handshake can still produce a token later in the same launch: it ran past
`ATTESTATION_BUDGET_MS`, or it failed and a retry succeeded after its backoff.
Without a second request the `default` payload would stay in the cache, and the
next launch would warm-start on it with no partner keys from the attested
layers.

So a request that goes out with no token arms `refreshWhenAttested`, which
subscribes to the attestation engine (`onAttestationToken`). When a token
arrives it fetches again and writes the answer to the cache for the next launch.
The rules it follows:

- It writes the cache only. The running `KEYS` and plugins keep the payload the
launch started on, the same rule as every other background refresh.
- It subscribes at most once per launch and stays subscribed until the answer to
a request that carried a token is in the cache. The engine reports every new
token, so a refetch that fails (network error, 5xx) is tried again at the
engine's next token instead of leaving the cache on `default` for the rest of
the launch.
- One refetch runs at a time. A token that arrives while a refetch is in flight
does not start a second one.

#### Which answer the cache keeps

The cache entry records whether its request carried a token (`attested`) next to
`fetchedAt`. `cacheKeys` reads the entry before every write and applies one
rule: the answer to a request without a token does not replace an attested
entry that is younger than `ATTESTED_CACHE_HOLD_MS` (3 days) and still merges.

That rule covers three cases:

- **A launch that misses the budget on a device that still attests.** The
handshake was slow, failed, or was rate limited (a 429 backs off for at least
60 seconds and up to 30 minutes), and the user may close the app before the
retry lands. Without the rule the `default` answer would be cached at once and
the next launch would start without the attested partner keys.
- **Two requests in flight at once.** The first request and the late-token
refetch can overlap. `writeKeysCache` updates the in-memory entry before it
touches disk, so whichever answer lands second sees the first, and the
attested one is kept in either order.
- **A device that can no longer attest.** Only an attested answer renews
`fetchedAt`, so once the entry is 3 days old the `default` answer replaces
it. Old attested keys are not served from disk indefinitely.

An entry written before the `attested` field existed counts as unattested, and
so does one that no longer merges, so any answer replaces either. The age is
measured in both directions: an entry stamped later than the current clock by
more than the hold (the clock has since been set back) is not kept.

### Where the cache lives

Expand Down
4 changes: 3 additions & 1 deletion src/__tests__/actions/DeviceSettingsActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ describe('patchDeviceSettings', () => {
const written = writeKeysCache({
keys: { EDGE_API_KEY: 'k' },
fetchedAt: 1,
assuranceLevel: 'default'
assuranceLevel: 'default',
attested: false
})

openGate()
Expand All @@ -87,6 +88,7 @@ describe('patchDeviceSettings', () => {
expect(file.themeMode).toBe('light')
expect(file.defaultScreen).toBe('assets')
expect(file.keysCache?.assuranceLevel).toBe('default')
expect(file.keysCache?.attested).toBe(false)
})

it('boots on defaults when the read hangs, then applies the late read', async () => {
Expand Down
18 changes: 18 additions & 0 deletions src/__tests__/actions/ExchangeRateActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from '@jest/globals'
import {
convertToRatesParams,
type ExchangeRateCache,
getServerNow,
mergePairCache
} from '../../actions/ExchangeRateActions'

Expand Down Expand Up @@ -179,3 +180,20 @@ describe('convertToRatesParams', () => {
)
})
})

describe('getServerNow', () => {
const deviceNow = Date.parse('2026-10-09T12:00:00.000Z')

it('reads the server clock from the Date header', () => {
// The device clock is two days ahead here. A rate the server stamped a
// moment ago is current by the server clock, but two days old by the
// device clock, which would file it as historical.
const serverNow = getServerNow('Wed, 07 Oct 2026 12:00:00 GMT', deviceNow)
expect(serverNow).toBe(Date.parse('2026-10-07T12:00:00.000Z'))
})

it('falls back to the device clock without a usable header', () => {
expect(getServerNow(null, deviceNow)).toBe(deviceNow)
expect(getServerNow('not a date', deviceNow)).toBe(deviceNow)
})
})
60 changes: 60 additions & 0 deletions src/__tests__/actions/LogActions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { afterEach, describe, expect, it, jest } from '@jest/globals'

import {
type LogOutput,
LogsClockError,
sendLogs
} from '../../actions/LogActions'
import { lstrings } from '../../locales/strings'

const makeLog = (): LogOutput => ({
isoDate: '2026-10-09T12:00:00.000Z',
uniqueId: 'abc123_info',
userMessage: '',
deviceInfo: 'test device',
appVersion: '0.0.0',
OS: 'test',
accounts: [],
data: ''
})

const mockFetch = (status: number, body: string): void => {
const response = {
ok: status >= 200 && status < 300,
status,
text: async () => body
}
global.fetch = jest.fn(async () => response) as unknown as typeof fetch
}

describe('sendLogs', () => {
const realFetch = global.fetch
afterEach(() => {
global.fetch = realFetch
})

it('resolves when the logs server accepts the upload', async () => {
mockFetch(200, '')
await expect(sendLogs(makeLog(), false)).resolves.toBeUndefined()
})

it('names the device clock when the server says the time is out of sync', async () => {
mockFetch(400, 'Time Out of Sync')
const error = await sendLogs(makeLog(), false).catch(
(error: unknown) => error
)
expect(error).toBeInstanceOf(LogsClockError)
expect((error as Error).message).toBe(
lstrings.settings_modal_send_logs_clock_error
)
})

it('keeps the status code for any other refusal', async () => {
mockFetch(400, 'Missing Request fields.')
const error = await sendLogs(makeLog(), false).catch(
(error: unknown) => error
)
expect(error).not.toBeInstanceOf(LogsClockError)
expect((error as Error).message).toContain('returned status 400')
})
})
20 changes: 20 additions & 0 deletions src/__tests__/util/attestation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const {
attestedJsonHeaders,
getAttestationToken,
initAttestation,
maybeWarnClockSkew,
onAttestationToken,
resetAttestationForTests
} = require('../../util/attestation')
Expand Down Expand Up @@ -676,6 +677,25 @@ describe('attestation engine', () => {
expect(mockShowButtonsModal.mock.calls.length).toBe(0)
})

it('warns from an HTTP Date header, with no handshake involved', () => {
const serverDate = new Date(Date.now() - 2 * 86_400_000).toUTCString()

maybeWarnClockSkew(serverDate)
expect(mockShowButtonsModal.mock.calls.length).toBe(1)
expect(mockFetchInfo.mock.calls.length).toBe(0)

// Same once-per-day latch as the handshake's own readings.
maybeWarnClockSkew(serverDate)
expect(mockShowButtonsModal.mock.calls.length).toBe(1)
})

it('ignores a server time it cannot read', () => {
maybeWarnClockSkew(undefined)
maybeWarnClockSkew('not a date')
maybeWarnClockSkew(new Date(Date.now() - 5000).toUTCString())
expect(mockShowButtonsModal.mock.calls.length).toBe(0)
})

it('does not warn for a few seconds of skew', async () => {
const slightlyOff = new Date(Date.now() - 5 * 1000).toISOString()
mockFetchInfo.mockImplementation(async (path: string) => {
Expand Down
59 changes: 50 additions & 9 deletions src/__tests__/util/keysServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, it, jest } from '@jest/globals'
import type { EdgeFetchFunction } from 'edge-core-js'

import { signHmacAuthorization } from '../../util/hmacAuth'
import { fetchRemoteKeys } from '../../util/keysServer'
import { fetchRemoteKeys, RemoteKeysError } from '../../util/keysServer'

const apiKey = 'test-api-key'
const secret = new Uint8Array(
Expand All @@ -28,6 +28,19 @@ const makeOkResponse = (body: unknown): Response =>
text: async () => JSON.stringify(body)
} as unknown as Response)

const makeErrorResponse = (
status: number,
text: string,
headers: Record<string, string> = {}
): Response =>
({
ok: false,
status,
headers: { get: (name: string) => headers[name.toLowerCase()] ?? null },
json: async () => ({}),
text: async () => text
} as unknown as Response)

describe('fetchRemoteKeys', () => {
it('requests v1/infoRollup with a URL-encoded appId', async () => {
const infoFetch = jest.fn<EdgeFetchFunction>(async () =>
Expand Down Expand Up @@ -153,21 +166,49 @@ describe('fetchRemoteKeys', () => {
})

it('throws with the status on a non-OK response', async () => {
const infoFetch = jest.fn<EdgeFetchFunction>(
async () =>
({
ok: false,
status: 503,
json: async () => ({}),
text: async () => 'unavailable'
} as unknown as Response)
const infoFetch = jest.fn<EdgeFetchFunction>(async () =>
makeErrorResponse(503, 'unavailable')
)

await expect(
fetchRemoteKeys({ apiKey, secret, appId: 'edge', infoFetch, ...query })
).rejects.toThrow('fetchRemoteKeys 503: unavailable')
})

it('carries the status and the server Date header on a refusal', async () => {
const serverDate = 'Wed, 07 Oct 2026 19:55:14 GMT'
const infoFetch = jest.fn<EdgeFetchFunction>(async () =>
makeErrorResponse(401, 'Unauthorized', { date: serverDate })
)

const error: unknown = await fetchRemoteKeys({
apiKey,
secret,
appId: 'edge',
infoFetch,
...query
}).catch((e: unknown) => e)

expect(error).toBeInstanceOf(RemoteKeysError)
expect(error).toMatchObject({ status: 401, serverDate })
})

it('leaves the server date unset when the response has no Date header', async () => {
const infoFetch = jest.fn<EdgeFetchFunction>(async () =>
makeErrorResponse(401, 'Unauthorized')
)

const error: unknown = await fetchRemoteKeys({
apiKey,
secret,
appId: 'edge',
infoFetch,
...query
}).catch((e: unknown) => e)

expect(error).toMatchObject({ status: 401, serverDate: undefined })
})

it('rejects a malformed body where appKeys is a string', async () => {
const infoFetch = jest.fn<EdgeFetchFunction>(async () =>
makeOkResponse({ appKeys: 'not-an-object' })
Expand Down
Loading
Loading