Skip to content

Native HMAC apiSigner and signed infoRollup appKeys - #6183

Open
paullinator wants to merge 31 commits into
developfrom
paul/nativeHmacApiSigner
Open

paullinator wants to merge 31 commits into
developfrom
paul/nativeHmacApiSigner

Conversation

@paullinator

@paullinator paullinator commented Aug 29, 2026 •

Copy link
Copy Markdown
Member

CHANGELOG

Does this branch warrant an entry to the CHANGELOG?

  • Yes
  • No

Dependencies

Requirements

If you have made any visual changes to the GUI. Make sure you have:

  • Tested on iOS device
  • Tested on Android device
  • Tested on small-screen device (iPod Touch)
  • Tested on large-screen device (tablet)

Description

Splits runtime env.json into non-secret config.json and secret keys.json, with four plugin maps (corePlugins, swapPlugins, guiApiKeys, rampPlugins) plus globalKeys. WalletConnect is globalKeys.WALLETCONNECT_PROJECT_ID. Plugin maps are opaque objects (no field-by-field *_INIT / *_API_KEY flattening).

Fetches remote secrets from signed GET /v1/infoRollup/:appId as sibling appKeys (partner id config.appId ?? 'edge'), with DeviceSettings getKeysCache and baked-in keys.json as fallbacks. pluginApiKeys.posthog is never served.

Adds a native Edge API HMAC signer (edgeKey.json + XOR-split C shards) so login-server requests can be signed outside the JS bundle via apiSigner, with JS KEYS.EDGE_API_* remaining as a fallback.

Rebased onto current develop (keeps Swapter and marketing-push tracking).


Note

High Risk
Touches authentication (native HMAC, infoRollup signing), secret handling, and cold-start key resolution before the core loads—failures or merge bugs could break login, plugins, or first launch.

Overview
Replaces the monolithic gitignored env.json with config.json (non-secret settings), keys.json (secrets and plugin init material), and build-only edgeKey.json for Edge login HMAC. Runtime code uses CONFIG, mutable KEYS / globalKeys, and pluginMaps instead of a flat ENV singleton; plugin settings move to ID-keyed maps (corePlugins, swapPlugins, guiApiKeys, rampPlugins).

Adds a native Edge API HMAC signer (XOR-split C shards from edgeKey.json, Android JNI + iOS bridge) wired into core as apiSigner, with Gradle/Xcode generate steps and JS KEYS.EDGE_API_* fallback when the module is absent.

Boot can fetch signed GET /v1/infoRollup/:appId appKeys, merge overlays into KEYS / pluginMaps (DeviceSettings cache + baked keys.json fallbacks), and gate Edge core startup until initializeKeys() resolves. Deploy and prepare paths, migration scripts (split-env-json, slim/server key split), and docs were retargeted; legacy deploy envJson overrides are ignored on this branch.

Reviewed by Cursor Bugbot for commit 78d8225. Bugbot is set up for automated code reviews on this repo. Configure here.

@paullinator
paullinator force-pushed the paul/nativeHmacApiSigner branch 4 times, most recently from c8f9826 to 44a9d19 Compare August 31, 2026 23:52
@paullinator
paullinator marked this pull request as ready for review September 1, 2026 16:24
@paullinator
paullinator force-pushed the paul/nativeHmacApiSigner branch from 44a9d19 to 568056f Compare September 5, 2026 00:20

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Token-only API key ignored
    • buildContextOptions now forwards a usable EDGE_API_KEY to MakeEdgeContext even when EDGE_API_SECRET is missing, so core can use legacy Token auth.

Create PR

Or push these changes by commenting:

@cursor push 61a8982da7
Preview (61a8982da7)
diff --git a/src/components/services/EdgeCoreManager.tsx b/src/components/services/EdgeCoreManager.tsx
--- a/src/components/services/EdgeCoreManager.tsx
+++ b/src/components/services/EdgeCoreManager.tsx
@@ -123,10 +123,12 @@
   const { EDGE_API_KEY: apiKey, EDGE_API_SECRET: apiSecret } = KEYS
   const nativeKey = hasNativeApiSigner() ? await warmNativeApiKey() : ''
   const nativeApiSigner = nativeKey !== '' ? makeNativeApiSigner() : undefined
-  const jsPair =
-    isUsableApiKey(apiKey) && apiSecret != null && apiSecret.byteLength > 0
+  // Token-only keys are valid: core uses `Authorization: Token {apiKey}`.
+  const jsPair = isUsableApiKey(apiKey)
+    ? apiSecret != null && apiSecret.byteLength > 0
       ? { apiKey, apiSecret }
-      : undefined
+      : { apiKey }
+    : undefined
   console.log(
     `[apiSigner] native=${nativeApiSigner != null} keysFallback=${
       jsPair != null

You can send follow-ups to the cloud agent here.

Comment thread src/components/services/EdgeCoreManager.tsx

@j0ntz j0ntz left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of this PR together with its two dependencies, edge-core-js#739 and edge-info-server#163. The HMAC design itself checks out: I compiled native/edge-api-signer/edge_hmac.c standalone and it matches SHA-256("abc"), SHA-256(""), SHA-256(1e6 x 'a'), and RFC 4231 HMAC cases 1, 2, 3, 6 and 7, including the >block-size key path, clean under -Wall -Wextra. The signed string also lines up end to end: keysServer.signPath builds /v1/infoRollup/... and the server verifies req.originalUrl under the /v1 mount (src/indexInfo.ts:44), with a shared test vector on both sides.

Two repo-wide items with no single line to hang them on:

  1. Em dashes (U+2014) in committed code and comments. 68 added lines carry one, 28 of them outside docs/: scripts/makeApiSigner.ts (6, including the auto-generated ... do not edit banner that ends up in the generated native C), scripts/splitEnvJson.ts (4), src/util/keysStore.ts (4), src/keys.ts (2), src/util/edgeApiSigner.ts (2), scripts/splitBakedAndServerKeys.js (2), plus one each in configKeysMerge.ts, configKeysSchema.ts, initializeProviders.ts, types/types.ts, network.ts, tracking.ts, makeNativeHeaders.ts, and 41 in docs/. A comma, colon, or parentheses reads the same. Ruleset: https://github.com/EdgeApp/edge-dev-agents/blob/main/.cursor/skills/no-slop/SKILL.md

  2. Commit hygiene. 5707c51c0 has a 68-character subject ("Log appKeys LAYER sentinels and native HMAC signer status on launch."); the cap is 50. Something like "Log keys tier and signer status on launch" fits.

Comment thread src/hooks/useRampPlugins.ts Outdated
const initOptions = pluginMaps.rampPlugins[pluginId]

// If there is no init option defined for the plugin, simply skip over it
if (initOptions == null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard still only rejects null, but the new merge can hand back a bare true.

mergePluginInit(true, undefined) returns true (src/configKeysMerge.ts:88), which is the "enabled in config.json, no credentials on the keys side yet" state. That is the shipped state on any build where slimKeysJson stripped the plugin maps and the signed appKeys fetch has not landed. true is not null, so it flows into factory(config) as initOptions, and asInitOptions(true) throws inside the plugin's asObject cleaner (e.g. banxaRampPlugin.ts:596, paybisRampPlugin.ts:470). The per-plugin catch only console.warns, so the ramp option disappears from buy/sell for the whole session, and the effect deps are [account, navigation, dispatch] so it never retries.

initializeProviders.ts:47 in this same PR added exactly this guard for guiApiKeys, with a comment explaining the case. The same test belongs here:

if (initOptions == null || typeof initOptions === 'boolean') {
  continue
}

(The boolean case only bites GUI-side loaders. Core normalizes true to {} at plugins-actions.ts:82, so corePlugins.ts is fine as written.)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in f152e78: the guard skips a boolean entry as well as null. A related case (ramp inits stored as {}) is in this review.

Comment thread src/util/edgeApiSigner.ts Outdated
async signMessage(message: string) {
const signed = asSignedMessage(await module.signMessage(message))
// Stub builds embed a placeholder that is not a valid Authorization value.
if (!isUsableApiKey(signed.apiKey) || signed.signature === '') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isUsableApiKey rejects whitespace in apiKey, but signature is only checked for emptiness, and it goes straight into Authorization: HMAC ${apiKey} ${signature} in keysServer.ts. A native binding that returned a signature with a stray space, tab, or CR/LF would produce a header the server cannot split into three parts, and the failure would look like a signing mismatch rather than a malformed header.

The JS path cannot produce this (it is base64.stringify(...)), so the native delegate is the first place an unvalidated string reaches the header. Cheap to close:

if (!isUsableApiKey(signed.apiKey) || !isUsableApiKey(signed.signature)) {

edge-core-js#739 has the same asymmetry at login-fetch.ts:34.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 2fe55f9: the signature now goes through isUsableApiKey too.

String(error)
)
try {
setContextOptions(await buildContextOptions())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fallback re-runs the identical call with identical inputs.

The comment says initializeKeys itself never rejects, which leaves buildContextOptions() as the only thing in the try that can throw. Calling it a second time with nothing changed in between will throw again for the same reason, so the catch adds a duplicate [apiSigner] log line and a second warmNativeApiKey round trip and then lands in the inner catch anyway.

If the goal is "boot with baked-in plugins when the keys store fails", the retry needs to differ from the first attempt (skip the native signer, for instance). Otherwise the outer try/catch can collapse to the inner one.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed: at fac86e7 the boot path is a single try/catch around initializeKeys and buildContextOptions, with no retry.

if (bootFatalError != null) {
return (
<View style={{ flex: 1, justifyContent: 'center', padding: 24 }}>
<Text>Edge failed to start: {bootFatalError}</Text>

@j0ntz j0ntz Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The string is hardcoded English rather than lstrings.*, and the style is inline instead of cacheStyles/getStyles.

The modal this replaced at least pulled lstrings.string_ok_cap. lstrings is a plain import with no dependency on Providers or Airship, so it is available here. A boot_failed_message_1s key plus a getStyles entry keeps this consistent with the rest of the app.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 48a56d8 and 61d6214: the message comes from lstrings.boot_failed_message_1s and the styles from cacheStyles.

? { apiKey, apiSecret }
: undefined
console.log(
`[apiSigner] native=${nativeApiSigner != null} keysFallback=${

@j0ntz j0ntz Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unguarded console.log in production, same as [keys] tier=... at keysStore.ts:428. Both are useful when checking a device run, so rather than dropping them, route them through the category logger the way Phaze does:

debugLog('keys', `tier=${keysTier} assurance=${assuranceLevel ?? 'none'} ...`)
debugLog('keys', `apiSigner native=${nativeApiSigner != null} keysFallback=${jsPair != null}`)

debugLog (src/util/logger.ts) is silent unless keys is in LOG_CONFIG.enabledCategories in config.json, and enableLogCategory('keys') turns it on at runtime. The console.error below reports a real misconfiguration and should stay as is.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 16544a6: both lines now go through debugLog('keys', ...).

Comment thread src/actions/NotificationActions.ts Outdated
}

async function legacyGet(path: string) {
async function legacyGet(path: string): Promise<any> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New explicit any return type. fetchPush returns an EdgeFetchResponse, so this is Promise<unknown> at worst, and the one caller (fetchLegacySettings) already declares the concrete shape { '1': boolean; '24': boolean; fallbackSettings?: boolean }. Returning Promise<unknown> and cleaning at the call site, or hoisting that shape into a cleaner, keeps the type checker on.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in b5ce923: legacyGet returns unknown, and the callers clean the responses with asV1Settings and asLegacySettings.

Comment thread src/configKeysMerge.ts Outdated
* Truncate a single secret to its first 8 characters so it can be shown for
* debugging without leaking the full value. Non-strings are returned as-is.
*/
export function redactKey(value: unknown): unknown {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

redactKey has no callers outside src/__tests__/configKeysMerge.test.ts, and its body is the same scalar branch redactValue already implements two functions down. Worth deleting along with its test case, so a future change to the truncation length has one place to land.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 67ee654: redactKey and its test are removed.

@paullinator
paullinator force-pushed the paul/nativeHmacApiSigner branch from 568056f to 0535766 Compare September 21, 2026 18:24

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

Claude Code Review is paused for this repository. To reconnect it, an admin of this repository's GitHub organization (or the account owner, for personal repositories) who can also manage your Claude organization's Code Review settings needs to re-link GitHub in Code Review settings. This is a one-time step.

Tip: disable this comment in your organization's Code Review settings.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Signed rollup omits rollupRaw
    • applyPublicRollup now stores the pre-cleaner payload on infoServerData.rollupRaw so signed fetches keep unknown public fields such as giftCardInfo.

Create PR

Or push these changes by commenting:

@cursor push 987a73754b
Preview (987a73754b)
diff --git a/src/util/keysStore.ts b/src/util/keysStore.ts
--- a/src/util/keysStore.ts
+++ b/src/util/keysStore.ts
@@ -177,6 +177,7 @@
     console.warn('initializeKeys: signed infoRollup failed to clean')
     return
   }
+  infoServerData.rollupRaw = raw
   infoServerData.rollup = cleaned
   // `queryInfo` runs this on the unsigned path. Without it here, builds that
   // take the signed path would defer the force-upgrade check by up to one

You can send follow-ups to the cloud agent here.

Comment thread src/util/keysStore.ts
@paullinator
paullinator force-pushed the paul/nativeHmacApiSigner branch from 0535766 to b1b87a0 Compare September 21, 2026 20:02

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: False overlay disables plugin secrets
    • Per-plugin false values are now stripped from remote/cache overlays before deepMerge so they cannot replace baked-in secrets or disable providers.

Create PR

Or push these changes by commenting:

@cursor push 851aab64df
Preview (851aab64df)
diff --git a/src/__tests__/configKeysMerge.test.ts b/src/__tests__/configKeysMerge.test.ts
--- a/src/__tests__/configKeysMerge.test.ts
+++ b/src/__tests__/configKeysMerge.test.ts
@@ -6,6 +6,7 @@
   asMergeableKeys,
   deepMerge,
   nestGlobalKeys,
+  omitFalsePluginEntries,
   redactValue,
   resolvePluginMaps
 } from '../configKeysMerge'
@@ -132,6 +133,32 @@
   })
 })
 
