diff --git a/apps/web/src/__tests__/components/nimiq-privy-isolation.test.ts b/apps/web/src/__tests__/components/nimiq-privy-isolation.test.ts index d1e9efb..73d3f86 100644 --- a/apps/web/src/__tests__/components/nimiq-privy-isolation.test.ts +++ b/apps/web/src/__tests__/components/nimiq-privy-isolation.test.ts @@ -92,3 +92,59 @@ describe('Nimiq Pay bundle isolation', () => { expect(staticImportSources(context)).toEqual(['react']) }) }) + +/** + * The mirror-image rule: `@nimiq/mini-app-sdk` must stay out of the static + * graph too. + * + * Only Nimiq Pay clients can use the NIM provider, but a value import anywhere + * reachable from the shell would ship the SDK — and its `events` polyfill and + * provider stack — to every browser visitor and into the map's first paint. + * `lib/nimiqProvider.ts` reaches it through a dynamic `import()` behind an + * `isNimiqPay()` gate; `lib/nimiq.ts` must not reach it at all, because the + * whole point of that module is a synchronous, dependency-free detection path. + * + * Type-only imports are allowed: `import type` is erased before webpack sees + * it, so it costs nothing at runtime. + */ +const SRC = path.join(process.cwd(), 'src') + +function readSrc(file: string): string { + return fs.readFileSync(path.join(SRC, file), 'utf8') +} + +/** Static value-import sources only — ignores `import()` and `import type`. */ +function staticValueImportSources(source: string): string[] { + return Array.from( + source.matchAll(/^\s*import\s+(?!type\s)[^;]*?from\s+['"]([^'"]+)['"]/gm), + (m) => m[1], + ) +} + +describe('Nimiq SDK bundle isolation', () => { + it.each([ + 'lib/nimiq.ts', + 'lib/nimiqProvider.ts', + 'lib/nimiqLink.ts', + 'hooks/useNimiqLink.ts', + 'components/Profile/ChainsBlock.tsx', + ])('%s does not statically value-import the SDK', (file) => { + const sdk = staticValueImportSources(readSrc(file)).filter((s) => + s.startsWith('@nimiq/mini-app-sdk'), + ) + expect(sdk).toEqual([]) + }) + + it('nimiqProvider reaches the SDK through a dynamic import, behind the host gate', () => { + const source = readSrc('lib/nimiqProvider.ts') + expect(source).toMatch(/import\(['"]@nimiq\/mini-app-sdk['"]\)/) + // The gate is what keeps the chunk from being fetched in a plain browser. + expect(source).toMatch(/if\s*\(!isNimiqPay\(\)\)\s*return null/) + }) + + it('the sync detection path stays dependency-free', () => { + // `lib/nimiq.ts` is read during render by wallet-provider; anything it + // imports is on the critical path for every client. + expect(staticValueImportSources(readSrc('lib/nimiq.ts'))).toEqual([]) + }) +}) diff --git a/apps/web/src/__tests__/hooks/useNimiqLink.test.ts b/apps/web/src/__tests__/hooks/useNimiqLink.test.ts new file mode 100644 index 0000000..f1fae24 --- /dev/null +++ b/apps/web/src/__tests__/hooks/useNimiqLink.test.ts @@ -0,0 +1,325 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' +import { useNimiqLink } from '@/hooks/useNimiqLink' +import { NimiqProviderError, listNimiqAccounts, signWithNimiq } from '@/lib/nimiqProvider' +import { EthProviderError, personalSign, requestEthAccounts } from '@/lib/ethProvider' +import { isNimiqPay } from '@/lib/nimiq' +import { loadNimiqLink, saveNimiqLink, type NimiqLink } from '@/lib/nimiqLink' + +vi.mock('@/lib/nimiq', () => ({ isNimiqPay: vi.fn(() => true) })) +vi.mock('@/lib/nimiqProvider', async (importOriginal) => ({ + ...(await importOriginal()), + listNimiqAccounts: vi.fn(), + signWithNimiq: vi.fn(), +})) +vi.mock('@/lib/ethProvider', async (importOriginal) => ({ + ...(await importOriginal()), + requestEthAccounts: vi.fn(), + personalSign: vi.fn(), +})) + +const NIM = 'NQ07 0000 0000 0000 0000 0000 0000 0000 0001' +const BASE = '0x8db1EaAd99eF3a4c2AE4479D0570C00E12Be3f79' + +function storedLink(overrides: Partial = {}): NimiqLink { + return { + nimAddress: NIM, + nimPublicKey: 'pk', + nimSignature: 'nimsig', + baseAddress: BASE.toLowerCase(), + baseSignature: '0xbasesig', + message: 'msg', + linkedAt: 1, + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + vi.mocked(isNimiqPay).mockReturnValue(true) + vi.mocked(listNimiqAccounts).mockResolvedValue([NIM]) + vi.mocked(signWithNimiq).mockResolvedValue({ publicKey: 'pk', signature: 'nimsig' }) + vi.mocked(personalSign).mockResolvedValue('0xbasesig') + vi.mocked(requestEthAccounts).mockResolvedValue([BASE]) +}) + +/** Drive the whole flow; each `act` is one tap, as the UI enforces. */ +async function runFullFlow(result: { current: ReturnType }) { + await act(async () => { + await result.current.requestAccount() + }) + await act(async () => { + await result.current.signNim() + }) + await act(async () => { + await result.current.signBase() + }) +} + +/** + * The rule under test throughout: each confirmed call across BOTH providers is + * reachable only from its own tap. A regression shows up here as a call that + * happened without an `act()` driving it. + */ +describe('dialog discipline', () => { + it('raises no dialog on mount', async () => { + renderHook(() => useNimiqLink(BASE)) + await waitFor(() => expect(listNimiqAccounts).not.toHaveBeenCalled()) + expect(signWithNimiq).not.toHaveBeenCalled() + expect(personalSign).not.toHaveBeenCalled() + expect(requestEthAccounts).not.toHaveBeenCalled() + }) + + it('does not chain the Nimiq signing dialog onto the account dialog', async () => { + const { result } = renderHook(() => useNimiqLink(BASE)) + await act(async () => { + await result.current.requestAccount() + }) + + expect(listNimiqAccounts).toHaveBeenCalledTimes(1) + expect(signWithNimiq).not.toHaveBeenCalled() + expect(result.current.status).toBe('account-ready') + }) + + it('does not chain the Base dialog onto the Nimiq signature', async () => { + const { result } = renderHook(() => useNimiqLink(BASE)) + await act(async () => { + await result.current.requestAccount() + }) + await act(async () => { + await result.current.signNim() + }) + + expect(result.current.status).toBe('nim-signed') + expect(personalSign).not.toHaveBeenCalled() + }) + + // wagmi's injected connector already owns eth_requestAccounts, and the deed + // cannot reach this flow unconnected, so a second account dialog would buy + // nothing. The control below proves the flow did run. + it('never raises an eth_requestAccounts dialog of its own', async () => { + const { result } = renderHook(() => useNimiqLink(BASE)) + await runFullFlow(result) + expect(requestEthAccounts).not.toHaveBeenCalled() + expect(personalSign).toHaveBeenCalledTimes(1) + expect(result.current.proven).toBe(true) + }) + + it('restores a stored link without calling either provider', async () => { + saveNimiqLink(storedLink()) + const { result } = renderHook(() => useNimiqLink(BASE)) + await waitFor(() => expect(result.current.status).toBe('linked')) + expect(listNimiqAccounts).not.toHaveBeenCalled() + expect(personalSign).not.toHaveBeenCalled() + }) +}) + +describe('the full dual-provider path', () => { + it('links after all three taps', async () => { + const { result } = renderHook(() => useNimiqLink(BASE)) + await runFullFlow(result) + + expect(result.current.status).toBe('linked') + expect(result.current.proven).toBe(true) + expect(result.current.link).toMatchObject({ + nimAddress: NIM, + nimSignature: 'nimsig', + baseAddress: BASE.toLowerCase(), + baseSignature: '0xbasesig', + }) + }) + + // The binding is the pair over one message. Two signatures over two + // different challenges would prove two unrelated things. + it('signs the SAME challenge with both providers', async () => { + const { result } = renderHook(() => useNimiqLink(BASE)) + await runFullFlow(result) + + const nimMessage = vi.mocked(signWithNimiq).mock.calls[0][0] + const baseMessage = vi.mocked(personalSign).mock.calls[0][0] + expect(baseMessage).toBe(nimMessage) + expect(result.current.link?.message).toBe(nimMessage) + }) + + it('signs a challenge naming both addresses', async () => { + const { result } = renderHook(() => useNimiqLink(BASE)) + await runFullFlow(result) + + const signed = vi.mocked(signWithNimiq).mock.calls[0][0] + expect(signed).toContain(NIM) + expect(signed).toContain(BASE) + }) + + it('signs with the address the challenge names', async () => { + const { result } = renderHook(() => useNimiqLink(BASE)) + await runFullFlow(result) + expect(vi.mocked(personalSign).mock.calls[0][1]).toBe(BASE) + }) +}) + +describe('the half-signed state', () => { + it('persists the NIM half but does not call it proven', async () => { + const { result } = renderHook(() => useNimiqLink(BASE)) + await act(async () => { + await result.current.requestAccount() + }) + await act(async () => { + await result.current.signNim() + }) + + expect(result.current.proven).toBe(false) + expect(loadNimiqLink(BASE)?.baseSignature).toBeNull() + }) + + it('resumes at the Base step after a reload', async () => { + saveNimiqLink(storedLink({ baseSignature: null })) + const { result } = renderHook(() => useNimiqLink(BASE)) + await waitFor(() => expect(result.current.status).toBe('nim-signed')) + expect(result.current.proven).toBe(false) + }) + + it('refuses the Base step before the Nimiq half exists', async () => { + const { result } = renderHook(() => useNimiqLink(BASE)) + await act(async () => { + await result.current.signBase() + }) + + expect(personalSign).not.toHaveBeenCalled() + expect(result.current.failedStep).toBe('nim-signature') + }) +}) + +describe('declines and failures', () => { + it('reports a declined Nimiq account dialog and stays unlinked', async () => { + vi.mocked(listNimiqAccounts).mockRejectedValue( + new NimiqProviderError('User denied account access', 'USER_REJECTED'), + ) + const { result } = renderHook(() => useNimiqLink(BASE)) + await act(async () => { + await result.current.requestAccount() + }) + + expect(result.current.status).toBe('idle') + expect(result.current.error).toBe('User denied account access') + expect(result.current.failedStep).toBe('nim-account') + expect(loadNimiqLink(BASE)).toBeNull() + }) + + it('falls back to account-ready when the Nimiq signature is declined', async () => { + vi.mocked(signWithNimiq).mockRejectedValue(new NimiqProviderError('User cancelled')) + const { result } = renderHook(() => useNimiqLink(BASE)) + await act(async () => { + await result.current.requestAccount() + }) + await act(async () => { + await result.current.signNim() + }) + + expect(result.current.status).toBe('account-ready') + expect(result.current.failedStep).toBe('nim-signature') + expect(loadNimiqLink(BASE)).toBeNull() + }) + + // The NIM half cost the user two dialogs; a declined Base signature must not + // throw it away. + it('keeps the NIM half when the Base signature is declined', async () => { + vi.mocked(personalSign).mockRejectedValue( + new EthProviderError('User denied message signature.', 4001), + ) + const { result } = renderHook(() => useNimiqLink(BASE)) + await runFullFlow(result) + + expect(result.current.status).toBe('nim-signed') + expect(result.current.failedStep).toBe('base-signature') + expect(result.current.proven).toBe(false) + expect(loadNimiqLink(BASE)?.nimSignature).toBe('nimsig') + expect(loadNimiqLink(BASE)?.baseSignature).toBeNull() + }) + + // Reachable when the wallet disconnects between the Nimiq half and the Base + // half. Signing with whatever the provider offers next would record a pair + // binding an address the holder never chose. + it('refuses to sign when the wallet disconnected mid-flow', async () => { + saveNimiqLink(storedLink({ baseSignature: null })) + const { result, rerender } = renderHook( + ({ addr }: { addr: string | undefined }) => useNimiqLink(addr), + { initialProps: { addr: BASE as string | undefined } }, + ) + await waitFor(() => expect(result.current.status).toBe('nim-signed')) + + rerender({ addr: undefined }) + + await act(async () => { + await result.current.signBase() + }) + + expect(personalSign).not.toHaveBeenCalled() + }) + + it('refuses to start without a connected Base wallet', async () => { + const { result } = renderHook(() => useNimiqLink(undefined)) + await act(async () => { + await result.current.requestAccount() + }) + + expect(listNimiqAccounts).not.toHaveBeenCalled() + expect(result.current.error).toBe('Connect a Base wallet first.') + }) + + it('refuses to sign with Nimiq before an account has been shared', async () => { + const { result } = renderHook(() => useNimiqLink(BASE)) + await act(async () => { + await result.current.signNim() + }) + + expect(signWithNimiq).not.toHaveBeenCalled() + expect(result.current.failedStep).toBe('nim-account') + }) +}) + +describe('outside Nimiq Pay', () => { + it('reports unsupported and offers no flow', async () => { + vi.mocked(isNimiqPay).mockReturnValue(false) + const { result } = renderHook(() => useNimiqLink(BASE)) + await waitFor(() => expect(result.current.status).toBe('unsupported')) + }) + + it('still shows a link made in a Nimiq Pay session', async () => { + saveNimiqLink(storedLink()) + vi.mocked(isNimiqPay).mockReturnValue(false) + const { result } = renderHook(() => useNimiqLink(BASE)) + await waitFor(() => expect(result.current.status).toBe('linked')) + expect(result.current.proven).toBe(true) + }) +}) + +describe('switching wallets', () => { + it('drops the previous holder’s link when the Base address changes', async () => { + saveNimiqLink(storedLink()) + const { result, rerender } = renderHook( + ({ addr }: { addr: string }) => useNimiqLink(addr), + { initialProps: { addr: BASE } }, + ) + await waitFor(() => expect(result.current.status).toBe('linked')) + + rerender({ addr: '0x000000000000000000000000000000000000dead' }) + + await waitFor(() => expect(result.current.status).toBe('idle')) + expect(result.current.nimAddress).toBeNull() + expect(result.current.link).toBeNull() + expect(result.current.proven).toBe(false) + }) + + it('unlink forgets the record for this wallet only', async () => { + const { result } = renderHook(() => useNimiqLink(BASE)) + await runFullFlow(result) + + act(() => { + result.current.unlink() + }) + + expect(result.current.status).toBe('idle') + expect(loadNimiqLink(BASE)).toBeNull() + }) +}) diff --git a/apps/web/src/__tests__/lib/ethProvider.test.ts b/apps/web/src/__tests__/lib/ethProvider.test.ts new file mode 100644 index 0000000..8356670 --- /dev/null +++ b/apps/web/src/__tests__/lib/ethProvider.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { + EthProviderError, + isUserRejection, + personalSign, + requestEthAccounts, + toHexUtf8, +} from '@/lib/ethProvider' + +const ADDR = '0x8db1EaAd99eF3a4c2AE4479D0570C00E12Be3f79' +const request = vi.fn() + +beforeEach(() => { + vi.clearAllMocks() + ;(window as unknown as { ethereum?: unknown }).ethereum = { request } +}) + +afterEach(() => { + delete (window as unknown as { ethereum?: unknown }).ethereum +}) + +describe('toHexUtf8', () => { + it('hex-encodes ASCII', () => { + expect(toHexUtf8('abc')).toBe('0x616263') + }) + + // The reason this is a helper rather than an inline expression: an unencoded + // non-ASCII message encodes differently across wallets, so the bytes the user + // approves stop matching the bytes stored beside the signature. + it('hex-encodes multi-byte characters as UTF-8', () => { + expect(toHexUtf8('é')).toBe('0xc3a9') + expect(toHexUtf8('→')).toBe('0xe28692') + }) + + it('encodes the newlines a multi-line challenge carries', () => { + expect(toHexUtf8('a\nb')).toBe('0x610a62') + }) +}) + +describe('requestEthAccounts', () => { + it('returns the wallet’s addresses', async () => { + request.mockResolvedValue([ADDR]) + expect(await requestEthAccounts()).toEqual([ADDR]) + expect(request).toHaveBeenCalledWith({ method: 'eth_requestAccounts' }) + }) + + it('throws with the provider’s message when the user declines', async () => { + request.mockRejectedValue({ code: 4001, message: 'User rejected the request.' }) + await expect(requestEthAccounts()).rejects.toThrow('User rejected the request.') + }) + + it('preserves the 4001 code so a decline can be told from a failure', async () => { + request.mockRejectedValue({ code: 4001, message: 'nope' }) + const err = await requestEthAccounts().catch((e: unknown) => e) + expect(isUserRejection(err)).toBe(true) + }) + + it('does not mistake a transport failure for a decline', async () => { + request.mockRejectedValue({ code: -32603, message: 'internal error' }) + const err = await requestEthAccounts().catch((e: unknown) => e) + expect(isUserRejection(err)).toBe(false) + }) + + it('treats an empty account list as a failure', async () => { + request.mockResolvedValue([]) + await expect(requestEthAccounts()).rejects.toThrow('no Ethereum accounts') + }) + + it('reports a missing provider rather than throwing a TypeError', async () => { + delete (window as unknown as { ethereum?: unknown }).ethereum + await expect(requestEthAccounts()).rejects.toThrow(EthProviderError) + }) +}) + +describe('personalSign', () => { + it('sends hex-encoded data and the address, in that order', async () => { + request.mockResolvedValue('0xsig') + await personalSign('hello', ADDR) + expect(request).toHaveBeenCalledWith({ + method: 'personal_sign', + params: ['0x68656c6c6f', ADDR], + }) + }) + + it('returns the signature', async () => { + request.mockResolvedValue('0xdeadbeef') + expect(await personalSign('hello', ADDR)).toBe('0xdeadbeef') + }) + + it('throws when the user declines the signing dialog', async () => { + request.mockRejectedValue({ code: 4001, message: 'User denied message signature.' }) + await expect(personalSign('hello', ADDR)).rejects.toThrow('User denied message signature.') + }) + + it.each([ + ['a non-hex string', 'not-hex'], + ['a number', 1], + ['null', null], + ])('rejects %s as an unusable signature', async (_label, result) => { + request.mockResolvedValue(result) + await expect(personalSign('hello', ADDR)).rejects.toThrow('unusable signature') + }) + + it('refuses to raise a dialog for an empty message', async () => { + await expect(personalSign(' ', ADDR)).rejects.toThrow('empty message') + expect(request).not.toHaveBeenCalled() + }) + + it('refuses to sign without an address', async () => { + await expect(personalSign('hello', '')).rejects.toThrow('No address') + expect(request).not.toHaveBeenCalled() + }) +}) diff --git a/apps/web/src/__tests__/lib/nimiqLink.test.ts b/apps/web/src/__tests__/lib/nimiqLink.test.ts new file mode 100644 index 0000000..d27b3fa --- /dev/null +++ b/apps/web/src/__tests__/lib/nimiqLink.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { + buildLinkChallenge, + clearNimiqLink, + formatNimAddress, + isMutuallyProven, + isNimAddress, + loadNimiqLink, + makeNonce, + saveNimiqLink, + type NimiqLink, +} from '@/lib/nimiqLink' + +const NIM = 'NQ07 0000 0000 0000 0000 0000 0000 0000 0001' +const BASE = '0x8db1EaAd99eF3a4c2AE4479D0570C00E12Be3f79' + +function link(overrides: Partial = {}): NimiqLink { + return { + nimAddress: NIM, + nimPublicKey: 'pk', + nimSignature: 'nimsig', + baseAddress: BASE.toLowerCase(), + baseSignature: '0xbasesig', + message: 'msg', + linkedAt: 1_700_000_000_000, + ...overrides, + } +} + +beforeEach(() => { + localStorage.clear() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('isNimAddress', () => { + it('accepts a spaced NIM address', () => { + expect(isNimAddress(NIM)).toBe(true) + }) + + it('accepts the same address with its spaces stripped', () => { + expect(isNimAddress(NIM.replace(/ /g, ''))).toBe(true) + }) + + it.each([ + ['an EVM address', BASE], + ['an empty string', ''], + ['a truncated address', 'NQ07 0000'], + ['lowercase groups', NIM.toLowerCase()], + ['a non-string', 42], + ])('rejects %s', (_label, value) => { + expect(isNimAddress(value)).toBe(false) + }) +}) + +describe('buildLinkChallenge', () => { + const challenge = buildLinkChallenge({ + baseAddress: BASE, + nimAddress: NIM, + nonce: 'deadbeefdeadbeef', + issuedAt: new Date('2026-08-26T12:00:00.000Z'), + }) + + it('names both addresses in full, so one signature cannot be replayed against another pairing', () => { + expect(challenge).toContain(NIM) + expect(challenge).toContain(BASE) + }) + + it('carries the nonce and issue time', () => { + expect(challenge).toContain('deadbeefdeadbeef') + expect(challenge).toContain('2026-08-26T12:00:00.000Z') + }) + + it('tells the signer what it does not do', () => { + expect(challenge).toContain('does not move funds') + }) + + it('says both wallets sign it, because both do', () => { + expect(challenge).toContain('Both wallets sign this') + }) + + it('differs between calls, because the nonce differs', () => { + const a = buildLinkChallenge({ baseAddress: BASE, nimAddress: NIM, nonce: makeNonce() }) + const b = buildLinkChallenge({ baseAddress: BASE, nimAddress: NIM, nonce: makeNonce() }) + expect(a).not.toEqual(b) + }) +}) + +describe('makeNonce', () => { + it('returns 16 hex characters', () => { + expect(makeNonce()).toMatch(/^[0-9a-f]{16}$/) + }) + + it('still returns a well-formed nonce without a CSPRNG', () => { + vi.spyOn(globalThis, 'crypto', 'get').mockReturnValue( + undefined as unknown as Crypto, + ) + expect(makeNonce()).toMatch(/^[0-9a-f]{16}$/) + }) +}) + +describe('storage round-trip', () => { + it('saves and loads a link for its Base address', () => { + saveNimiqLink(link()) + expect(loadNimiqLink(BASE)?.nimAddress).toBe(NIM) + }) + + it('matches the Base address case-insensitively', () => { + saveNimiqLink(link()) + expect(loadNimiqLink(BASE.toUpperCase())).not.toBeNull() + }) + + it('does not leak one wallet’s link to another', () => { + saveNimiqLink(link()) + expect(loadNimiqLink('0x000000000000000000000000000000000000dead')).toBeNull() + }) + + it('returns null when no address is connected', () => { + saveNimiqLink(link()) + expect(loadNimiqLink(undefined)).toBeNull() + }) + + it('clears only the addressed link', () => { + const other = '0x000000000000000000000000000000000000dead' + saveNimiqLink(link()) + saveNimiqLink(link({ baseAddress: other })) + clearNimiqLink(BASE) + expect(loadNimiqLink(BASE)).toBeNull() + expect(loadNimiqLink(other)).not.toBeNull() + }) + + it.each([ + ['a NIM-signature-less record', { ...link(), nimSignature: '' }], + ['an empty public key', { ...link(), nimPublicKey: '' }], + ['a non-NIM address', { ...link(), nimAddress: BASE }], + ['a missing timestamp', { ...link(), linkedAt: undefined }], + ['an empty-string Base signature posing as one', { ...link(), baseSignature: '' }], + ])('refuses to load %s as a link', (_label, record) => { + localStorage.setItem( + 'terreno.nimiq-link.v1', + JSON.stringify({ [BASE.toLowerCase()]: record }), + ) + expect(loadNimiqLink(BASE)).toBeNull() + }) + + it('survives corrupt JSON in storage', () => { + localStorage.setItem('terreno.nimiq-link.v1', '{not json') + expect(loadNimiqLink(BASE)).toBeNull() + }) + + it('survives storage that throws on access, as a private WebView does', () => { + const boom = () => { + throw new Error('SecurityError') + } + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(boom) + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(boom) + + expect(() => saveNimiqLink(link())).not.toThrow() + expect(loadNimiqLink(BASE)).toBeNull() + }) +}) + +describe('formatNimAddress', () => { + it('keeps the leading groups and the last one', () => { + expect(formatNimAddress(NIM)).toBe('NQ07 0000…0001') + }) + + it('formats an unspaced address the same way', () => { + expect(formatNimAddress(NIM.replace(/ /g, ''))).toBe('NQ07 0000…0001') + }) + + it('returns a non-NIM value untouched rather than mangling it', () => { + expect(formatNimAddress(BASE)).toBe(BASE) + }) +}) + + +describe('isMutuallyProven', () => { + it('is true only when both halves have signed', () => { + expect(isMutuallyProven(link())).toBe(true) + }) + + // The half-signed record is legitimate and must persist — but calling it + // proven would claim the Base holder signed when they never did. + it('is false for a NIM-only link', () => { + expect(isMutuallyProven(link({ baseSignature: null }))).toBe(false) + }) + + it('is false for a null link', () => { + expect(isMutuallyProven(null)).toBe(false) + }) + + it('is false for an empty-string Base signature', () => { + expect(isMutuallyProven(link({ baseSignature: '' }))).toBe(false) + }) +}) + +describe('half-signed records', () => { + it('round-trips a NIM-only link', () => { + saveNimiqLink(link({ baseSignature: null })) + const loaded = loadNimiqLink(BASE) + expect(loaded).not.toBeNull() + expect(loaded?.baseSignature).toBeNull() + }) + + it('does not read a v1 record, which had no Base half', () => { + localStorage.setItem( + 'terreno.nimiq-link.v1', + JSON.stringify({ + [BASE.toLowerCase()]: { + nimAddress: NIM, + baseAddress: BASE.toLowerCase(), + publicKey: 'pk', + signature: 'sig', + message: 'msg', + linkedAt: 1, + }, + }), + ) + expect(loadNimiqLink(BASE)).toBeNull() + }) +}) + +describe('a tampered store', () => { + // localStorage is writable by whoever holds the browser. A record filed + // under one wallet while naming another would render a signature pair as + // proof of a binding the connected holder never made. + it('refuses a record whose baseAddress does not match the key it sits under', () => { + const other = '0x000000000000000000000000000000000000dead' + localStorage.setItem( + 'terreno.nimiq-link.v2', + JSON.stringify({ [BASE.toLowerCase()]: link({ baseAddress: other }) }), + ) + expect(loadNimiqLink(BASE)).toBeNull() + }) + + it('accepts the same record under its own key — the control for the case above', () => { + const other = '0x000000000000000000000000000000000000dead' + localStorage.setItem( + 'terreno.nimiq-link.v2', + JSON.stringify({ [other]: link({ baseAddress: other }) }), + ) + expect(loadNimiqLink(other)).not.toBeNull() + }) +}) diff --git a/apps/web/src/__tests__/lib/nimiqProvider.test.ts b/apps/web/src/__tests__/lib/nimiqProvider.test.ts new file mode 100644 index 0000000..a0a1a62 --- /dev/null +++ b/apps/web/src/__tests__/lib/nimiqProvider.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { + NimiqProviderError, + listNimiqAccounts, + loadNimiqProvider, + resetNimiqProviderForTests, + signWithNimiq, +} from '@/lib/nimiqProvider' +import { isNimiqPay } from '@/lib/nimiq' + +vi.mock('@/lib/nimiq', () => ({ isNimiqPay: vi.fn(() => true) })) + +// Hoisted above the `vi.mock` factory, which is itself hoisted — the repo's +// existing convention for doubles a factory has to close over. +const h = vi.hoisted(() => ({ + listAccounts: vi.fn(), + sign: vi.fn(), + init: vi.fn(), +})) +const { listAccounts, sign, init } = h + +vi.mock('@nimiq/mini-app-sdk', () => ({ init: h.init })) + +const NIM = 'NQ07 0000 0000 0000 0000 0000 0000 0000 0001' + +beforeEach(() => { + vi.clearAllMocks() + resetNimiqProviderForTests() + vi.mocked(isNimiqPay).mockReturnValue(true) + init.mockResolvedValue({ listAccounts, sign }) +}) + +describe('loadNimiqProvider', () => { + it('returns null outside Nimiq Pay, and never loads the SDK', async () => { + vi.mocked(isNimiqPay).mockReturnValue(false) + expect(await loadNimiqProvider()).toBeNull() + expect(init).not.toHaveBeenCalled() + }) + + it('initializes once across concurrent callers', async () => { + await Promise.all([loadNimiqProvider(), loadNimiqProvider(), loadNimiqProvider()]) + expect(init).toHaveBeenCalledTimes(1) + }) + + it('lets a failed init be retried rather than poisoning the session', async () => { + init.mockRejectedValueOnce(new Error('timed out')) + await expect(loadNimiqProvider()).rejects.toThrow('timed out') + + // Second attempt gets a fresh init, not the cached rejection. + await expect(loadNimiqProvider()).resolves.toMatchObject({ listAccounts }) + expect(init).toHaveBeenCalledTimes(2) + }) +}) + +describe('listNimiqAccounts', () => { + it('returns the host’s addresses', async () => { + listAccounts.mockResolvedValue([NIM]) + expect(await listNimiqAccounts()).toEqual([NIM]) + }) + + // The defect this module exists to prevent: the SDK types listAccounts as + // `Promise`, so a declined dialog arrives as a + // FULFILLED promise. Without narrowing, `[first]` on that object is + // undefined and a decline reads as success. + it('throws when the user declines, instead of resolving the error envelope', async () => { + listAccounts.mockResolvedValue({ + error: { type: 'USER_REJECTED', message: 'User denied account access' }, + }) + await expect(listNimiqAccounts()).rejects.toThrow('User denied account access') + }) + + it('carries the host’s error type through', async () => { + listAccounts.mockResolvedValue({ error: { type: 'USER_REJECTED', message: 'no' } }) + await expect(listNimiqAccounts()).rejects.toMatchObject({ type: 'USER_REJECTED' }) + }) + + it('falls back to a readable message when the envelope carries none', async () => { + listAccounts.mockResolvedValue({ error: { type: 'USER_REJECTED' } }) + await expect(listNimiqAccounts()).rejects.toThrow('Account access was declined.') + }) + + it('treats an empty account list as a failure, not an empty success', async () => { + listAccounts.mockResolvedValue([]) + await expect(listNimiqAccounts()).rejects.toThrow(NimiqProviderError) + }) + + it('throws outside Nimiq Pay', async () => { + vi.mocked(isNimiqPay).mockReturnValue(false) + await expect(listNimiqAccounts()).rejects.toThrow('Not running inside Nimiq Pay.') + }) +}) + +describe('signWithNimiq', () => { + it('returns the signature record', async () => { + sign.mockResolvedValue({ publicKey: 'pk', signature: 'sig' }) + expect(await signWithNimiq('hello')).toEqual({ publicKey: 'pk', signature: 'sig' }) + }) + + it('passes the message through verbatim — it is shown in the native dialog', async () => { + sign.mockResolvedValue({ publicKey: 'pk', signature: 'sig' }) + await signWithNimiq('line one\nline two') + expect(sign).toHaveBeenCalledWith('line one\nline two') + }) + + it('throws when the user declines the signing dialog', async () => { + sign.mockResolvedValue({ error: { type: 'USER_REJECTED', message: 'User cancelled' } }) + await expect(signWithNimiq('hello')).rejects.toThrow('User cancelled') + }) + + it.each([ + ['a missing signature', { publicKey: 'pk' }], + ['a missing public key', { signature: 'sig' }], + ['a non-string signature', { publicKey: 'pk', signature: 123 }], + ['null', null], + ])('rejects %s rather than storing an unusable link', async (_label, result) => { + sign.mockResolvedValue(result) + await expect(signWithNimiq('hello')).rejects.toThrow('unusable signature') + }) + + it('refuses to raise a dialog for an empty message', async () => { + await expect(signWithNimiq(' ')).rejects.toThrow('empty message') + expect(sign).not.toHaveBeenCalled() + }) +}) diff --git a/apps/web/src/app/profile/page.tsx b/apps/web/src/app/profile/page.tsx index 40d8189..bab3787 100644 --- a/apps/web/src/app/profile/page.tsx +++ b/apps/web/src/app/profile/page.tsx @@ -8,6 +8,7 @@ import BottomNav from '@/components/Layout/BottomNav' import AvatarBlock from '@/components/Profile/AvatarBlock' import StatsRow from '@/components/Profile/StatsRow' import ColorPicker from '@/components/Profile/ColorPicker' +import ChainsBlock from '@/components/Profile/ChainsBlock' import { useProfile } from '@/hooks/useProfile' import { useStablecoinBalance } from '@/hooks/useStablecoinBalance' import { useMaps } from '@/hooks/useMaps' @@ -556,6 +557,12 @@ export default function ProfilePage() { )} + {/* Chains — Base (where the land is) and the optional NIM link. + Below FILE CHANGES because the on-chain profile edit above is the + deed's primary action; linking a Nimiq address is a side note that + changes nothing on chain. */} + + {/* Share actions — grouped and secondary to FILE CHANGES, so they read as "spread the word", not a second primary action. Share needs plots to brag about; Invite always shows. */} diff --git a/apps/web/src/components/Profile/ChainsBlock.tsx b/apps/web/src/components/Profile/ChainsBlock.tsx new file mode 100644 index 0000000..70f0962 --- /dev/null +++ b/apps/web/src/components/Profile/ChainsBlock.tsx @@ -0,0 +1,249 @@ +'use client' + +import { useNimiqLink } from '@/hooks/useNimiqLink' +import { formatNimAddress } from '@/lib/nimiqLink' + +const MONO = "'Space Mono', monospace" + +const LABEL: React.CSSProperties = { + fontFamily: MONO, + fontWeight: 700, + fontSize: 9, + letterSpacing: '0.2em', +} + +const VALUE: React.CSSProperties = { + fontFamily: MONO, + fontSize: 11, + letterSpacing: '0.04em', + color: 'var(--on-surface)', + wordBreak: 'break-all', +} + +/** + * Row action. 44px minimum height — these are tapped with a thumb inside a + * phone WebView, and the compact `.pixel-btn-sm` alone lands under that. + */ +const ACTION: React.CSSProperties = { + minHeight: 44, + fontSize: 10, + whiteSpace: 'nowrap', + flexShrink: 0, +} + +interface ChainsBlockProps { + /** The connected Base address, or undefined when no wallet is connected. */ + baseAddress?: string +} + +/** + * Both injected providers, as one block on the deed. + * + * The two rows are the two providers, and the flow walks down the block: LINK + * and SIGN on the NIM row drive the Nimiq provider, then CONFIRM on the BASE + * row drives `personal_sign` over the same challenge. Each tap raises exactly + * one native dialog — never two in sequence, which are indistinguishable to the + * person answering them. + * + * Base is where land actually lives; that half is read from the connected + * wallet and is not something this block can change. What it *can* do is ask + * that wallet to sign, which is the difference between the CONNECTED and + * SIGNED states: connected says which wallet is in the session, signed proves + * control of it. Only `proven` — both signatures present — renders as SIGNED + * on both rows. + * + * Connecting a Base wallet is the precondition for the whole block, not just + * the Base row: the challenge names both addresses, so there is nothing to + * sign until the Base half is known. Both unavailable states therefore name + * what is missing — CONNECT WALLET FIRST without a wallet, NIMIQ PAY ONLY in a + * browser — rather than offering a control that cannot work. + */ +export default function ChainsBlock({ baseAddress }: ChainsBlockProps) { + const { + status, + nimAddress, + link, + proven, + error, + failedStep, + busy, + requestAccount, + signNim, + signBase, + unlink, + } = useNimiqLink(baseAddress) + + const shortBase = baseAddress + ? `${baseAddress.slice(0, 6)}…${baseAddress.slice(-4)}`.toUpperCase() + : null + + return ( + // No width or padding of its own: on the deed this sits inside the page's + // `maxWidth: 460, padding: '0 16px'` column, and setting either again here + // would inset it out of line with every sibling block. +
+
CHAINS
+ +
+ {/* ---- Base ------------------------------------------------------ */} +
+ + BASE + + + {shortBase ?? NOT CONNECTED} + + {/* The Base half of the proof. Until it is signed the row says + CONNECTED, not VERIFIED — being connected shows which wallet is + in the session, which is not the same as proving control of it. */} + {proven ? ( + ✓ SIGNED + ) : status === 'base-signing' ? ( + + ) : status === 'nim-signed' ? ( + + ) : shortBase ? ( + CONNECTED + ) : null} +
+ +
+ + {/* ---- Nimiq ----------------------------------------------------- */} +
+ + NIM + + + + {status === 'unsupported' ? ( + NIMIQ PAY ONLY + ) : status === 'account-pending' ? ( + CHECK NIMIQ PAY… + ) : status === 'nim-signing' ? ( + SIGN IN NIMIQ PAY… + ) : nimAddress ? ( + formatNimAddress(nimAddress) + ) : !baseAddress ? ( + // The precondition, named. A dash beside a dead button reads as + // broken; this says which step comes first. + CONNECT WALLET FIRST + ) : ( + + )} + + + {(status === 'nim-signed' || status === 'linked') && ( + ✓ SIGNED + )} + + {/* One control per state. `busy` disables rather than hides, so the + row does not change height while a native dialog is open. + Disconnected is the exception: the control is absent, not + disabled, because linking cannot start before a Base wallet is + connected and a greyed-out button invites a tap that does + nothing. The row says CONNECT WALLET FIRST instead. */} + {status === 'idle' && baseAddress && ( + + )} + + {status === 'account-ready' && ( + + )} + + {busy && status !== 'base-signing' && ( + + )} + + {/* Also offered while half-signed: a holder who signed with Nimiq + and then changed their mind about the Base half must be able to + discard the record rather than be stuck mid-flow. */} + {(status === 'linked' || status === 'nim-signed') && ( + + )} +
+
+ + {/* Status line. `aria-live` because the outcome of a native dialog lands + here and a screen-reader user gets no other announcement of it. */} +

+ {error ?? + (status === 'idle' && !baseAddress + ? 'Connect your Base wallet above, then link a Nimiq address to your deed.' + : status === 'account-ready' + ? 'Step 2 of 3 — sign with Nimiq to prove you control that address.' + : status === 'nim-signed' + ? 'Step 3 of 3 — confirm with your Base wallet to finish the link.' + : proven && link + ? `Both wallets signed ${new Date(link.linkedAt).toLocaleDateString()}. Stored on this device only.` + : status === 'idle' + ? 'Prove one owner holds both wallets. Three taps, no funds move.' + : '')} +

+
+ ) +} diff --git a/apps/web/src/hooks/useNimiqLink.ts b/apps/web/src/hooks/useNimiqLink.ts new file mode 100644 index 0000000..9b686da --- /dev/null +++ b/apps/web/src/hooks/useNimiqLink.ts @@ -0,0 +1,266 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { + NimiqProviderError, + listNimiqAccounts, + signWithNimiq, +} from '@/lib/nimiqProvider' +import { EthProviderError, personalSign } from '@/lib/ethProvider' +import { + type NimiqLink, + buildLinkChallenge, + clearNimiqLink, + loadNimiqLink, + makeNonce, + saveNimiqLink, +} from '@/lib/nimiqLink' +import { isNimiqPay } from '@/lib/nimiq' + +/** + * The dual-provider linking flow, as an explicit tap-per-dialog state machine. + * + * Three confirmed calls run across the two injected providers, and every one + * of them raises a native dialog: + * + * NIM : listAccounts() → sign() + * Base : personal_sign + * + * The mini-app rules forbid firing confirmed calls in rapid sequence, because + * queued native dialogs are indistinguishable to whoever is answering them. So + * none of the three are chained — each is behind its own tap: + * + * tap LINK → dialog 1 share Nimiq account → `account-ready` + * tap SIGN → dialog 2 sign the challenge → `nim-signed` + * tap CONFIRM → dialog 3 personal_sign → `linked` + * + * Account access on the Base side is NOT one of these. wagmi's `injected()` + * connector already owns `eth_requestAccounts`, and the deed cannot reach this + * flow without a connected wallet, so asking again would be a dialog that buys + * nothing. `lib/ethProvider.ts` exposes the call for callers that do need it; + * this flow deliberately is not one. + * + * Both signatures cover the SAME challenge. That is the point of the flow: the + * pair is what binds the two addresses, so the challenge is built once, when + * the NIM address arrives, and reused for the Base half. + * + * Nothing here runs on mount — `restore()` reads localStorage only. + */ +export type NimiqLinkStatus = + | 'unsupported' + | 'idle' + | 'account-pending' + | 'account-ready' + | 'nim-signing' + | 'nim-signed' + | 'base-signing' + | 'linked' + +/** Which call failed, so a retry label names the right action. */ +export type NimiqLinkStep = 'nim-account' | 'nim-signature' | 'base-signature' + +export interface UseNimiqLinkResult { + status: NimiqLinkStatus + nimAddress: string | null + /** The stored link — half-signed after step 2, complete after step 3. */ + link: NimiqLink | null + /** True only when both providers have signed. Never render "verified" without it. */ + proven: boolean + error: string | null + failedStep: NimiqLinkStep | null + busy: boolean + /** Step 1 — Nimiq `listAccounts()`. Tap only. */ + requestAccount: () => Promise + /** Step 2 — Nimiq `sign()`. Tap only, after step 1. */ + signNim: () => Promise + /** Step 3 — `personal_sign` with the connected wallet. Tap only. */ + signBase: () => Promise + unlink: () => void + reset: () => void +} + +function messageFor(err: unknown, fallback: string): string { + if (err instanceof NimiqProviderError || err instanceof EthProviderError) { + return err.message + } + if (err instanceof Error && err.message) return err.message + return fallback +} + +export function useNimiqLink( + baseAddress: string | undefined, +): UseNimiqLinkResult { + const [supported, setSupported] = useState(false) + const [status, setStatus] = useState('unsupported') + const [nimAddress, setNimAddress] = useState(null) + const [link, setLink] = useState(null) + const [error, setError] = useState(null) + const [failedStep, setFailedStep] = useState(null) + + useEffect(() => { + setSupported(isNimiqPay()) + }, []) + + // Restore whatever this Base address had linked before. Storage-only: no + // provider call, so no dialog, so this is safe in an effect. + useEffect(() => { + const stored = loadNimiqLink(baseAddress) + setLink(stored) + setNimAddress(stored?.nimAddress ?? null) + setError(null) + setFailedStep(null) + setStatus( + stored?.baseSignature + ? 'linked' + : stored + ? 'nim-signed' + : isNimiqPay() + ? 'idle' + : 'unsupported', + ) + }, [baseAddress]) + + const requestAccount = useCallback(async () => { + if (!baseAddress) { + setError('Connect a Base wallet first.') + setFailedStep('nim-account') + return + } + setError(null) + setFailedStep(null) + setStatus('account-pending') + try { + const [first] = await listNimiqAccounts() + setNimAddress(first) + setStatus('account-ready') + } catch (err) { + setError(messageFor(err, 'Could not read your Nimiq account.')) + setFailedStep('nim-account') + setStatus('idle') + } + }, [baseAddress]) + + const signNim = useCallback(async () => { + if (!baseAddress || !nimAddress) { + setError('Share a Nimiq account first.') + setFailedStep('nim-account') + setStatus('idle') + return + } + setError(null) + setFailedStep(null) + setStatus('nim-signing') + + // Built here, not at step 1, so the timestamp in the dialog is the moment + // the holder is actually asked to sign. Persisted on the record so the + // Base half signs these exact bytes. + const message = buildLinkChallenge({ + baseAddress, + nimAddress, + nonce: makeNonce(), + }) + + try { + const { publicKey, signature } = await signWithNimiq(message) + const record: NimiqLink = { + nimAddress, + nimPublicKey: publicKey, + nimSignature: signature, + baseAddress: baseAddress.toLowerCase(), + baseSignature: null, + message, + linkedAt: Date.now(), + } + // Stored half-signed on purpose: the NIM signature is real and should + // survive a reload, and `isMutuallyProven` keeps the UI from calling it + // verified until the Base half lands. + saveNimiqLink(record) + setLink(record) + setStatus('nim-signed') + } catch (err) { + setError(messageFor(err, 'The Nimiq signature was not completed.')) + setFailedStep('nim-signature') + // Back to `account-ready`, not `idle`: the shared account is still good, + // so a retry costs one dialog rather than two. + setStatus('account-ready') + } + }, [baseAddress, nimAddress]) + + const signBase = useCallback(async () => { + if (!link) { + setError('Sign with Nimiq first.') + setFailedStep('nim-signature') + return + } + // A link only ever exists for a connected wallet — `loadNimiqLink` keys on + // it and `requestAccount` refuses without it — so reaching here with none + // means the wallet disconnected mid-flow. Ask for it back rather than + // signing with whatever the provider offers next. + if (!baseAddress) { + setError('Reconnect your Base wallet to finish the link.') + setFailedStep('base-signature') + setStatus('nim-signed') + return + } + setError(null) + setFailedStep(null) + setStatus('base-signing') + + try { + const baseSignature = await personalSign(link.message, baseAddress) + const record: NimiqLink = { ...link, baseSignature } + saveNimiqLink(record) + setLink(record) + setStatus('linked') + } catch (err) { + setError(messageFor(err, 'The Base signature was not completed.')) + setFailedStep('base-signature') + // The NIM half is still valid and stored; retry costs one dialog. + setStatus('nim-signed') + } + }, [baseAddress, link]) + + const unlink = useCallback(() => { + clearNimiqLink(baseAddress) + setLink(null) + setNimAddress(null) + setError(null) + setFailedStep(null) + setStatus(isNimiqPay() ? 'idle' : 'unsupported') + }, [baseAddress]) + + const reset = useCallback(() => { + setError(null) + setFailedStep(null) + }, []) + + // A stored link stays visible outside Nimiq Pay (it is just data), but the + // flow itself is only offered where a Nimiq provider exists. + const visibleStatus = + supported || status === 'linked' || status === 'nim-signed' + ? status + : 'unsupported' + + return { + status: visibleStatus, + nimAddress, + link, + // Both halves of this are deliberately redundant today: `linked` is only + // ever set where a Base signature exists, so no test can tell the two + // apart. Kept because `proven` is read as a security claim and the two + // ways of being wrong are not symmetric — a future edit that sets `linked` + // from somewhere new would silently promote a half-signed record. + proven: status === 'linked' && !!link?.baseSignature, + error, + failedStep, + busy: + status === 'account-pending' || + status === 'nim-signing' || + status === 'base-signing', + requestAccount, + signNim, + signBase, + unlink, + reset, + } +} diff --git a/apps/web/src/lib/ethProvider.ts b/apps/web/src/lib/ethProvider.ts new file mode 100644 index 0000000..7b3e58a --- /dev/null +++ b/apps/web/src/lib/ethProvider.ts @@ -0,0 +1,128 @@ +/** + * The Ethereum provider — the second of the two injected providers. + * + * Terreno reaches `window.ethereum` through wagmi for everything on the money + * path: the `injected()` connector owns `eth_requestAccounts`, and the buy + * hooks own `eth_sendTransaction`. This module is deliberately NOT a second + * way to do those things. It exists for the one call wagmi has no hook for in + * this app — `personal_sign` — plus the account request that must precede it + * when nothing is connected yet. + * + * Unlike the Nimiq provider, an EIP-1193 provider *rejects* on a declined + * dialog rather than resolving an error envelope, so there is no narrowing to + * do here. What it does share with the Nimiq side is the rule that matters: + * both calls raise a native confirmation, so neither may be reached from a + * mount effect — only from a tap. + */ + +/** Thrown for every failure here, so callers have one shape to catch. */ +export class EthProviderError extends Error { + /** EIP-1193 code where the host supplied one; 4001 is "user rejected". */ + readonly code?: number + + constructor(message: string, code?: number) { + super(message) + this.name = 'EthProviderError' + this.code = code + } +} + +function toEthProviderError(err: unknown, fallback: string): EthProviderError { + if (typeof err === 'object' && err !== null) { + const { code, message } = err as { code?: unknown; message?: unknown } + return new EthProviderError( + typeof message === 'string' && message ? message : fallback, + typeof code === 'number' ? code : undefined, + ) + } + return new EthProviderError(fallback) +} + +/** True when the rejection is the user declining, not the host failing. */ +export function isUserRejection(err: unknown): boolean { + return err instanceof EthProviderError && err.code === 4001 +} + +function provider(): NonNullable { + const eth = typeof window !== 'undefined' ? window.ethereum : undefined + if (!eth) { + throw new EthProviderError( + 'No Ethereum wallet found. Open Terreno in Nimiq Pay or connect a wallet.', + ) + } + return eth +} + +/** + * UTF-8 → `0x…` hex, which is what `personal_sign` takes as its data argument. + * + * Passing the raw string instead mostly works and is the reason this is a + * named helper rather than an inline expression: a message containing any + * non-ASCII character encodes differently across wallets when it is not + * hex-encoded first, so the bytes the user approves stop matching the bytes + * stored alongside the signature. + */ +export function toHexUtf8(input: string): string { + return `0x${Array.from(new TextEncoder().encode(input)) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join('')}` +} + +/** + * The user's EVM addresses. **Shows a native confirmation dialog** when the + * origin is not already authorized. + * + * Prefer the connected wagmi account where there is one; this is for the case + * where the flow needs an address and nothing is connected yet. + */ +export async function requestEthAccounts(): Promise { + let result: unknown + try { + result = await provider().request({ method: 'eth_requestAccounts' }) + } catch (err) { + throw toEthProviderError(err, 'Wallet connection was declined.') + } + + const accounts = Array.isArray(result) + ? result.filter((a): a is string => typeof a === 'string') + : [] + if (accounts.length === 0) { + throw new EthProviderError('The wallet returned no Ethereum accounts.') + } + return accounts +} + +/** + * Sign `message` with the EVM key at `address`. **Shows a native confirmation + * dialog**, displaying the decoded text — so the message is written to be read + * on a phone, not to be parsed. + * + * The message is hex-encoded before it goes to the provider; the caller keeps + * the original string, since that is what a verifier needs. + */ +export async function personalSign( + message: string, + address: string, +): Promise { + if (!message.trim()) { + throw new EthProviderError('Refusing to sign an empty message.') + } + if (!address) { + throw new EthProviderError('No address to sign with.') + } + + let result: unknown + try { + result = await provider().request({ + method: 'personal_sign', + params: [toHexUtf8(message), address], + }) + } catch (err) { + throw toEthProviderError(err, 'Signature was declined.') + } + + if (typeof result !== 'string' || !result.startsWith('0x')) { + throw new EthProviderError('The wallet returned an unusable signature.') + } + return result +} diff --git a/apps/web/src/lib/nimiq.ts b/apps/web/src/lib/nimiq.ts index 54af9db..7ed6a04 100644 --- a/apps/web/src/lib/nimiq.ts +++ b/apps/web/src/lib/nimiq.ts @@ -17,17 +17,24 @@ * - Gas. There is no stablecoin fee abstraction here; gas is paid in the * chain's native asset (ETH on Base). `lib/feeCurrency.ts` is gone. * - * `@nimiq/mini-app-sdk` is NOT imported here, and that is deliberate. Its - * provider is NIM-native — Lunas, `NQ…` addresses, staking — none of which - * this app uses; everything Terreno needs is on `window.ethereum` and the - * `window.nimiqPay` host object. The two host-context helpers below would be - * the only reason to pull it in, and they are three lines each. + * `@nimiq/mini-app-sdk` is NOT imported *here*, and that is still deliberate, + * though the reason has narrowed. Everything in this module is host context — + * detection, language, device identifier — which Nimiq Pay seeds on + * `window.nimiqPay` before the page script runs. Reading it directly keeps the + * detection path synchronous, dependency-free, and loadable even if the SDK + * chunk fails, which is what lets `wallet-provider.tsx` branch on it during + * render without an await. + * + * The NIM *provider* — `listAccounts`, `sign` — does now use the SDK, in + * `lib/nimiqProvider.ts`, behind a dynamic `import()` gated on `isNimiqPay()`. + * That split is the point: the SDK never enters the static graph, so browser + * clients and the map's first paint carry none of it. Nothing on the buy path + * touches either module; pixels are still bought over `window.ethereum`. * * The trade-off, stated because it is a real one: `NimiqPayHostContext` below * is hand-copied from the SDK's `dist/index.d.ts` and can drift from it - * silently. Re-check it against the package when bumping the SDK. The package - * stays in `dependencies` so that check is possible and so the switch to - * importing it is a one-line change. + * silently. Re-check it against the package when bumping the SDK — it is + * verbatim as of 0.1.0. */ /** Read-only host context Nimiq Pay injects before the page script runs. */ diff --git a/apps/web/src/lib/nimiqLink.ts b/apps/web/src/lib/nimiqLink.ts new file mode 100644 index 0000000..46cab2c --- /dev/null +++ b/apps/web/src/lib/nimiqLink.ts @@ -0,0 +1,232 @@ +/** + * Linking a NIM address to a holder's deed. + * + * The claim being made is "the same person controls this Base address and + * this NIM address". Holding an address is not enough on its own — both are + * public, and the app is simply told them by the host — so the claim rests on + * signatures over a challenge that names *both* addresses. + * + * One challenge, two signatures, one from each provider: + * + * NIM — Nimiq provider `sign()` → proves control of the NIM address + * Base — `personal_sign` → proves control of the Base address + * + * Neither alone is the claim. A NIM signature says nothing about who holds the + * Base address, and vice versa; it is the pair over the *same* nonce'd message + * that binds them. `isMutuallyProven()` is the only thing that may be rendered + * as "verified", and a half-signed record is stored honestly as half-signed. + * + * What this still does NOT establish: nothing verifies these signatures. They + * are recorded, not checked — a verifier would need the Nimiq signature scheme + * for one half and `personal_ecRecover` for the other, and neither runs here. + * So a link is strong evidence to the person who made it and unchecked data to + * anyone else. Nothing on the money path reads a link. + * + * Storage is per-Base-address and client-side, so switching wallets in the + * same browser never shows the previous holder's NIM address. + */ + +/** + * Bumped when the record shape changes; old keys are then simply not found. + * v1 held a NIM signature only. v2 adds the Base half, so a v1 record cannot + * satisfy the current shape and is deliberately not migrated — re-linking is + * two taps, and silently promoting a one-sided record to a "mutually proven" + * one would be a lie told by a migration. + */ +const STORAGE_KEY = 'terreno.nimiq-link.v2' + +/** + * A recorded link between a Base address and a NIM address. + * + * `message` is kept alongside the signature deliberately — a signature without + * the exact bytes that were signed cannot be checked by anything later, and + * re-deriving the message is not possible once the nonce is gone. + */ +export interface NimiqLink { + /** The NIM address, in the host's spaced `NQ..` form. */ + nimAddress: string + nimPublicKey: string + /** Nimiq provider `sign()` over `message`. */ + nimSignature: string + /** The Base address the link was made from, lowercased. */ + baseAddress: string + /** + * `personal_sign` over the SAME `message`, or null while only the NIM half + * is done. Both signatures covering one challenge is what turns the link + * from an assertion into something a verifier can check from either side. + */ + baseSignature: string | null + /** The exact challenge string both sides sign. */ + message: string + /** Epoch ms at which the first (NIM) signature was taken. */ + linkedAt: number +} + +/** + * True when both halves have signed. The UI must not call a link "verified" + * on the strength of the NIM signature alone — that proves control of the NIM + * address and says nothing about who holds the Base one. + */ +export function isMutuallyProven(link: NimiqLink | null): boolean { + return !!link && typeof link.baseSignature === 'string' && link.baseSignature.length > 0 +} + +/** + * Nimiq addresses are `NQ` + a 2-digit checksum + eight 4-character groups, + * conventionally spaced. Accept either spacing, since a value that has made a + * round trip through storage or a copy-paste may have lost its spaces. + */ +const NIM_ADDRESS_RE = /^NQ\d{2}(?: ?[A-Z0-9]{4}){8}$/ + +export function isNimAddress(value: unknown): value is string { + return typeof value === 'string' && NIM_ADDRESS_RE.test(value.trim()) +} + +/** + * The challenge the user is asked to sign. + * + * This string is shown verbatim in Nimiq Pay's native dialog on a phone, so it + * is built to be read there: short lines, no jargon, and a first line that says + * what agreeing to it does. Both addresses appear in full — a challenge that + * elided either one would let the same signature be replayed against a + * different pairing. + * + * The nonce makes each challenge distinct so a signature captured once cannot + * stand in for a later link. + */ +export function buildLinkChallenge(params: { + baseAddress: string + nimAddress: string + nonce: string + issuedAt?: Date +}): string { + const { baseAddress, nimAddress, nonce } = params + const issued = (params.issuedAt ?? new Date()).toISOString() + return [ + 'Link this Nimiq address to your Terreno deed.', + '', + `Nimiq: ${nimAddress}`, + `Base: ${baseAddress}`, + `Issued: ${issued}`, + `Nonce: ${nonce}`, + '', + 'Both wallets sign this to prove one owner.', + 'It does not move funds and does not buy land.', + ].join('\n') +} + +/** + * A 16-character hex nonce from the platform CSPRNG. + * + * Falls back to `Math.random` only where `crypto` is missing entirely. The + * nonce's job is uniqueness between challenges, not unpredictability against + * an attacker — nothing is authorized by guessing it — so a weak fallback + * degrades the property gracefully instead of failing the link outright. + */ +export function makeNonce(): string { + const bytes = new Uint8Array(8) + const webCrypto = + typeof globalThis !== 'undefined' ? globalThis.crypto : undefined + + if (webCrypto?.getRandomValues) { + webCrypto.getRandomValues(bytes) + } else { + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Math.floor(Math.random() * 256) + } + } + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') +} + +/** All links, keyed by lowercased Base address. */ +type LinkStore = Record + +/** + * localStorage is wrapped everywhere below because it is not reliably present: + * Nimiq Pay renders in a WebView whose site data the user can have disabled, + * and a private context throws on *access*, not just on write. A holder whose + * storage is unavailable should see an unlinked deed, never a crashed page. + */ +function readStore(): LinkStore { + try { + const raw = globalThis.localStorage?.getItem(STORAGE_KEY) + if (!raw) return {} + const parsed: unknown = JSON.parse(raw) + if (typeof parsed !== 'object' || parsed === null) return {} + return parsed as LinkStore + } catch { + return {} + } +} + +function writeStore(store: LinkStore): void { + try { + globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(store)) + } catch (err) { + // Quota, disabled site data, or a private context. Degraded rather than + // broken: the link is lost on reload, but the session that made it still + // shows it, and nothing on the buy path reads this store. + console.warn('nimiqLink: could not persist link', err) + } +} + +/** Narrow a parsed record before handing it to the UI as a verified link. */ +function isNimiqLink(value: unknown): value is NimiqLink { + if (typeof value !== 'object' || value === null) return false + const v = value as Partial + return ( + isNimAddress(v.nimAddress) && + typeof v.baseAddress === 'string' && + typeof v.nimPublicKey === 'string' && + v.nimPublicKey.length > 0 && + typeof v.nimSignature === 'string' && + v.nimSignature.length > 0 && + // Optional, but when present it must be a real signature rather than a + // truthy placeholder — `isMutuallyProven` is read as a security claim. + (v.baseSignature === null || + (typeof v.baseSignature === 'string' && v.baseSignature.length > 0)) && + typeof v.message === 'string' && + typeof v.linkedAt === 'number' + ) +} + +export function loadNimiqLink(baseAddress: string | undefined): NimiqLink | null { + if (!baseAddress) return null + const key = baseAddress.toLowerCase() + const record = readStore()[key] + if (!isNimiqLink(record)) return null + + // The key and the record's own `baseAddress` must agree. Nothing in this app + // writes a record where they don't — but localStorage is writable by whoever + // holds the browser, and a record filed under one wallet while naming another + // would show a signature pair as proof of a binding the connected holder + // never made. Cheaper to refuse it here than to reason about it downstream. + if (record.baseAddress.toLowerCase() !== key) return null + return record +} + +export function saveNimiqLink(link: NimiqLink): void { + const store = readStore() + store[link.baseAddress.toLowerCase()] = link + writeStore(store) +} + +export function clearNimiqLink(baseAddress: string | undefined): void { + if (!baseAddress) return + const store = readStore() + delete store[baseAddress.toLowerCase()] + writeStore(store) +} + +/** + * A NIM address shortened for the deed's fixed-width row: the leading `NQxx` + * and the last group, which is what a holder actually recognizes their own + * address by. Anything that is not a NIM address is returned untouched rather + * than mangled into a shape that looks like one. + */ +export function formatNimAddress(address: string): string { + if (!isNimAddress(address)) return address + const groups = address.trim().replace(/ /g, '').match(/.{1,4}/g) + if (!groups || groups.length < 3) return address.trim() + return `${groups[0]} ${groups[1]}…${groups[groups.length - 1]}` +} diff --git a/apps/web/src/lib/nimiqProvider.ts b/apps/web/src/lib/nimiqProvider.ts new file mode 100644 index 0000000..38c8e12 --- /dev/null +++ b/apps/web/src/lib/nimiqProvider.ts @@ -0,0 +1,173 @@ +/** + * The Nimiq provider — lazily loaded, narrowed at the boundary. + * + * Terreno's money path is EVM-only: pixels are bought with USDC/USDT on Base + * through `window.ethereum`. The Nimiq provider is used for exactly one thing + * — proving control of a NIM address so it can be shown on a holder's deed — + * and nothing on the buy path may depend on it. + * + * Two rules shape this module, both of them load-bearing: + * + * 1. **The SDK is never in the static graph.** `@nimiq/mini-app-sdk` pulls in + * an `events` polyfill and its own provider stack. Only Nimiq Pay clients + * can ever use it, and those run the device's Android System WebView — on + * the phones that matter here, a 2018 factory build. So the SDK is reached + * through a dynamic `import()` behind an `isNimiqPay()` gate, the mirror + * image of the rule that keeps Privy out of the Nimiq Pay chunk (see + * `__tests__/components/nimiq-privy-isolation.test.ts`). The `import type` + * below is erased at compile time and adds nothing to the bundle. + * + * 2. **The SDK resolves errors, it does not reject them.** `listAccounts()` + * is typed `Promise` and `sign()` is typed + * `Promise`. A declined dialog comes back + * as a *fulfilled* promise carrying `{ error: { type, message } }`. A naive + * `await` therefore hands a denial straight through as if it were data — + * an object where an array was expected, or a signature record with no + * signature in it. Every call goes through the narrowing helpers here so + * that a refusal becomes a thrown `NimiqProviderError`, and callers get one + * failure shape to handle instead of two. + * + * Verified against `@nimiq/mini-app-sdk@0.1.0` `dist/provider.d.ts`. If the + * SDK ever starts rejecting, the narrowing stays correct — it only ever adds + * a throw where the promise resolved. + */ + +import type { NimiqProvider, SignatureResult } from '@nimiq/mini-app-sdk' +import { isNimiqPay } from './nimiq' + +/** How long to wait for the host to answer `init()` before giving up. */ +const INIT_TIMEOUT_MS = 10_000 + +/** + * A Nimiq provider call that did not produce a usable result. + * + * Carries the host's own `error.type` when there is one, so a caller can tell + * a user declining a dialog from the host failing to answer at all. + */ +export class NimiqProviderError extends Error { + readonly type?: string + + constructor(message: string, type?: string) { + super(message) + this.name = 'NimiqProviderError' + this.type = type + } +} + +/** + * The `{ error: { type, message } }` envelope the SDK resolves with, narrowed + * structurally rather than by `instanceof` — it crosses a WebView bridge as + * plain JSON, so nothing survives as a class instance. + */ +function asErrorResponse( + value: unknown, +): { type?: string; message?: string } | null { + if (typeof value !== 'object' || value === null) return null + const maybe = (value as { error?: unknown }).error + if (typeof maybe !== 'object' || maybe === null) return null + const { type, message } = maybe as { type?: unknown; message?: unknown } + return { + type: typeof type === 'string' ? type : undefined, + message: typeof message === 'string' ? message : undefined, + } +} + +/** + * Throw if `value` is the SDK's error envelope; otherwise hand it back. + * + * `fallback` is what the user is told when the host sends an envelope with no + * message in it — which it does for at least some declines. + */ +function rejectErrorResponse(value: T | unknown, fallback: string): T { + const err = asErrorResponse(value) + if (err) throw new NimiqProviderError(err.message || fallback, err.type) + return value as T +} + +/** + * Memoized `init()`. The SDK sets up a bridge to the host; doing that twice + * per session is waste, and a second `init()` racing the first has no defined + * winner. Cleared on failure so a transient timeout can be retried by tapping + * again rather than poisoning the session. + */ +let providerPromise: Promise | null = null + +/** + * The Nimiq provider, or `null` outside Nimiq Pay. + * + * Returning `null` rather than throwing is deliberate: "not in Nimiq Pay" is + * an ordinary state for Terreno — most traffic is a normal browser — and every + * caller here has a sensible do-nothing branch for it. + */ +export async function loadNimiqProvider(): Promise { + if (!isNimiqPay()) return null + + if (!providerPromise) { + providerPromise = import('@nimiq/mini-app-sdk') + .then((sdk) => sdk.init({ timeout: INIT_TIMEOUT_MS })) + .catch((err: unknown) => { + providerPromise = null + throw new NimiqProviderError( + err instanceof Error ? err.message : 'Could not reach Nimiq Pay.', + ) + }) + } + + return providerPromise +} + +/** + * The user's Nimiq addresses. **Shows a native confirmation dialog.** + * + * Never call this from a mount effect. Account access is a confirmed action, + * and the mini-app checklist forbids triggering approval dialogs on page load + * without user interaction — the same rule that makes `NimiqPayAutoConnect` + * gate on read-only `eth_accounts` instead of `eth_requestAccounts`. + */ +export async function listNimiqAccounts(): Promise { + const provider = await loadNimiqProvider() + if (!provider) throw new NimiqProviderError('Not running inside Nimiq Pay.') + + const result = rejectErrorResponse( + await provider.listAccounts(), + 'Account access was declined.', + ) + + if (!Array.isArray(result) || result.length === 0) { + throw new NimiqProviderError('Nimiq Pay returned no accounts.') + } + return result.filter((a): a is string => typeof a === 'string') +} + +/** + * Sign `message` with the user's Nimiq key. **Shows a native confirmation + * dialog**, and the message is displayed verbatim inside it — keep it short, + * readable, and honest about what signing means. + */ +export async function signWithNimiq(message: string): Promise { + if (!message.trim()) { + throw new NimiqProviderError('Refusing to sign an empty message.') + } + + const provider = await loadNimiqProvider() + if (!provider) throw new NimiqProviderError('Not running inside Nimiq Pay.') + + const result = rejectErrorResponse( + await provider.sign(message), + 'Signature was declined.', + ) + + // A fulfilled promise that is not the error envelope can still be missing + // the fields we need; treat that as a failure rather than storing a link + // with an empty signature in it. + const { publicKey, signature } = (result ?? {}) as Partial + if (typeof publicKey !== 'string' || typeof signature !== 'string') { + throw new NimiqProviderError('Nimiq Pay returned an unusable signature.') + } + return { publicKey, signature } +} + +/** Test seam: drop the memoized provider so each case starts clean. */ +export function resetNimiqProviderForTests(): void { + providerPromise = null +}