feat: support pubky contact deep links - #1320
Conversation
|
Regtest APKDownload bitkit-dev-debug universal APK (expires in 30 days). |
There was a problem hiding this comment.
Verdict: ✅ Approve
Review: diff 8 files.
Counterpart synonymdev/bitkit-ios#768: diverges, see the parity finding.
Findings:
1 inline (non-blocking)
Audit:
Audited - no findings.
Coverage:
QA: journeys and manual tests await all reviewers to approve, author can run it now via comment: @ovi-reviewer test
Reviewed by claude-opus-5-xhigh via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest · audit (author or owner)
jvsena42
left a comment
There was a problem hiding this comment.
No HIGH or MEDIUM findings. Two LOWs inline, no verifier pass; read them as observations.
Checked and clean:
- No state change from the link. A valid link only navigates: to Add Contact (
fetchProfileis read-only), Contact Detail, or Profile for the user's own key. Saving and paying stay tap-driven. - Scheme precedence. The order is SamRock →
screen→ recovery →pubky-auth→pubkyauth://→ contact. Apubky=value carryingbitcoin:,lightning:orpubkyauth://fails normalization and is toasted beforedecodeorhandlePubkyAuthruns. - Parser. It rejects userinfo, port, path, fragment and extra or duplicate query params. The ≤57 length check runs before truncation, and the tests exercise the real
PaykitPublicKeys.normalize. - Locked wallet and pre-onboarding. On a locked wallet the link goes through
enqueueDeferredScanand is flushed once after PIN. Before onboarding it is dropped by thewalletExists()guard. With Paykit off it is dropped silently. - Journey. The file name, journey name and all actions are identical to synonymdev/bitkit-ios#768; only the platform launch command differs. The suite table is alphabetical and the count of 3 matches the folder.
There was a problem hiding this comment.
Verdict: ✅ Approve
Reaudit: diff 2 files.
Counterpart synonymdev/bitkit-ios#768: equivalent.
Findings:
N/A
Audit:
Already done in comment.
Coverage:
QA: journeys and manual tests await all reviewers to approve, author can run it now via comment: @ovi-reviewer test
Reviewed by claude-opus-5-xhigh via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest · audit (author or owner)
jvsena42
left a comment
There was a problem hiding this comment.
Delta since db45d0519 (94405b015): no findings. processDeeplink now checks PubkyContactLink.matches before isRecoveryModeDeeplink(). That closes a real hole: at the previous head, bitkit://contact/recovery-mode?pubky=… turned on recovery mode before the contact parser saw it. The regression test covers it with never().setRecoveryMode(true) and asserts one decoding toast per malformed link. My two LOWs from the last pass are still open.
There was a problem hiding this comment.
Delta since 94405b015 (54d174cbb): no findings. Both of my LOWs are fixed.
- The deeplink now waits on
contactsLoadCompletionVersion, which is bumped on success and also on a failure for the current key. A failed startup fetch therefore releases the link at once, without marking contacts as loaded. - A stale load for a previous key bumps neither signal.
resetzeroes both. - The
deeplinksrow has itsno READMEmarker back.
jvsena42
left a comment
There was a problem hiding this comment.
Delta since 54d174cbb (cc87440db): no HIGH or MEDIUM findings. One LOW inline.
This addresses ovi-reviewer's point. After a failed startup load, the link retries once and only routes on a real success, so a saved contact is no longer sent to Add Contact from an empty list. Completion is now marked in finally, after the mutex is released, and only for the current key.
There was a problem hiding this comment.
Advice: ✅ Approve
Reaudit: diff 2 files.
Counterpart synonymdev/bitkit-ios#768: diverges, see the parity finding.
Findings:
N/A
Audit:
Already done in comment.
Coverage:
QA: journeys and manual tests await all reviewers to approve, author can run it now via comment: @ovi-reviewer test
Reviewed by claude-opus-5-xhigh via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest · audit (author or owner)
jvsena42
left a comment
There was a problem hiding this comment.
Delta since cc87440db (456680c58): no findings. My LOW is fixed. The retry snapshots contactsLoadCompletionVersion and waits for a newer completion, so a concurrent load that succeeds routes the saved contact. If no newer completion ever arrives (sign-out mid-wait, or a stale-key load), the outer withTimeoutOrNull(AUTHORIZATION_TIMEOUT) bounds the wait and ends in the existing toast. The success-path tests now assert that loadContacts() is not called when contacts are already loaded, which answers ovi-reviewer's coverage point.
There was a problem hiding this comment.
Verdict: ⛔️ Request Changes
Tests for the review: 5 of 6 manual tests passed.
QA:
Tested on Android 16 emulator (Pixel 2)
Test 1 ✅ passed
Test 2 ✅ passed
Test 3 ✅ passed
Test 4 ✅ passed
Test 5 ⛔️ failed: Duplicate pubky parameters opened Contact Detail instead of ignoring the malformed link.
Test 6 ✅ passed
Tip
Worth a journey
Test 1
- Open a contact link for an unsaved Pubky key
- Verify Add Contact opens with the supplied key
- Verify Save remains available without automatic saving
Test 2
- Return to wallet home
- Open a contact link for a saved Pubky key
- Verify Contact Detail opens for the saved contact
Test 3
- Return to wallet home
- Open a contact link for your own Pubky key
- Verify Profile opens without contact controls
Test 4
- Enable a wallet PIN
- Force-stop the app
- Open a saved contact link
- Verify the PIN screen appears before contact UI
- Enter the PIN
- Verify Contact Detail opens once
- Press Back once and verify wallet home appears
Test 6
- From wallet home, open the QR scanner
- Scan a valid Pubky key
- Verify Add Contact opens for that key
- Return to wallet home
- Scan a valid wallet payment QR
- Verify the existing zero-savings payment behavior returns home
Reviewed by gpt-5.6-sol-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest · audit (author or owner)
|
The app rejects the full duplicate URI, and the deeplink regression passes with that exact input. Opening Contact Detail means Android received only the first |
|
@ovi-reviewer retest 5 |
There was a problem hiding this comment.
Verdict: ✅ Approve
Tests for the review: no test needed the device again; each one passed on an earlier run.
QA:
Tests 1-4, 6 already done in review.
Test 5 already done in review.
Reviewed by claude-opus-5-5-xhigh via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest · audit (author or owner)
|
@ben-kaufman conflicts |
There was a problem hiding this comment.
Verdict: ✅ Approve
Reaudit: diff 0 files.
No new findings; the rest is in the review.
Counterpart bitkit-ios#768 diverges; see the parity finding.
Tests 1-4, 6 already done in review.
Test 5 already done in review.
Reviewed by gpt-6-sol-xhigh via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest · audit (author or owner)
|
Found this while testing the example link from the PR. Saving that contact and opening the same link again stays on Add Contact. A real saved contact opens Contact Detail. The example key is After Save, the stored key comes back as This shows up with the app already unlocked. Force-stop and the PIN screen only made it look like a cold-start problem, because that reload is what brings the canonical key back. The key is a valid public key with no pkarr record, so Add Contact saves a placeholder. That is existing behavior, and it is what surfaces the mismatch. The link itself does not save anything. The same lookup is string equality on iOS ( Steps
Expected: Contact Detail for the saved contact. Actual: Add Contact for Screen recording: qa-contact-deeplink.mov |
|
Canonicalized the key before routing. The example link now resolves to the same stored key, so reopening it after Save opens Contact Detail. |
jvsena42
left a comment
There was a problem hiding this comment.
Delta since 712fdf125 (285db622f): no findings. The link now canonicalizes the final z-base-32 symbol, masking off the four padding bits, so two encodings of the same key resolve to one contact. I checked the mask against the alphabet: g (6) becomes y (0), which is what the test asserts.
One question, not a finding: is the canonical form what the paste and scan paths store? If a contact was saved from a non-canonical encoding, a deeplink for the same key would now route to Add Contact rather than Contact Detail.
|
They did not. Plain paste and scanner input kept the non-canonical spelling. I changed contact routing and new-contact persistence to canonicalize first, so those paths now save and look up the same key as the deeplink. |
|
The Own key opens Profile. Invalid, duplicate, and |
piotr-iohk
left a comment
There was a problem hiding this comment.
Approving. (Modulo conflict on journeys/README.md)
Please port the contact-key canonicalization to iOS. PubkyContactLink.publicKey(from:) on origin/master 3ac017b6 still keeps the original last character.
|
@ben-kaufman conflicts |
ff3e8c1
jvsena42
left a comment
There was a problem hiding this comment.
0c774ea answers the canonical-form question: canonicalized() runs after normalized, and the SDK's alphabet check means the padding mask can only clear padding bits, so two keys cannot collide. The paste, scanner, address-continue and deeplink routes, plus fetchContactProfile/addContact, now agree with what contactRecords() returns. refreshContactReceiverPaths intentionally keeps the loaded key, and a test pins that. No regressions in the new commit.
One LOW observation inline (gated behind Paykit UI). Not independently verified.
Checked and clean:
- No state change without a tap: a valid link only navigates to AddContact / ContactDetail / Profile;
addContactandpayContactneed a tap; the bare canonical key misses Bip21, SamRock and pubkyauth, and the route branch returns beforedecode. - Parser: authority must equal
contact(no userinfo/port), empty path, no fragment, exactly onepubkyparam, raw&rejected, length bounded beforetake(57);%26inside the value fails the alphabet check. processDeeplinkprecedence: SamRock, screen, pubky-auth callbacks can't be reached from acontacthost;bitkit://contact/recovery-modeno longer flips recovery mode.- Gating: malformed links short-circuit before the init wait; a flag-off link is dropped after the wait and
handleScanre-checks the flag; pre-onboarding is dropped; locked wallet replays withroutePubkyKeyspreserved. - Own key routes to Profile;
CannotAddSelfstill guards. - The query is dropped from deeplink logs, so the key never reaches them.
piotr-iohk
left a comment
There was a problem hiding this comment.
Re-approving ff3e8c18. The merge of master only added documentation rows in journeys/README.md. The contact-link code is unchanged from 0c774ea.
The contact-key canonicalization still needs an iOS port. PubkyContactLink.publicKey(from:) on origin/master 3ac017b6 keeps the original last character.









This PR adds
bitkit://contact?pubky=<public-key>links that open the existing Pubky scanner contact flow.iOS counterpart: synonymdev/bitkit-ios#768
Description
Example:
The value may be a raw 52-character key or include the
pubkyprefix. URL-encode the value. Existing Paykit feature gating is unchanged.Out of Scope
Design
N/A — no UI changes. Reuses existing Add Contact, Contact Detail and Profile screens.
Preview
N/A — existing scanner screens are unchanged.
QA Notes
Manual Tests
pubkyparameters: no contact, payment or auth flow opens.Automated Checks
PubkyContactLinkTest.kt: accepted key forms, malformed URL authorities, missing/duplicate parameters and rejection of non-key payloads.AppViewModelSendFlowTest.kt: unknown/own-key routing, restored saved-contact routing and receiver refresh, PIN deferral, wallet/feature gates and rejection without payment decode or auth.-Dmaven.repo.local=/private/tmp/contact-deeplink-empty-maven --no-configuration-cacheto bypass stale locally published artifacts.