Skip to content

feat(profile): link a NIM address to a deed with both injected providers - #3

Merged
Samuel1-ona merged 1 commit into
mainfrom
samuel1-ona/nim-address-unlinkable-on-deed
Aug 26, 2026
Merged

feat(profile): link a NIM address to a deed with both injected providers#3
Samuel1-ona merged 1 commit into
mainfrom
samuel1-ona/nim-address-unlinkable-on-deed

Conversation

@Samuel1-ona

Copy link
Copy Markdown
Contributor

Terreno ran inside Nimiq Pay but used only one of the two providers it is given. window.ethereum carried the whole app; the Nimiq provider was never called, and lib/nimiq.ts documented the SDK as deliberately unimported. A holder had no way to show a NIM address on their deed.

This adds that flow across both providers. A holder taps LINK to share a Nimiq account, SIGN to sign a challenge with their Nimiq key, then CONFIRM to sign the same challenge with their Base wallet. Both signatures cover one nonce'd message naming both addresses, which is what binds them — either alone proves control of one side and says nothing about the other. isMutuallyProven() is the only thing rendered as SIGNED, and a half-signed record is stored honestly as half-signed so a declined Base signature costs one dialog on retry rather than three.

What changes:

  • lib/nimiqProvider.tslistAccounts/sign behind a dynamic import() gated on isNimiqPay(). The SDK never enters the static graph, mirroring the rule that keeps Privy out of the Nimiq Pay chunk. The provider resolves { error } rather than rejecting, so a declined dialog arrives as a fulfilled promise; every call is narrowed so a refusal throws instead of passing through as data.
  • lib/ethProvider.tspersonal_sign plus eth_requestAccounts. wagmi's injected connector still owns account access and the buy path; this is only what it has no hook for. Messages are hex-encoded, since an unencoded non-ASCII message encodes differently across wallets and the approved bytes stop matching the stored ones.
  • hooks/useNimiqLink.ts — the three confirmed calls as a tap-per-dialog state machine. None are chained: queued native dialogs are indistinguishable to whoever answers them, and nothing runs on mount.
  • components/Profile/ChainsBlock.tsx — both providers as one block on the deed. Connecting a Base wallet is the precondition for the whole block, so the NIM row names it rather than offering a dead control.
  • lib/nimiq.ts — the header no longer claims the SDK is never imported.

Why it is safe: nothing on the money path reads a link. Pixels are still bought with USDC/USDT on Base over window.ethereum, and the buy hooks are untouched. Storage is client-side and per-Base-address, and loadNimiqLink refuses a record whose baseAddress disagrees with the key it sits under — localStorage is writable by whoever holds the browser, and such a record would show a signature pair as proof of a binding the connected holder never made.

Deliberately not included: nothing verifies these signatures. They are recorded, not checked — that needs the Nimiq signature scheme for one half and personal_ecRecover for the other. So a link is strong evidence to the person who made it and unchecked data to anyone else. Stated in the module header rather than left implicit. Paying for land in NIM is also out of scope; the contract takes an accepted ERC-20 on Base and NIM is not one.

Verification: tsc --noEmit clean, next lint clean, 62 test files and the full suite pass, coverage floors held (39.3% overall, up from 38.7%). 100 tests are new. Mutation-tested: dropping the error-envelope narrowing turns 4 red, chaining the second dialog onto the first turns 3 red, signing a fresh challenge on the Base half instead of the shared one turns 1 red, and passing raw text to personal_sign turns 1 red. A fifth mutation — proven ignoring the Base signature — survives as an equivalent mutant and is commented as such at the site.

Not verified automatically: the native dialogs themselves. This needs a manual pass in Nimiq Pay on a device — pnpm dev --host, open the LAN URL, and walk all three confirmations plus a decline at each step.

Claude-Session: https://claude.ai/code/session_01Ckb8yB9X3p41mVvfmfQdpo

The hole, and the fix

What this does NOT do / residual risk

Judgement calls

Issues

Closes #
Refs #

Stacking / conflicts

Verification evidence

Remaining ops steps

  • none

Checklist

  • Title is the commit message I want on main
  • Ships the test that fails on pre-fix code; fixtures are real captured data; fakes honour their arguments
  • Covers the seam, not only pure functions; any stated guarantee tested on its failure path
  • Error responses audited for what they leak; reads on write paths bounded at the query
  • Judgement calls / bundled product changes flagged above (or "none")
  • lint / typecheck / test pass locally on the current head
  • Re-read acceptance criteria of every Closes issue — all met
  • Development sidebar links match Closes/Refs above
  • README / runbook / .env.example / examples / error strings updated for the world this creates
  • Lockfile touched → rebased on current main, lockfile regenerated (never hand-resolved)
  • Wallet/provider tree touched → loaded in a normal browser, not only Nimiq Pay
  • Money/security path → money-path-checklist.md run; payout logic compared against the other side
  • No secrets in the diff
  • Questions for the maintainer marked clearly at the end (or "none")

Terreno ran inside Nimiq Pay but used only one of the two providers it is
given. `window.ethereum` carried the whole app; the Nimiq provider was
never called, and `lib/nimiq.ts` documented the SDK as deliberately
unimported. A holder had no way to show a NIM address on their deed.

This adds that flow across both providers. A holder taps LINK to share a
Nimiq account, SIGN to sign a challenge with their Nimiq key, then
CONFIRM to sign the same challenge with their Base wallet. Both
signatures cover one nonce'd message naming both addresses, which is
what binds them — either alone proves control of one side and says
nothing about the other. `isMutuallyProven()` is the only thing rendered
as SIGNED, and a half-signed record is stored honestly as half-signed so
a declined Base signature costs one dialog on retry rather than three.

What changes:

- `lib/nimiqProvider.ts` — `listAccounts`/`sign` behind a dynamic
  `import()` gated on `isNimiqPay()`. The SDK never enters the static
  graph, mirroring the rule that keeps Privy out of the Nimiq Pay chunk.
  The provider *resolves* `{ error }` rather than rejecting, so a
  declined dialog arrives as a fulfilled promise; every call is narrowed
  so a refusal throws instead of passing through as data.
- `lib/ethProvider.ts` — `personal_sign` plus `eth_requestAccounts`.
  wagmi's injected connector still owns account access and the buy path;
  this is only what it has no hook for. Messages are hex-encoded, since
  an unencoded non-ASCII message encodes differently across wallets and
  the approved bytes stop matching the stored ones.
- `hooks/useNimiqLink.ts` — the three confirmed calls as a tap-per-dialog
  state machine. None are chained: queued native dialogs are
  indistinguishable to whoever answers them, and nothing runs on mount.
- `components/Profile/ChainsBlock.tsx` — both providers as one block on
  the deed. Connecting a Base wallet is the precondition for the whole
  block, so the NIM row names it rather than offering a dead control.
- `lib/nimiq.ts` — the header no longer claims the SDK is never imported.

Why it is safe: nothing on the money path reads a link. Pixels are still
bought with USDC/USDT on Base over `window.ethereum`, and the buy hooks
are untouched. Storage is client-side and per-Base-address, and
`loadNimiqLink` refuses a record whose `baseAddress` disagrees with the
key it sits under — localStorage is writable by whoever holds the
browser, and such a record would show a signature pair as proof of a
binding the connected holder never made.

Deliberately not included: nothing verifies these signatures. They are
recorded, not checked — that needs the Nimiq signature scheme for one
half and `personal_ecRecover` for the other. So a link is strong
evidence to the person who made it and unchecked data to anyone else.
Stated in the module header rather than left implicit. Paying for land
in NIM is also out of scope; the contract takes an accepted ERC-20 on
Base and NIM is not one.

Verification: `tsc --noEmit` clean, `next lint` clean, 62 test files and
the full suite pass, coverage floors held (39.3% overall, up from
38.7%). 100 tests are new. Mutation-tested: dropping the error-envelope
narrowing turns 4 red, chaining the second dialog onto the first turns 3
red, signing a fresh challenge on the Base half instead of the shared
one turns 1 red, and passing raw text to `personal_sign` turns 1 red. A
fifth mutation — `proven` ignoring the Base signature — survives as an
equivalent mutant and is commented as such at the site.

Not verified automatically: the native dialogs themselves. This needs a
manual pass in Nimiq Pay on a device — `pnpm dev --host`, open the LAN
URL, and walk all three confirmations plus a decline at each step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckb8yB9X3p41mVvfmfQdpo
@vercel

vercel Bot commented Aug 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
terreno-web Ready Ready Preview Aug 26, 2026 10:59pm

Request Review

@Samuel1-ona
Samuel1-ona merged commit e9f6a79 into main Aug 26, 2026
3 checks passed
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.

1 participant