feat(profile): link a NIM address to a deed with both injected providers - #3
Merged
Merged
Conversation
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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Terreno ran inside Nimiq Pay but used only one of the two providers it is given.
window.ethereumcarried the whole app; the Nimiq provider was never called, andlib/nimiq.tsdocumented 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/signbehind a dynamicimport()gated onisNimiqPay(). 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_signpluseth_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, andloadNimiqLinkrefuses a record whosebaseAddressdisagrees 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_ecRecoverfor 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 --noEmitclean,next lintclean, 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 topersonal_signturns 1 red. A fifth mutation —provenignoring 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
Checklist
mainlint/typecheck/testpass locally on the current headClosesissue — all met.env.example/ examples / error strings updated for the world this createsmain, lockfile regenerated (never hand-resolved)money-path-checklist.mdrun; payout logic compared against the other side