+describe('omitFalsePluginEntries', () => {
+  it('drops per-plugin false so deepMerge cannot wipe baked-in secrets', () => {
+    expect(
+      omitFalsePluginEntries({
+        guiApiKeys: { moonpay: false, banxa: { apiKey: 'k' } },
+        swapPlugins: { thorchain: false },
+        corePlugins: { bitcoin: { nowNodesApiKey: 'abc' } },
+        rampPlugins: { infinite: false },
+        globalKeys: { COINGECKO_API_KEY: 'cg' }
+      })
+    ).toEqual({
+      guiApiKeys: { banxa: { apiKey: 'k' } },
+      swapPlugins: {},
+      corePlugins: { bitcoin: { nowNodesApiKey: 'abc' } },
+      rampPlugins: {},
+      globalKeys: { COINGECKO_API_KEY: 'cg' }
+    })
+  })
+
+  it('leaves maps without false entries untouched', () => {
+    const keys = { guiApiKeys: { moonpay: 'm' } }
+    expect(omitFalsePluginEntries(keys)).toEqual(keys)
+    expect(omitFalsePluginEntries(keys).guiApiKeys).toBe(keys.guiApiKeys)
+  })
+})
+
 describe('resolvePluginMaps', () => {
   it('uses the keys object when config enables a plugin with true', () => {
     const config = { corePlugins: { bitcoin: true } }

diff --git a/src/__tests__/util/keysStore.test.ts b/src/__tests__/util/keysStore.test.ts
--- a/src/__tests__/util/keysStore.test.ts
+++ b/src/__tests__/util/keysStore.test.ts
@@ -310,6 +310,36 @@
     )
   })
 
+  it('does not let a false plugin overlay wipe baked-in secrets', async () => {
+    mockFetchRemoteKeys.mockResolvedValue({
+      keys: {
+        guiApiKeys: { moonpay: false },
+        globalKeys: { AZTECO_API_KEY: 'from-remote' }
+      },
+      assuranceLevel: 'unattested'
+    })
+
+    const { keysStore, keys, pluginMaps } = freshModules()
+    await keysStore.initializeKeys()
+
+    expect(keysStore.getKeysTier()).toBe('remote')
+    expect(keys.globalKeys.AZTECO_API_KEY).toBe('from-remote')
+    expect((keys.KEYS.guiApiKeys as { moonpay?: unknown }).moonpay).toBe(
+      'baked-moonpay'
+    )
+    expect(
+      (pluginMaps.pluginMaps.guiApiKeys as { moonpay?: unknown }).moonpay
+    ).toBe('baked-moonpay')
+    expect(mockWriteKeysCache).toHaveBeenCalledWith(
+      expect.objectContaining({
+        keys: {
+          globalKeys: { AZTECO_API_KEY: 'from-remote' },
+          guiApiKeys: {}
+        }
+      })
+    )
+  })
+
   it('keeps unrelated baked-in secrets when the remote payload is partial', async () => {
     mockFetchRemoteKeys.mockResolvedValue({
       keys: { globalKeys: { AZTECO_API_KEY: 'from-remote' } },

diff --git a/src/configKeysMerge.ts b/src/configKeysMerge.ts
--- a/src/configKeysMerge.ts
+++ b/src/configKeysMerge.ts
@@ -82,6 +82,35 @@
 }
 
 /**
+ * Drop per-plugin `false` from a keys overlay. Keys carry secrets, never kill
+ * switches: `mergePluginInit` already ignores `false`, but `applyKeys`
+ * deep-merges the overlay into KEYS first, and `deepMerge` replaces on type
+ * mismatch. Leaving `false` in would wipe a baked-in object or string and
+ * leave providers with the `true` sentinel they skip.
+ */
+export function omitFalsePluginEntries(
+  keys: Record<string, unknown>
+): Record<string, unknown> {
+  const out: Record<string, unknown> = { ...keys }
+  for (const field of KEYS_PAYLOAD_MAP_FIELDS) {
+    if (field === 'globalKeys') continue
+    const value = out[field]
+    if (!isPlainObject(value)) continue
+    const filtered: Record<string, unknown> = {}
+    let dropped = false
+    for (const [id, entry] of Object.entries(value)) {
+      if (entry === false) {
+        dropped = true
+        continue
+      }
+      filtered[id] = entry
+    }
+    if (dropped) out[field] = filtered
+  }
+  return out
+}
+
+/**
  * Combine the config-side enablement flag with the keys-side value for one
  * plugin ID across corePlugins, swapPlugins, guiApiKeys, and rampPlugins.
  */

diff --git a/src/util/keysStore.ts b/src/util/keysStore.ts
--- a/src/util/keysStore.ts
+++ b/src/util/keysStore.ts
@@ -12,7 +12,8 @@
   asMergeableKeys,
   deepMerge,
   isPlainObject,
-  nestGlobalKeys
+  nestGlobalKeys,
+  omitFalsePluginEntries
 } from '../configKeysMerge'
 import { asKeysJson, type RuntimeKeys } from '../configKeysSchema'
 import { applyRuntimeKeys, bakedKeys, globalKeys, KEYS } from '../keys'
@@ -154,7 +155,7 @@
   }
 
   const nestedOverlay = nestGlobalKeys(
-    stripLocalOnlyFields(keepKeysFields(mergeable))
+    omitFalsePluginEntries(stripLocalOnlyFields(keepKeysFields(mergeable)))
   )
   const mergedKeys = deepMerge(bakedKeys, nestedOverlay) as RuntimeKeys
   applyRuntimeKeys(
@@ -265,7 +266,9 @@
   let overlay: Record<string, unknown>
   try {
     overlay = nestGlobalKeys(
-      stripLocalOnlyFields(keepKeysFields(asMergeableKeys(result.keys)))
+      omitFalsePluginEntries(
+        stripLocalOnlyFields(keepKeysFields(asMergeableKeys(result.keys)))
+      )
     )
   } catch (error: unknown) {
     console.warn(

You can send follow-ups to the cloud agent here.

Comment thread src/util/keysStore.ts
@paullinator
paullinator force-pushed the paul/nativeHmacApiSigner branch from b1b87a0 to cbc1984 Compare September 21, 2026 21:31

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread src/util/keysStore.ts
@paullinator
paullinator force-pushed the paul/nativeHmacApiSigner branch from cbc1984 to 0660bd7 Compare September 21, 2026 21:35

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Deploy overrides ignore false flags
    • Deploy config.json branch overrides now use a nested merge that treats false as a real disable, so flags like guiApiKeys.phaze: false win over a true base value.

Create PR

Or push these changes by commenting:

@cursor push eed84c9d29
Preview (eed84c9d29)
diff --git a/scripts/deploy.ts b/scripts/deploy.ts
--- a/scripts/deploy.ts
+++ b/scripts/deploy.ts
@@ -3,7 +3,7 @@
 import { join } from 'path'
 import { sprintf } from 'sprintf-js'
 
-import { deepMerge } from '../src/configKeysMerge'
+import { deepMerge, deepMergeOverrides } from '../src/configKeysMerge'
 import { deleteOldDirsSync } from './cleanDirectories'
 
 const BUILD_ARCHIVE_MONTHS = 6
@@ -174,17 +174,21 @@
 /**
  * Deep-merge a branch's override object into the file contents, or return the
  * file unchanged when this branch has nothing to say.
+ *
+ * Config uses `deepMergeOverrides` so `false` can disable a plugin or flag.
+ * Keys keep `deepMerge` so a `false` / `{}` overlay cannot wipe credentials.
  */
 function applyBranchOverrides(
   file: Record<string, unknown> | undefined,
   overridesByBranch: Record<string, object> | undefined,
   branch: string,
-  fileLabel: string
+  fileLabel: string,
+  merge: (a: unknown, b: unknown) => unknown
 ): Record<string, unknown> | undefined {
   const overrides = overridesByBranch?.[branch]
   if (overrides == null) return file
   if (file == null) throw new Error(`${fileLabel} file is missing`)
-  return deepMerge(file, overrides) as Record<string, unknown>
+  return merge(file, overrides) as Record<string, unknown>
 }
 
 function makeCommonPost(buildObj: BuildObj): void {
@@ -228,13 +232,15 @@
     configJson,
     buildObj.configJson,
     buildObj.repoBranch,
-    'config.json'
+    'config.json',
+    deepMergeOverrides
   )
   keysJson = applyBranchOverrides(
     keysJson,
     buildObj.keysJson,
     buildObj.repoBranch,
-    'keys.json'
+    'keys.json',
+    deepMerge
   )
   if (buildObj.maestroBuild) {
     if (configJson == null) throw new Error('config.json file is missing')

diff --git a/src/__tests__/configKeysMerge.test.ts b/src/__tests__/configKeysMerge.test.ts
--- a/src/__tests__/configKeysMerge.test.ts
+++ b/src/__tests__/configKeysMerge.test.ts
@@ -5,6 +5,7 @@
 import {
   asMergeableKeys,
   deepMerge,
+  deepMergeOverrides,
   mergePluginInit,
   nestGlobalKeys,
   redactValue,
@@ -73,6 +74,32 @@
   })
 })
 
+describe('deepMergeOverrides', () => {
+  // Deploy configJson branch blocks use false to disable a plugin or flag.
+  // That must win even when the copied config.json still has true.
+  it('lets a false override disable an enabled flag', () => {
+    expect(deepMergeOverrides(true, false)).toBe(false)
+    expect(
+      deepMergeOverrides(
+        { guiApiKeys: { banxa: true, phaze: true } },
+        { guiApiKeys: { phaze: false } }
+      )
+    ).toEqual({ guiApiKeys: { banxa: true, phaze: false } })
+  })
+
+  it('still merges nested objects field-by-field', () => {
+    expect(
+      deepMergeOverrides(
+        { swapPlugins: { changelly: true }, BETA_FEATURES: false },
+        { swapPlugins: { thorchain: true }, BETA_FEATURES: true }
+      )
+    ).toEqual({
+      swapPlugins: { changelly: true, thorchain: true },
+      BETA_FEATURES: true
+    })
+  })
+})
+
 describe('a keys overlay never destroys a baked value', () => {
   // A remote infoRollup overlay or a stale disk cache can carry `false` or
   // `{}` for a plugin. Neither may wipe the credential that shipped in the

diff --git a/src/configKeysMerge.ts b/src/configKeysMerge.ts
--- a/src/configKeysMerge.ts
+++ b/src/configKeysMerge.ts
@@ -98,6 +98,28 @@
 }
 
 /**
+ * Recursively merge two values for deploy-config branch overrides. `b` wins
+ * on conflict, including `false`: that is how a branch disables a plugin or
+ * flag. Unlike `deepMerge`, `false` is a real override, not "no opinion".
+ *
+ * Plain objects are merged field-by-field; arrays and other primitives are
+ * replaced wholesale.
+ */
+export function deepMergeOverrides(a: unknown, b: unknown): unknown {
+  if (b === undefined) return a
+  if (a === undefined) return b
+  if (isPlainObject(a) && isPlainObject(b)) {
+    const out: Record<string, unknown> = { ...a }
+    for (const key of Object.keys(b)) {
+      if (FORBIDDEN_MERGE_KEYS.has(key)) continue
+      out[key] = deepMergeOverrides(a[key], b[key])
+    }
+    return out
+  }
+  return b
+}
+
+/**
  * Combine the config-side enablement flag with the keys-side value for one
  * plugin ID across corePlugins, swapPlugins, guiApiKeys, and rampPlugins.
  */

You can send follow-ups to the cloud agent here.

Comment thread scripts/deploy.ts
@cursor

cursor Bot commented Sep 21, 2026

Copy link
Copy Markdown

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Overlay primitives wipe plugin secrets
    • asMergeableKeys now rejects non-object/non-string plugin entries so overlay false/null cannot deep-merge over baked secrets, and mergePluginInit treats null like false so keys cannot disable a plugin.

Create PR

Or push these changes by commenting:

@cursor push 66daa879d3
Preview (66daa879d3)
diff --git a/.gitignore b/.gitignore
--- a/.gitignore
+++ b/.gitignore
@@ -8,10 +8,11 @@
 /android/app/google-services.json
 /android/google-java-format-*.jar
 /deploy-config.json
+/edgeKey.json
 /env.json
 /config.json
 /keys.json
-keys.*.json
+/keys.*.json
 /fastlane.json
 /ios/edge/GoogleService-Info.plist
 /ios/Pods/
@@ -20,6 +21,7 @@
 android-release.bundle.map
 ios-release.bundle.map
 keystores/
+/.edgeApiSigner.stamp
 
 # Debugging
 overrideTheme.json
@@ -38,6 +40,13 @@
 # Generated headers
 /android/app/src/main/java/co/edgesecure/app/EdgeApiKey.java
 /ios/EdgeApiKey.swift
+/ios/EdgeApiSecret.c
+/ios/EdgeApiSecret.h
+/android/app/src/main/cpp/edge_api_secret.c
+/android/app/src/main/cpp/edge_api_secret.h
+/vendor/*.tgz
+/vendor/edge-core-js-*.tgz
+/*.tgz
 
 # Checkpoint jsons
 /android/app/src/main/assets/saplingtree/

diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,8 +14,9 @@
 ## 4.51.0 (staging)
 
 - added: Robinhood Chain wallets
+- added: Native Edge API HMAC signer (`edgeKey.json` + XOR-split C shards) so login-server requests can be signed outside the JS bundle via `apiSigner`, with JS `KEYS.EDGE_API_*` remaining as a fallback.
 - added: Push info-server attestation tokens into edge-core-js via `setAttestationToken` so the login server can skip CAPTCHA for attested devices, and allow `LOGIN_SERVER` / `INFO_SERVER` env overrides for local E2E stacks.
-- added: Remote `GET /v1/getKeys` fetch so plugin secrets can rotate without an app release, with DeviceSettings cache and baked-in `keys.json` fallback
+- added: Remote signed `GET /v1/infoRollup/:appId` `appKeys` fetch so plugin secrets can rotate without an app release, with DeviceSettings cache and baked-in `keys.json` fallback
 - added: App/device attestation for gated info-server requests
 - added: Swapter swap provider
 - added: "-m" tag on the version number in the Help scene for Maestro test builds

diff --git a/android/app/build.gradle b/android/app/build.gradle
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -103,8 +103,21 @@
         ndk {
             abiFilters 'armeabi-v7a', 'arm64-v8a' // Exclude Intel
         }
+        externalNativeBuild {
+            cmake {
+                cppFlags ""
+                arguments "-DANDROID_STL=c++_shared"
+            }
+        }
     }
 
+    externalNativeBuild {
+        cmake {
+            path file("src/main/cpp/CMakeLists.txt")
+            version "3.22.1"
+        }
+    }
+
     // Edge addition: sideloadable per-ABI APKs for distribution outside
     // Google Play (Play already serves per-ABI installs from the AAB).
     // Each one is roughly 31 MB smaller than the universal APK (about
@@ -395,3 +408,55 @@
       telemetry = true
   }
 }
+
+// Gradle daemons started from Android Studio inherit a minimal PATH that
+// usually lacks nvm / Homebrew node, so reuse the NODE_BINARY that the React
+// Native iOS build already depends on before falling back to a PATH lookup.
+def resolveNodeBinary(File repoRoot) {
+    def pattern = ~'^\\s*export\\s+NODE_BINARY=(.+)$'
+    for (String name : ['ios/.xcode.env.local', 'ios/.xcode.env']) {
+        File file = new File(repoRoot, name)
+        if (!file.exists()) continue
+        for (String line : file.readLines()) {
+            def matcher = pattern.matcher(line)
+            if (!matcher.find()) continue
+            String value = matcher.group(1).trim().replaceAll('^["\']|["\']$', '')
+            // Skip `$(command -v node)` and friends: this is not a shell.
+            if (value.contains('$')) continue
+            if (new File(value).canExecute()) return value
+        }
+    }
+    return 'node'
+}
+
+// Regenerate XOR-split API secret C sources and EdgeApiKey.{swift,java} before
+// every native build. Explicitly clear ALLOW_STUB so a stub from `npm prepare`
+// cannot leak into the signer outputs.
+def nodeBinary = resolveNodeBinary(rootProject.projectDir.parentFile)
+tasks.register("generateEdgeApiSigner", Exec) {
+    def repoRoot = rootProject.projectDir.parentFile
+    workingDir repoRoot
+    environment "EDGE_API_SIGNER_ALLOW_STUB", ""
+    commandLine nodeBinary, "-r", "sucrase/register", "./scripts/makeApiSigner.ts"
+}
+// Same edgeKey.json feeds EdgeApiKey used by native push registration; keep it
+// in lockstep with the signer so a key rotation cannot leave AppDelegate /
+// MessagesWorker on the previous public key.
+tasks.register("generateEdgeApiKeyHeaders", Exec) {
+    def repoRoot = rootProject.projectDir.parentFile
+    workingDir repoRoot
+    commandLine nodeBinary, "-r", "sucrase/register", "./scripts/makeNativeHeaders.ts"
+}
+generateEdgeApiKeyHeaders.dependsOn("generateEdgeApiSigner")
+preBuild.dependsOn("generateEdgeApiKeyHeaders")
+
+// edge_api_secret.c is gitignored but listed in CMakeLists.txt, and the CMake
+// configure/build tasks do not run behind preBuild, so wire them up directly
+// or a fresh checkout fails with "Cannot find source file".
+tasks.matching {
+    it.name.startsWith("configureCMake") ||
+        it.name.startsWith("buildCMake") ||
+        it.name.startsWith("externalNativeBuild")
+}.configureEach {
+    dependsOn("generateEdgeApiSigner")
+}

diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt
new file mode 100644
--- /dev/null
+++ b/android/app/src/main/cpp/CMakeLists.txt
@@ -1,0 +1,27 @@
+cmake_minimum_required(VERSION 3.18.1)
+project(edge_api_signer)
+
+set(NATIVE_SIGNER_DIR "${CMAKE_SOURCE_DIR}/../../../../../native/edge-api-signer")
+
+add_library(
+  edge_api_signer
+  SHARED
+  edge_api_secret.c
+  edge_api_signer_jni.c
+  "${NATIVE_SIGNER_DIR}/edge_hmac.c"
+)
+
+target_include_directories(
+  edge_api_signer
+  PRIVATE
+  ${CMAKE_SOURCE_DIR}
+  ${NATIVE_SIGNER_DIR}
+)
+
+target_compile_options(edge_api_signer PRIVATE -fvisibility=hidden -O2)
+
+# Pixel / Android 15+: 16 KB page-size ELF alignment
+target_link_options(edge_api_signer PRIVATE "-Wl,-z,max-page-size=16384")
+
+find_library(log-lib log)
+target_link_libraries(edge_api_signer ${log-lib})

diff --git a/android/app/src/main/cpp/edge_api_signer_jni.c b/android/app/src/main/cpp/edge_api_signer_jni.c
new file mode 100644
--- /dev/null
+++ b/android/app/src/main/cpp/edge_api_signer_jni.c
@@ -1,0 +1,95 @@
+#include <jni.h>
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "edge_api_sign.h"
+
+static void throw_by_name(JNIEnv *env, const char *class_name, const char *msg) {
+  jclass ex = (*env)->FindClass(env, class_name);
+  if (ex != NULL) {
+    (*env)->ThrowNew(env, ex, msg);
+  }
+}
+
+static void throw_illegal_argument(JNIEnv *env, const char *msg) {
+  throw_by_name(env, "java/lang/IllegalArgumentException", msg);
+}
+
+static void throw_runtime(JNIEnv *env, const char *msg) {
+  throw_by_name(env, "java/lang/RuntimeException", msg);
+}
+
+JNIEXPORT jbyteArray JNICALL
+Java_co_edgesecure_app_EdgeApiSignerModule_nativeSignMessage(
+  JNIEnv *env,
+  jobject thiz,
+  jbyteArray message_utf8,
+  jbyteArray package_name_utf8
+) {
+  if (message_utf8 == NULL || package_name_utf8 == NULL) {
+    throw_illegal_argument(env, "messageUtf8 and packageNameUtf8 are required");
+    return NULL;
+  }
+
+  jsize msg_len = (*env)->GetArrayLength(env, message_utf8);
+  jbyte *msg_bytes = (*env)->GetByteArrayElements(env, message_utf8, NULL);
+  if (msg_bytes == NULL) return NULL;
+
+  jsize pkg_len = (*env)->GetArrayLength(env, package_name_utf8);
+  jbyte *pkg_bytes = (*env)->GetByteArrayElements(env, package_name_utf8, NULL);
+  if (pkg_bytes == NULL) {
+    (*env)->ReleaseByteArrayElements(env, message_utf8, msg_bytes, JNI_ABORT);
+    return NULL;
+  }
+
+  /* edge_api_hmac_sign expects a C string bundle id (NUL-terminated). */
+  char *bundle_id = (char *)malloc((size_t)pkg_len + 1);
+  if (bundle_id == NULL) {
+    (*env)->ReleaseByteArrayElements(env, message_utf8, msg_bytes, JNI_ABORT);
+    (*env)->ReleaseByteArrayElements(env, package_name_utf8, pkg_bytes, JNI_ABORT);
+    throw_by_name(env, "java/lang/OutOfMemoryError", "bundle id allocation failed");
+    return NULL;
+  }
+  memcpy(bundle_id, pkg_bytes, (size_t)pkg_len);
+  bundle_id[pkg_len] = '\0';
+
+  uint8_t signature[32];
+  int rc = edge_api_hmac_sign(
+    (const uint8_t *)msg_bytes,
+    (size_t)msg_len,
+    bundle_id,
+    signature
+  );
+  (*env)->ReleaseByteArrayElements(env, message_utf8, msg_bytes, JNI_ABORT);
+  (*env)->ReleaseByteArrayElements(env, package_name_utf8, pkg_bytes, JNI_ABORT);
+  free(bundle_id);
+
+  if (rc != 0) {
+    throw_runtime(env, "edge_api_hmac_sign failed");
+    return NULL;
+  }
+
+  /* nativeSignMessage is declared non-null in Kotlin, so a bare NULL return
+     would surface as an NPE far from its cause. */
+  jbyteArray out = (*env)->NewByteArray(env, 32);
+  if (out == NULL) {
+    throw_by_name(env, "java/lang/OutOfMemoryError", "signature allocation failed");
+    return NULL;
+  }
+  (*env)->SetByteArrayRegion(env, out, 0, 32, (const jbyte *)signature);
+  memset(signature, 0, sizeof(signature));
+  return out;
+}
+
+JNIEXPORT jstring JNICALL
+Java_co_edgesecure_app_EdgeApiSignerModule_nativeApiKey(
+  JNIEnv *env,
+  jobject thiz
+) {
+  jstring out = (*env)->NewStringUTF(env, edge_api_key());
+  if (out == NULL) {
+    throw_runtime(env, "apiKey allocation failed");
+  }
+  return out;
+}

diff --git a/android/app/src/main/java/co/edgesecure/app/EdgeApiSignerModule.kt b/android/app/src/main/java/co/edgesecure/app/EdgeApiSignerModule.kt
new file mode 100644
--- /dev/null
+++ b/android/app/src/main/java/co/edgesecure/app/EdgeApiSignerModule.kt
@@ -1,0 +1,82 @@
+package co.edgesecure.app
+
+import com.facebook.react.bridge.Arguments
+import com.facebook.react.bridge.Promise
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.bridge.ReactContextBaseJavaModule
+import com.facebook.react.bridge.ReactMethod
+import com.facebook.react.bridge.WritableMap
+import android.util.Base64
+import java.nio.charset.StandardCharsets
+
+/**
+ * React Native bridge to the native HMAC API signer.
+ * The secret never enters Java as a contiguous plaintext constant.
+ */
+class EdgeApiSignerModule(
+  reactContext: ReactApplicationContext,
+) : ReactContextBaseJavaModule(reactContext) {
+  companion object {
+    /**
+     * React Native constructs every module while building the package list, so
+     * an UnsatisfiedLinkError here would kill the app at startup. Record the
+     * failure instead, so EdgeApiSignerPackage can leave the module unregistered
+     * and JS sees an honestly absent signer rather than one that rejects every
+     * call.
+     */
+    val libraryLoaded: Boolean =
+      try {
+        System.loadLibrary("edge_api_signer")
+        true
+      } catch (e: UnsatisfiedLinkError) {
+        false
+      }
+  }
+
+  override fun getName(): String = "EdgeApiSigner"
+
+  @ReactMethod
+  fun signMessage(
+    message: String,
+    promise: Promise,
+  ) {
+    if (!libraryLoaded) {
+      promise.reject("EDGE_API_SIGNER", "edge_api_signer library is unavailable")
+      return
+    }
+    try {
+      // Real UTF-8 bytes for both message and packageName (not JNI Modified UTF-8).
+      val messageUtf8 = message.toByteArray(StandardCharsets.UTF_8)
+      val packageNameUtf8 =
+        reactApplicationContext.packageName.toByteArray(StandardCharsets.UTF_8)
+      val signature = nativeSignMessage(messageUtf8, packageNameUtf8)
+      val apiKey = nativeApiKey()
+      val map: WritableMap = Arguments.createMap()
+      map.putString("apiKey", apiKey)
+      map.putString("signature", Base64.encodeToString(signature, Base64.NO_WRAP))
+      promise.resolve(map)
+    } catch (e: Throwable) {
+      promise.reject("EDGE_API_SIGNER", e.message, e)
+    }
+  }
+
+  @ReactMethod
+  fun getApiKey(promise: Promise) {
+    if (!libraryLoaded) {
+      promise.reject("EDGE_API_SIGNER", "edge_api_signer library is unavailable")
+      return
+    }
+    try {
+      promise.resolve(nativeApiKey())
+    } catch (e: Throwable) {
+      promise.reject("EDGE_API_SIGNER", e.message, e)
+    }
+  }
+
+  private external fun nativeSignMessage(
+    messageUtf8: ByteArray,
+    packageNameUtf8: ByteArray,
+  ): ByteArray
+
+  private external fun nativeApiKey(): String
+}

diff --git a/android/app/src/main/java/co/edgesecure/app/EdgeApiSignerPackage.kt b/android/app/src/main/java/co/edgesecure/app/EdgeApiSignerPackage.kt
new file mode 100644
--- /dev/null
+++ b/android/app/src/main/java/co/edgesecure/app/EdgeApiSignerPackage.kt
@@ -1,0 +1,21 @@
+package co.edgesecure.app
+
+import com.facebook.react.ReactPackage
+import com.facebook.react.bridge.NativeModule
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.uimanager.ViewManager
+
+/** Registers the EdgeApiSigner native module with React Native. */
+class EdgeApiSignerPackage : ReactPackage {
+  /**
+   * Registering a module whose JNI library is missing would make
+   * `hasNativeApiSigner()` true and steer JS away from its credential
+   * fallback, so an unusable signer is simply not registered.
+   */
+  override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
+    if (EdgeApiSignerModule.libraryLoaded) listOf(EdgeApiSignerModule(reactContext))
+    else emptyList()
+
+  override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
+    emptyList()
+}

diff --git a/android/app/src/main/java/co/edgesecure/app/MainApplication.kt b/android/app/src/main/java/co/edgesecure/app/MainApplication.kt
--- a/android/app/src/main/java/co/edgesecure/app/MainApplication.kt
+++ b/android/app/src/main/java/co/edgesecure/app/MainApplication.kt
@@ -36,6 +36,7 @@
           // packages.add(new MyReactNativePackage());
           val packages = PackageList(this).packages
           packages.add(EdgeAttestationPackage())
+          packages.add(EdgeApiSignerPackage())
           return packages
         }
 

diff --git a/docs/CONFIG_KEYS_ARCHITECTURE.md b/docs/CONFIG_KEYS_ARCHITECTURE.md
--- a/docs/CONFIG_KEYS_ARCHITECTURE.md
+++ b/docs/CONFIG_KEYS_ARCHITECTURE.md
@@ -7,19 +7,29 @@
 plugin enablement) with real credential material (API keys, secrets, tokens) in
 one flat, `ALLCAPS_*_INIT`-keyed blob.
 
-This refactor splits that single file into two gitignored inputs and reshapes
+This refactor splits that single file into three gitignored inputs and reshapes
 the schema so that plugin configuration is keyed by real plugin ID:
 
 - **`config.json`**: non-secret app/debug settings and the non-secret halves of
   each plugin's init options. Safe to commit to a private build-config repo.
 - **`keys.json`**: every secret (API keys, tokens, credentials), including the
-  secret halves of plugin init options.
+  secret halves of plugin init options, **except** the Edge login HMAC
+  credentials when using the native signer.
+- **`edgeKey.json`**: `{ apiKey, apiSecret }` for Edge login HMAC. Build-time
+  only: `scripts/makeApiSigner.ts` embeds XOR-split native shards from it and
+  `scripts/makeNativeHeaders.ts` reads the public `apiKey`. The Metro bundle
+  never loads it, so `KEYS.EDGE_API_KEY` / `KEYS.EDGE_API_SECRET` are absent in
+  native-signer builds and every consumer must handle that (native
+  `EdgeApiSigner` or JS fallback).
 
-At runtime the two files stay separate accessors rather than flattening into one
+HMAC request signing (login-server via core, and info-server signed
+infoRollup) is documented in [HMAC_SIGNING.md](./HMAC_SIGNING.md).
+
+At runtime the config/keys files stay separate accessors rather than flattening into one
 `ENV` singleton:
 
 - **`CONFIG`** (`src/config.ts`): immutable cleaned `config.json`. Never updated
-  by remote getKeys overlays.
+  by remote appKeys overlays.
 - **`KEYS`** / **`globalKeys`** (`src/keys.ts`): mutable cleaned keys. Partner
   secrets live only under `KEYS.globalKeys`; `globalKeys` is a live alias of that
   same object (no top-level flatten onto `KEYS`).
@@ -67,7 +77,7 @@
 
 Those defaults were duplicates: every plugin cleans its own init options and
 declares the same default itself, so an omitted field still ends up with the
-same value. The one exception was `pluginApiKeys.paybis.partnerUrl`, whose
+same value. The one exception was `guiApiKeys.paybis.partnerUrl`, whose
 consumer required the field outright, so that default now lives in
 `paybisProvider.ts` where it is used.
 
@@ -84,14 +94,29 @@
 | `src/config.ts`                         | Cleans `config.json` with `asConfigJson.withRest` and exports immutable `CONFIG`.                                                                                                                |
 | `src/keys.ts`                           | Cleans `keys.json`, nests flat partner secrets via `nestGlobalKeys`, exports immutable merge-base `bakedKeys`, mutable `KEYS`, live `globalKeys` alias, and `applyRuntimeKeys`.                  |
 | `src/pluginMaps.ts`                     | Builds `pluginMaps` via `resolvePluginMaps(CONFIG, KEYS)` and exports `rebuildPluginMaps` for in-place updates after key overlays.                                                               |
-| `src/util/keysStore.ts`                 | Tier selection, the remote/cache/baked-in resolution promise, the local-only strip list, and `applyKeys` (mutates `KEYS`/`globalKeys`, then `rebuildPluginMaps` + `rebuildAllPlugins`).          |
-| `src/util/keysServer.ts`                | Signs and issues `GET /v1/getKeys`, and validates the response shape.                                                                                                                            |
+| `src/util/keysStore.ts`                 | Tier selection, the remote/cache/baked-in resolution promise, the local-only strip list, and `applyKeys` (mutates `KEYS`/`globalKeys`, then `rebuildPluginMaps` + `rebuildAllPlugins`). Prefers native `apiSigner` for signed infoRollup when linked. |
+| `src/util/keysServer.ts`                | Signs and issues `GET /v1/infoRollup/:appId` (JS HMAC or `apiSigner`), extracts `appKeys`, and validates the overlay shape.                                                                                                                              |
+| `src/util/edgeApiSigner.ts`             | Detects the native `EdgeApiSigner` module, builds the core's `apiSigner`, and caches the public `apiKey` for push / notification callers.                                                                                                     |
 | `src/configKeysMerge.ts`                | Runtime merge layer: `deepMerge`, `mergePluginInit`, `nestGlobalKeys`, `resolvePluginMaps`, and `asMergeableKeys`. Also holds redaction helpers for unit tests.                                  |
 | `src/configKeysSchema.ts`               | Per-file cleaners `asConfigJson` (non-secret) and `asKeysJson` (secret), `globalKeysShape` / `asGlobalKeys`, and the `ConfigJson` / `KeysJson` / `RuntimeKeys` / `GlobalKeys` types.             |
-| `scripts/splitEnvJson.ts`               | Migration-only CLI (`npm run split-env-json`) that classifies a legacy `env.json` and writes `config.json` + `keys.json`. Never prints secrets; `--force` to overwrite. Not imported by the app. |
+| `scripts/splitEnvJson.ts`               | Migration-only CLI (`npm run split-env-json`) that classifies a legacy `env.json` and writes `config.json` + `keys.json` + `edgeKey.json`. Never prints secrets; `--force` to overwrite. Not imported by the app. |
 | `src/__tests__/configKeysMerge.test.ts` | Golden-equivalence + deep-merge + redaction unit tests.                                                                                                                                          |
 | `scripts/configure.ts`                  | Runs `makeConfig(asConfigJson.withRest, 'config.json')` and `makeConfig(asKeysJson.withRest, 'keys.json')` so `prepare` can bootstrap both files without writing secrets into `config.json`.     |
 
+## Debug logging
+
+Which tier of keys won, and whether the native signer was used, is reported
+through `debugLog('keys', ...)` rather than a bare `console.log`, so nothing is
+printed in a normal build. Turn it on in `config.json`:
+
+```json
+"LOG_CONFIG": { "enabledCategories": ["keys"] }
+```
+
+That covers `logTier` in `src/util/keysStore.ts` (tier, assurance level, and
+the `LAYER-*` sentinels of a matched overlay) and the signer summary in
+`src/components/services/EdgeCoreManager.tsx`. Neither prints key material.
+
 ## The CONFIG / KEYS / pluginMaps schema
 
 `asConfigJson` and `asKeysJson` (`src/configKeysSchema.ts`) define the two
@@ -110,12 +135,12 @@
 
 ```ts
 export const asConfigJson = asObject({
-  corePlugins, swapPlugins, pluginApiKeys, rampPlugins, // shared plugin maps
+  corePlugins, swapPlugins, guiApiKeys, rampPlugins, // shared plugin maps
   ...non-secret config fields
 })
 
 export const asKeysJson = asObject({
-  pluginApiKeys, rampPlugins, // secret-bearing plugin maps
+  corePlugins, swapPlugins, guiApiKeys, rampPlugins, // secret-bearing plugin maps
   globalKeys: asOptional(asGlobalKeys, () => ({})),
   ...globalKeysShape, // legacy flat partner keys still accepted on disk
   ...secret fields // EDGE_API_*, SENTRY_*, POSTHOG_API_KEY, …
@@ -146,11 +171,11 @@
   Each value is the same `object | true | false` union as before.
 - **`swapPlugins`**: swap plugin inits keyed by real swap plugin ID
   (`changehero`, `thorchain`, `0xgasless`, ...).
-- **`pluginApiKeys`** — GUI provider keys (formerly `PLUGIN_API_KEYS`), plus the
-  migrated `walletconnect` (`projectId`) and `posthog` (`apiKey`, `apiHost`)
-  entries where those still appear as plugin-shaped maps.
+- **`guiApiKeys`**: GUI fiat / gift-card provider credentials (formerly
+  `PLUGIN_API_KEYS`: banxa, paybis, phaze, revolut, simplex, …). WalletConnect
+  is **not** in this map; its `projectId` is `globalKeys.WALLETCONNECT_PROJECT_ID`.
 - **`rampPlugins`**: ramp plugin inits (formerly `RAMP_PLUGIN_INITS`). Kept
-  distinct from `pluginApiKeys` on purpose: `banxa` exists in both maps with
+  distinct from `guiApiKeys` on purpose: `banxa` exists in both maps with
   different shapes, so merging them would collide.
 
 There are **no `*_INIT` fields** left in the schema or in any consumer. The dead
@@ -166,17 +191,18 @@
   `evmScanApiKey`, `ninerealmsClientId`, `thorswapApiKey`, `privateKeyB64`,
   `hmacUser`, `jwtTokenProvider`, `clientSecret`, `heliusApiKey`,
   `alchemyApiKey`, `blockfrostProjectId`, `glifApiKey`, `subscanApiKey`,
-  `tonCenterApiKeys`, `projectId` (walletconnect), auth/telemetry top-level
+  `WALLETCONNECT_PROJECT_ID` (from `WALLET_CONNECT_INIT.projectId`), auth/telemetry top-level
   fields (`EDGE_API_KEY`/`EDGE_API_SECRET`, `SENTRY_*`, `BUGSNAG_API_KEY`,
   `POSTHOG_API_KEY`), and the partner secrets, the "global keys". On disk those
   partner secrets may still appear **flat** at the top level for legacy files;
   load and overlay paths run `nestGlobalKeys` so the runtime `KEYS` object keeps
   them only under `KEYS.globalKeys` (`AZTECO_API_KEY`, `COINGECKO_API_KEY`,
-  `IP_API_KEY`, `STAKEKIT_API_KEY`, `UNSTOPPABLE_DOMAINS_API_KEY`, `KILN_*`, …).
-  A `GET /v1/getKeys` payload delivers the same partner secrets nested under a
-  `globalKeys` section; the client keeps that nesting (no top-level flatten onto
-  `KEYS`). `YOLO_*` and `POSTHOG_API_HOST` live in `config.json` (local-only
-  developer / host wiring, never served).
+  `IP_API_KEY`, `STAKEKIT_API_KEY`, `UNSTOPPABLE_DOMAINS_API_KEY`,
+  `WALLETCONNECT_PROJECT_ID`, `KILN_*`, …).
+  A signed infoRollup `appKeys` overlay delivers the same partner secrets nested
+  under a `globalKeys` section; the client keeps that nesting (no top-level
+  flatten onto `KEYS`). `YOLO_*` and `POSTHOG_API_HOST` live in `config.json`
+  (local-only developer / host wiring, never served).
 
 Both files are gitignored (`.gitignore` lists `/config.json` and `/keys.json`
 alongside the retained `/env.json`).
@@ -187,7 +213,7 @@
 resolved `pluginMaps`, and normalizes partner secrets under `globalKeys`:
 
 1. **`CONFIG` top-level fields** stay on `CONFIG` only. They are never overwritten
-   by getKeys overlays (`keysStore` also drops non-`asKeysJson` fields from
+   by appKeys overlays (`keysStore` also drops non-`asKeysJson` fields from
    overlays via `keepKeysFields`).
 2. **`KEYS` top-level secret fields** (`EDGE_API_*`, `SENTRY_*`, `POSTHOG_API_KEY`,
    plugin maps, …) live on `KEYS`. Remote/cache overlays deep-merge onto
@@ -196,20 +222,20 @@
    `globalKeys` section are normalized by `nestGlobalKeys`. Consumers read
    `globalKeys.COINGECKO_API_KEY` (or `KEYS.globalKeys.…`); there is no
    top-level `KEYS.COINGECKO_API_KEY` after nesting.
-4. **Currency & swap plugins** — for each ID present in
-   `CONFIG.corePlugins` / `CONFIG.swapPlugins`, the non-secret config value is
-   combined with the matching secret from `KEYS.pluginApiKeys[id]` via
-   `mergePluginInit`:
+4. **Currency & swap plugins**: for each ID present in config or keys
+   `corePlugins` / `swapPlugins` (union), the non-secret config value is
+   combined with the matching secret from `KEYS.corePlugins[id]` /
+   `KEYS.swapPlugins[id]` via `mergePluginInit`:
    - a `false` config value keeps the plugin disabled (secrets ignored);
    - a `false` keys value is ignored: keys carry secrets, never kill
      switches, so a remote/cache overlay cannot turn a plugin off;
    - a `true`/absent config value with an object secret becomes the secret
      object (an object always wins over a bare boolean enablement flag);
    - otherwise the two are deep-merged with the keys side winning.
-5. **GUI provider keys (`pluginApiKeys`)** — every `pluginApiKeys` ID that is
-   _not_ a currency or swap plugin (those secrets live inside
-   `corePlugins`/`swapPlugins` after resolve). Config and keys are deep-merged
-   per ID.
+   Extra remote IDs on `pluginMaps.corePlugins` do **not** register a new
+   engine: `corePlugins.ts` is a hardcoded table.
+5. **GUI provider keys (`guiApiKeys`)**: union of config and keys IDs, merged
+   per ID. Currency/swap secrets do not live here.
 6. **Ramp plugins (`rampPlugins`)**: `CONFIG.rampPlugins[id]` deep-merged with
    `KEYS.rampPlugins[id]` per ID.
 
@@ -225,10 +251,11 @@
   `thorchain` for swap).
 - `isSecretField` (a field-name regex) and `isSecretTopLevel` classify each
   field. Secret-looking fields go to `keys.json`; the rest go to `config.json`.
-- `PLUGIN_API_KEYS` → `pluginApiKeys`, `RAMP_PLUGIN_INITS` → `rampPlugins`.
+- `PLUGIN_API_KEYS` → `guiApiKeys`, `RAMP_PLUGIN_INITS` → `rampPlugins`.
 - `POSTHOG_INIT` → `config.POSTHOG_API_HOST` + a flat `keys.POSTHOG_API_KEY`
   (PostHog is not a plugin; the api key stays top-level on `KEYS` at runtime).
-- `WALLET_CONNECT_INIT` → `pluginApiKeys.walletconnect`.
+- `WALLET_CONNECT_INIT.projectId` → flat `keys.WALLETCONNECT_PROJECT_ID` (then
+  nested under `globalKeys` at load). No config flag; disable = omit the key.
 - Loose partner secrets (`AZTECO_*`, `KILN_*`, CoinGecko, …) → flat top-level
   fields in `keys.json` (nested under `globalKeys` at runtime load).
 - `YOLO_*` stays in `config.json`.
@@ -257,11 +284,11 @@
     `thorchainrunestagenet` both read `corePlugins.thorchainrune`.
   - `src/hooks/useRampPlugins.ts`: `pluginMaps.rampPlugins[pluginId]`.
   - `src/plugins/gui/util/initializeProviders.ts`, `fetchRevolut.ts`, and the
-    gift-card / WalletConnect paths — `pluginMaps.pluginApiKeys.*`.
+    gift-card paths: `pluginMaps.guiApiKeys.*`.
   - Inner-field readers: `FioAddressUtils.ts` (`pluginMaps.corePlugins.fio`),
     `thorchainYield.ts` + `stakePlugins.ts` (`pluginMaps.swapPlugins.thorchain`),
     `fantomEcosystem.ts` (`pluginMaps.corePlugins.fantom`),
-    `WalletConnectService.tsx` (`pluginMaps.pluginApiKeys.walletconnect.projectId`),
+    `WalletConnectService.tsx` (`globalKeys.WALLETCONNECT_PROJECT_ID`),
     `tracking.ts` (`KEYS.POSTHOG_API_KEY` + `CONFIG.POSTHOG_API_HOST`).
 
 ## Scripts
@@ -274,6 +301,12 @@
 (`configJson` / `keysJson` branch-override fields, already shaped like the
 files they patch).
 
+After `npm run split-env-json`, `npm run split-baked-and-server-keys` rewrites
+`keys.json` to the local-only keep-list (`slimKeysJson` / `localOnlyKeys`) and
+writes `appKeys.json` for the info-server Couch default layer: `corePlugins`,
+`swapPlugins`, `guiApiKeys`, `rampPlugins`, and nested `globalKeys`. It never
+prints secret values.
+
 ---
 
 ## Status of remaining Env config code
@@ -339,13 +372,19 @@
 - Private build-config repos must ship `config.json` + `keys.json` instead of
   `env.json` before release builds use this branch.
 - Deploy deep-merges explicit `configJson` / `keysJson` per-branch overrides into
-  the matching files and does not run overrides through `splitEnv`. Legacy
-  `envJson` is ignored (with a migration error when a branch block exists only
-  there) so the same file can still serve older GUI builds that read it.
+  the matching files and does not run overrides through `splitEnv`. Outer keys
+  are **git branch names** (`develop`, `beta`, `yolo`, …). Inner `keysJson[branch]`
+  is the same overlay as `info_keys` layer `keys` / signed rollup `appKeys`
+  (four maps + `globalKeys.WALLETCONNECT_PROJECT_ID`). Inner `configJson[branch]`
+  is enablement / non-secret init. See `deploy-config.sample.json`. Never-serve
+  fields (`POSTHOG_API_KEY`, `EDGE_API_*`, `SENTRY_*`, `YOLO_*`) do not belong
+  in `keysJson`. Legacy `envJson` is ignored (with a migration error when a
+  branch block exists only there) so the same file can still serve older GUI
+  builds that read it.
 - Optional: update `README.md` and native comments to reference the new files;
   eventually retire `env.json` + `scripts/splitEnvJson.ts` together.
 
-## Remote keys via the info server (`GET /v1/getKeys`)
+## Remote keys via the info server (signed `infoRollup` `appKeys`)
 
 Client support for remote keys is implemented on this branch (`keysStore`,
 `keysServer`, DeviceSettings `keysCache`, EdgeCoreManager gate). The design
@@ -363,7 +402,7 @@
 | Tier       | Source                               | When it applies                                                     |
 | ---------- | ------------------------------------ | ------------------------------------------------------------------- |
 | `cache`    | `keysCache` in `DeviceSettings.json` | Any launch with a mergeable on-disk cache (does not expire)         |
-| `remote`   | `GET /v1/getKeys` on the info server | Cold start (no usable cache), fetch succeeded within budget         |
+| `remote`   | Signed `GET /v1/infoRollup/:appId` `appKeys` | Cold start (no usable cache), fetch succeeded within budget         |
 | `baked-in` | `keys.json` compiled into the binary | Cold start where the fetch failed/missed budget and no usable cache |
 
 The cache takes precedence over the network rather than the other way round.
@@ -379,8 +418,8 @@
 overwrites it. Paying the budget once repairs it.
 
 Both tiers are held to the same definition of "will not merge", `asMergeableKeys`
-in `configKeysMerge.ts`: a top-level object whose `pluginApiKeys`, `rampPlugins`,
-and `globalKeys` are objects if present. It is checked in `applyKeys`, which
+in `configKeysMerge.ts`: a top-level object whose `corePlugins`, `swapPlugins`,
+`guiApiKeys`, `rampPlugins`, and `globalKeys` are objects if present. It is checked in `applyKeys`, which
 every tier passes through, and again at the fetch so a bad response never reaches
 disk. Validating only the fetch would leave the cache unguarded, and because
 `deepMerge` replaces rather than merges when the two sides disagree on type, a
@@ -409,114 +448,94 @@
 Two consequences worth stating plainly:
 
 - **`keys.json` does not go away.** It keeps its full schema with every field
-  optional; only `EDGE_API_KEY` and `EDGE_API_SECRET` are required, since those
-  are the credentials used to authenticate the fetch. A release build may ship
-  either a minimal bootstrap file or a fully populated fallback file.
-- **A shipped binary may therefore still contain every secret.** This work
+  optional. `EDGE_API_KEY` / `EDGE_API_SECRET` authenticate signed infoRollup and login
+  when the native signer is **not** linked. Native-signer builds embed those
+  credentials at compile time from `edgeKey.json` and omit them from the Metro
+  bundle; see [HMAC signing](HMAC_SIGNING.md).
+- **A shipped binary may therefore still contain partner secrets.** This work
   _reduces_ secret exposure and enables server-side rotation; it does not make
   the IPA/APK secret-free.
 
 ### Authentication
 
-The endpoint reuses the login server's HMAC-signed `Authorization` scheme
-(`edge-login-server/src/middleware/with-api-key.ts`), with one deliberate
-divergence — a required, signed `X-Timestamp`:
+The endpoint uses HMAC `Authorization` plus a required `X-Timestamp`. That is
+the existing login-server scheme (`with-api-key.ts`) with one extra signed line.
+Canonical server behavior, layer matching, and the Couch schema live in
+[edge-info-server `docs/INFO_ROLLUP.md`](https://github.com/EdgeApp/edge-info-server/blob/master/docs/INFO_ROLLUP.md).
 

-GET /v1/getKeys
+GET /v1/infoRollup/{appId}?os={ios|android}&osVersion={x.y.z}&appVersion={semver}
Authorization: HMAC {edgeApiKey} {base64(hmacSha256(signedString, secret))}
X-Timestamp: {unix seconds}
-x-attestation-token: {ES256 JWT} // optional
+x-attestation-token: {ES256 JWT} // optional; invalid token → HTTP 401


-The signed string is the login server's `METHOD\nURL\nBODY` plus a timestamp
-line, with an empty body because this is a GET:
+Signed string (empty GET body):

-GET\n/v1/getKeys\n\n{timestamp}
+GET\n/v1/infoRollup/{appId}?os=…&osVersion=…&appVersion=…\n\n{timestamp}


-The login server itself has **no** signature freshness window, so there is no
-existing window to match. The window instead follows the info server's clamped,
-operator-editable remote-config pattern used for attestation challenge
-lifetimes, defaulting to 300 s with a 30 s floor. The wider default reflects
-that `X-Timestamp` comes from a device clock that can drift by minutes, unlike a
-server-issued challenge.
+The client signs that path **including** `/v1` (`keysServer.ts` `signPath`).
+The info server verifies `req.originalUrl`. Login-server HMAC has **no**
+timestamp line and **no** freshness window.

-### Attestation-level layering
+`appId` in the path is the info-rollup partner id (`config.appId ?? 'edge'`).
+It must match `info_keys` document `_id`. The attested bundle id is a different
+field: the JWT `appId` claim (`co.edgesecure.app`).

-The payload is composed by **cumulative ascending deep merge**: `default` is the
-base, then every defined level whose rank is at or below the caller's attested
-rank is merged in ascending order, later levels winning. Ranks are the info
-server's existing assurance levels — `debug` 0, `software` 1, `hardware` 2,
-`secureElement` 3. An unattested caller receives `default` alone; a key with no
-`default` returns an empty payload to an unattested caller, which is a valid way
-to require attestation.
+Disk cache holds the **`appKeys` overlay only** (`DeviceSettings.keysCache`).
+The public rollup (promo/APY/…) stays **in-memory** on `infoServerData.rollup`.
+A 5-minute / NetInfo unsigned poll may live-update those public fields; KEYS
+never hot-swap this session. Boot is a single signed infoRollup when HMAC
+credentials exist (no parallel unsigned fetch at t=0).

-Because `debug` participates in the cumulative chain, production material must
-never be placed under `debug`.
+Native `apiSigner` is preferred when `EdgeApiSigner` is linked; otherwise JS
+HMAC uses `KEYS.EDGE_API_KEY` / `KEYS.EDGE_API_SECRET`.

-### App ID scoping
+### Attestation-level layering

-Keys differ per app, since white-label apps ship from this codebase with their
-own provider credentials. The request carries the logical app ID in the signed
-query string, reusing the same value already sent to `infoRollup`
-(`config.appId ?? 'edge'` in `src/util/network.ts`).
+Payloads are **not** a named ladder (`default` then `debug` then `hardware`)
+nested under each app. The info server walks an ordered `layers` array and
+deep-merges every row that independently matches:

-Two distinct identifiers are both called `appId`, and they must not be
-conflated:
+1. Layer `apiKeys` lists the **memo name** of the HMAC that signed the request
+   (not the presented public id).
+2. Token assurance ≥ `minAssurance` (`default` is unattested, below `debug`).
+3. `bundleIds` is `"*"` (only legal at `minAssurance: default`) **or** the JWT
+   `appId` is in the layer’s bundle list. Unattested callers never match a
+   non-wildcard bundle list.

-|        | Logical app ID                           | Attested app ID                                    |
-| ------ | ---------------------------------------- | -------------------------------------------------- |
-| Value  | Build-config slug, e.g. `edge`           | Bundle id / package name, e.g. `co.edgesecure.app` |
-| Source | `config.appId`, from `CONFIG.APP_CONFIG` | The `appId` claim in the attestation JWT           |
-| Trust  | Unverified build-time label              | Cryptographically bound                            |
+Unknown bundles are not a 403; they receive only wildcard/`default` rows.
+A present-but-invalid attestation token is HTTP 401: the GUI must not treat
+that as “unattested floor.”

-The document therefore maps each logical app ID to its iOS and Android
-identifiers, and the server verifies the attestation token's claim against that
-mapping. A token whose bundle id belongs to a different app in the same document
-is rejected rather than downgraded.
+**Invariant:** anything on a `"bundleIds": "*"` / `minAssurance: default` row is
+reachable by any holder of a listed HMAC. App-scoped credentials belong on rows
+that list real bundle IDs and `minAssurance` of `debug` or above.

-**Security invariant:** an unattested caller can name any allowed app ID and
-receive that app's `default` payload, because nothing proves which binary is
-asking. So `default` may only hold keys acceptable to hand to any holder of that
-Edge API key and secret; anything genuinely app-scoped belongs at `software` or
-above, where the bundle id is proven. Apps needing mutually isolated defaults
-need separate Edge API keys.
-
### `info_keys` document shape

-A new CouchDB database `info_keys` holds one document per API-key partner. Each
-document carries an `appIds` allow-list and multiple Edge API keys, mirroring the
-login server's `login-api-keys` layout. Each key holds its own HMAC secret plus
-per-app payloads, nested app then attestation level so layering never crosses app
-boundaries:
+Couch `info_keys/<appId>` (`_id` is the rollup app ID: `edge`,
+`com.testy.wallet`, …). Lookup is the presented HMAC public id via
+`_design/api-key`. Secrets live here, not on login-server Couch (rotate both
+when minting a pair).

-info_keys/

  • appIds: [, ...] // allow-list
  • apiKeys
  •  type, secret, enabled, created, comment
    
  •  apps
    
  •    <logicalAppId>
    
  •      ios:     [<bundleId>, ...]   // verified against the attestation claim
    
  •      android: [<packageName>, ...]
    
  •      keys
    
  •        default        -> keys.json-shaped payload
    

... diff truncated: showing 800 of 4952 lines


</details>


<sub>You can send follow-ups to the cloud agent <a href="https://cursor.com/agents/bc-61f006e8-644c-4369-8343-f379d1e93e23">here</a>.</sub>

@paullinator
paullinator force-pushed the paul/nativeHmacApiSigner branch 2 times, most recently from 1d2229d to 8ab5158 Compare September 22, 2026 15:26

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread src/configKeysMerge.ts
@paullinator
paullinator force-pushed the paul/nativeHmacApiSigner branch 2 times, most recently from 4bedbb4 to 011f9a8 Compare September 22, 2026 16:13

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Boot error hardcodes Edge branding
    • Replaced the hardcoded Edge brand in boot_failed_message with config.appName so white-label builds show the partner app name.

Create PR

Or push these changes by commenting:

@cursor push 9d207fae60
Preview (9d207fae60)
diff --git a/src/components/services/EdgeCoreManager.tsx b/src/components/services/EdgeCoreManager.tsx
--- a/src/components/services/EdgeCoreManager.tsx
+++ b/src/components/services/EdgeCoreManager.tsx
@@ -39,6 +39,7 @@
 import { useIsAppForeground } from '../../hooks/useIsAppForeground'
 import { KEYS } from '../../keys'
 import { lstrings } from '../../locales/strings'
+import { config } from '../../theme/appConfig'
 import { addMetadataToContext } from '../../util/addMetadataToContext'
 import { onAttestationToken } from '../../util/attestation'
 import { allPlugins } from '../../util/corePlugins'
@@ -319,7 +320,11 @@
     return (
       <View style={styles.bootErrorContainer}>
         <Text style={styles.bootErrorText}>
-          {sprintf(lstrings.boot_failed_message_1s, bootFatalError)}
+          {sprintf(
+            lstrings.boot_failed_message_2s,
+            config.appName,
+            bootFatalError
+          )}
         </Text>
       </View>
     )

diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts
--- a/src/locales/en_US.ts
+++ b/src/locales/en_US.ts
@@ -73,7 +73,7 @@
   bitcoin_received: '%1$s Received',
 
   // Shown instead of the app when the core fails to start at all:
-  boot_failed_message_1s: 'Edge failed to start: %1$s',
+  boot_failed_message_2s: '%1$s failed to start: %2$s',
 
   dialog_title: 'Set Auto Logoff time',
   share_subject: 'Hey, I think you should try %s',

You can send follow-ups to the cloud agent here.

Comment thread src/locales/en_US.ts
bitcoin_received: '%1$s Received',

// Shown instead of the app when the core fails to start at all:
boot_failed_message_1s: 'Edge failed to start: %1$s',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Boot error hardcodes Edge branding

Low Severity

The new boot-failure copy in boot_failed_message_1s hardcodes the Edge brand. This screen is user-facing when the core fails to start, so white-label builds would show the wrong app name.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by learned rule: White-label awareness: no hardcoded Edge branding or URLs in user-facing features

Reviewed by Cursor Bugbot for commit 011f9a8. Configure here.

@paullinator
paullinator force-pushed the paul/nativeHmacApiSigner branch 2 times, most recently from 059fba1 to e35aead Compare September 23, 2026 04:15
Apply strict-boolean, nullish, and return-type fixes in files leaving
the relaxed-rules list.
Single-flight the initial load and serialize every write through a
promise chain so overlapping patches cannot clobber each other or
blank on-disk fields. Adds keysCache fields for remote key fetch.
@paullinator
paullinator force-pushed the paul/nativeHmacApiSigner branch from e35aead to fac86e7 Compare September 24, 2026 21:27
@paullinator
paullinator force-pushed the paul/nativeHmacApiSigner branch from cfefee7 to b6d700c Compare September 25, 2026 01:35

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Robinhood init dropped on env split
    • Added ROBINHOOD_INIT to CURRENCY_INIT_MAP so the env split preserves Robinhood enablement and secrets like every other coreInit currency.

Create PR

Or push these changes by commenting:

@cursor push 8e5f942f26
Preview (8e5f942f26)
diff --git a/scripts/splitEnvJson.ts b/scripts/splitEnvJson.ts
--- a/scripts/splitEnvJson.ts
+++ b/scripts/splitEnvJson.ts
@@ -60,6 +60,7 @@
   POLKADOT_INIT: 'polkadot',
   POLYGON_INIT: 'polygon',
   PULSECHAIN_INIT: 'pulsechain',
+  ROBINHOOD_INIT: 'robinhood',
   RSK_INIT: 'rsk',
   SEPOLIA_INIT: 'sepolia',
   SOLANA_INIT: 'solana',

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit b6d700c. Configure here.

Comment thread scripts/splitEnvJson.ts
LITECOIN_INIT: 'litecoin',
PIVX_INIT: 'pivx',
ZCOIN_INIT: 'zcoin'
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robinhood init dropped on env split

Medium Severity

CURRENCY_INIT_MAP has no ROBINHOOD_INIT entry, so split-env-json drops that legacy field. coreInit('robinhood') then falls back to false when the id is absent from both maps, which turns Robinhood wallets off after migration unless a remote overlay happens to reintroduce the id.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by learned rule: Verify hardcoded pluginId lists are complete against the plugin repo

Reviewed by Cursor Bugbot for commit b6d700c. Configure here.

@paullinator
paullinator force-pushed the paul/nativeHmacApiSigner branch from b6d700c to 4e84748 Compare September 25, 2026 01:39
Print only LAYER-* overlay markers from the local info_keys seed, plus whether the native signer loaded, so device e2e can confirm remote key fetch without dumping secrets.
Plugins whose API keys are absent or malformed do not register with the
core, which leaves them out of `currencyConfig` and `swapConfig`. Diff the
plugin list we handed to `makeEdgeContext` against what the account came
back with, and show the missing plugin IDs in an error drop-down so a
misconfigured key surfaces instead of silently removing assets and
exchanges from the app.

Keys-only plugins are left out of the diff: they take nothing from the
info server, so remote keys cannot break them, and they are hidden from
the user regardless.

Plugin loading happens once per core context, so this reports once per
session rather than on every login.
@paullinator
paullinator force-pushed the paul/nativeHmacApiSigner branch from 4e84748 to 78d8225 Compare September 25, 2026 01:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants