Skip to content

feat(dashpay): sign in to an app with one DashConnect QR from protocol 14 - #1202

Draft
romchornyi wants to merge 6 commits into
developfrom
feat/dashconnect-v2
Draft

romchornyi wants to merge 6 commits into
developfrom
feat/dashconnect-v2

Conversation

@romchornyi

@romchornyi romchornyi commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Issue being fixed or feature implemented

DashConnect v2 (one-QR login) for iOS. Today a first login takes two scans: dash-key: (the wallet publishes an encrypted login key) and then dash-st: (the app hands back an identity update that registers the keys). From protocol version 14 the wallet does it in one approval and publishes its answer to the App Connect system contract (H8F9mP1BM55TE1ShsxPZHzhyinaMdY9bMmP85mkDhcJJ, dashpay/platform#4869), which has the same id on every network — this is what makes the feature possible on mainnet.

Supersedes the draft #1133 (a per-network id for the old response contract is no longer needed).

Draft — not ready for review. It does not build against develop's SDK (see "Depends on").

What was done?

  • One-QR approval (approveAppConnectLogin, new AppConnectLogin.swift). The wallet derives the request's session key (DIP-13 application session authentication, leaf hash256 of the app's ephemeral key, via deriveConnectKey), registers it on the identity as AUTHENTICATION / HIGH / ECDSA_HASH160 bound to the contract named in the QR, with a budget and an expiry, and only then publishes the encrypted key as a three-field loginKeyResponse. Earlier responses for the same app are deleted by their values first.
  • Which login runs is decided by the network's protocol version: 14 and above → one-QR; below → the two-QR flow, unchanged.
  • Grant records. The key id and the published values are recorded as soon as the key is on the identity, before the publish, and kept until the key is disabled. The response type is index-only, so these records are the only way back to the entry and the only place that knows which key belongs to which app.
  • Real disconnect. Turning a one-QR connection off disables its session key(s) on the identity and deletes the response(s), then removes the row; a failure leaves the row and is shown to the user. A two-QR connection stays local-only (that login derives the same key every time and Platform never re-adds a disabled key).
  • dash-st: parser ported to the SDK's ParsedStateTransition summary (feat(sdk)!: key limits, DIP-14 sub-feature derivation and decode-any-kind for DashPay Connect platform#4844): a key registration maps as before and now keeps its budget and expiry; a batch is accepted only as a lone, complete token direct purchase.

How Has This Been Tested?

  • Unit tests: the DashConnect suites, 150 passing locally (session-key state, grant records and their store, the sealed response round trip, disconnect key selection, the parser, the view model's disconnect).
  • On a device against devnet sakura (protocol 14, Platform 5.0.0-beta.3), QR from yap.pr/devnet: the first approval registered the session key (contract-bound, budget, expiry) and published the response. Both were read back from a node: the key via getIdentityKeys, the response via getDocuments on H8F9… by the request hash.
  • Same device and network, 2026-10-12, head 09a28c551: login → disconnect → login → disconnect, each step read back from a node. Disconnect set disabledAt on the session key and the response was gone from H8F9…. The last disconnect also disabled two live session keys from earlier installs that the wallet had no record of (one identity update for all three).
  • Not tested on a network: disconnect, re-login (delete + create), and everything changed after that device run.
  • The app itself could not complete a sign-in: yap.pr/devnet still queries its own key-exchange contract (contractId + appEphemeralPubKeyHash) and does not read H8F9….

Depends on

  • feat(sdk)!: key limits, DIP-14 sub-feature derivation and decode-any-kind for DashPay Connect platform#4844 — deriveConnectKey and the reshaped ParsedStateTransition. Open.
  • A delete-by-values entry point for index-only documents in the Swift SDK. It does not exist upstream; the SDK refuses an id-only delete for this type. Built here against a local addition.
  • The 5.1 SDK line reading contracts that use countPresent (present on v5.0-dev only). Without it the SDK fails to parse such an app contract, bans each answering node, and has no DAPI addresses left for about 40 seconds.

Known gaps

  • The approval sheet still shows the two-QR text ("see your username / verify your identity") while the one-QR login gives the app a key that can spend up to its budget. Must be fixed before this leaves draft.
  • The budget (0.05 DASH) and lifetime (90 days) are placeholders (TODO(app-connect-limits)).
  • The lookup that settles a publish or delete whose outcome is unknown runs on the main actor (SDK.documentList is main-actor isolated and blocks), so the sheet freezes for that round trip. The two-QR lookup has the same shape.
  • The SDK does not expose a key's contract bounds, so a session key found already registered is matched by its hash alone.
  • The approve and disconnect orchestration is covered in unit tests only through its pure parts. Not run against a network: approving the same request twice, and the paths that settle a publish or delete whose outcome is unknown.
  • Disconnect finds keys without a local record through the wallet's key store, on the active identity only, and only keys shaped as this login registers them (single-contract bound, budget and expiry). The response behind such a key stays on Platform: it can only be found by its request hash. A key on another identity with no record is not seen.
  • The v2 request profile proposed in the comments (several bound keys per request) is a follow-up PR.
  • A later login to the same app leaves the earlier session key live until disconnect — open question whether one app may hold several session keys (one per device) or only one.
  • Mainnet is still gated off (ConnectionsViewModel, the mock data source, the data source's network assert).
  • Testnet's SDK is pinned to protocol 13, so testnet stays on the two-QR flow until that pin moves.
  • Four new English strings are not in the catalogs yet.

Breaking Changes

None for users. DashConnectDataSource.disconnect(id:) now throws.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have made corresponding changes to the documentation

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

jeanpierreroma and others added 4 commits October 8, 2026 19:14
…l 14

From protocol version 14 the wallet answers a `dash-key:` request by itself:
it derives the request's session key (DIP-13 application session
authentication, leaf `hash256` of the app's ephemeral key), registers it on
the identity as an AUTHENTICATION / HIGH / ECDSA_HASH160 key bound to the
requested contract with a budget and an expiry, and only then publishes the
encrypted key to the App Connect system contract. The app no longer sends a
`dash-st:` key registration. Earlier responses for the same app are deleted
by their values first; the published values are recorded for that.

Below protocol 14 the two-QR flow runs unchanged. Testnet's SDK is pinned to
protocol 13, so testnet stays on it until that pin moves.

Also ports the `dash-st:` parser to the SDK's `ParsedStateTransition`
summary (dashpay/platform#4844): a key registration maps as before, and a
batch is accepted only as a lone, complete token direct purchase.

Needs dashpay/platform#4844 and the delete-by-values entry point. The
budget and lifetime are placeholders. Mainnet is still gated off, the
approval sheet and disconnect are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed off

Turning a connection off only changed its local status, so the app kept a
key that could still sign. For a connection made by the one-QR login the
wallet now disables that session key on the identity and deletes the
published response by its values before the row is downgraded; a failure
leaves the row active and is shown to the user.

A two-QR connection stays local-only: that login derives the same key every
time and Platform never re-adds a disabled key, so disabling it would lock
the app out for good.

`disconnect(id:)` now throws, and the view model keeps a second tap and
inbound requests out while the identity update is in flight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review pass on the one-QR login and its disconnect:

- A later login to the same app deleted the earlier record together with
  its key id, so that key stayed live and could no longer be found. The
  record now outlives its response (marked as deleted) until a disconnect
  disables the key.
- The grant and the connection row are recorded once the key is on the
  identity, before the publish, so a publish with an unknown outcome still
  leaves a key the user can see and disable. Approving the same QR again
  reuses the recorded values and does nothing when already published.
- A connection remembers which login made it. Turning a one-QR connection
  off removes the row instead of parking it in the two-QR "approved" state,
  and refuses, rather than reporting success, when its records are missing.
- A scanned `dash-st:` key registration keeps the budget and expiry it
  carried instead of being rebuilt as an unlimited key.
- Disconnect has its own in-flight flag, does not overlap an approval or a
  resolving request, and re-resolves the wallet context after the PIN
  prompt. Grant records are read, changed and written in one step.
- The protocol-version query runs off the cooperative pool.
- The two logins share one `AppConnect.seal` instead of two copies of the
  encrypt-and-wipe sequence, and the session private key is the sole owner
  of its buffer so the wipe reaches it.

Still open, marked in code: key state is read from the SDK's local view of
the identity (TODO(app-connect-stale-keys)); the approval sheet still shows
the two-QR text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dentity view

Second review pass on the one-QR login:

- A grant was looked up in the store by whole-value equality, and its date
  does not survive the store's millisecond round trip bit for bit, so about
  half of all publishes never recorded their document id. Records are now
  matched on what identifies a grant: identity, app, key id, request hash.
- A granted key id missing from the wallet's local view of the identity was
  treated as gone, after which the records and the row were dropped as if
  the key had been disabled. Platform never removes a key, so a missing id
  now stops the disconnect with an error.
- The one-QR approval re-resolves the wallet context after the PIN prompt
  and refuses to continue for a different identity.
- The grant store decodes row by row and writes back rows it cannot read
  instead of overwriting them.
- Two doc comments corrected to say what the code does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 10, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@thepastaclaw

thepastaclaw commented Oct 10, 2026 •

Copy link
Copy Markdown

🕓 Review not started yet because this PR is a draft.

  • Request normal review — click when the PR is ready for review.
  • Request priority review — click to move this review to the front of the queue.

Commit 09a28c5. Normal review starts when eligible; priority review starts as soon as a slot is available.

@PastaPastaPasta

Copy link
Copy Markdown
Member

Thanks for this, Roman. Yappr is lined up as an early App Connect consumer, and we've written up the request profile we need. We'd like to agree it with you before the one-QR flow hardens around a single key:

Profile: https://github.com/PastaPastaPasta/yappr/blob/staging/docs/APP_CONNECT_PROFILE.md
Test vectors: https://github.com/PastaPastaPasta/yappr/blob/staging/vendor/platform-auth/src/app-connect/vectors.json

Why one singleContract key bound to the QR's contract id doesn't work for Yappr. Yappr writes to about 8 of its own contracts: social, blocks, DMs, blog, polls, storefront, auth vault. It also writes the DashPay profile document. That would mean one key per contract, more than the 6 keys an identity update can add, and a fresh grant every time we re-cut a contract. PV14 already offers contractGroup and documentType bounds, so the profile lets the app ask for exactly those.

What the profile asks for:

  • Request v2: version byte 0x02 and v=2, so v1 wallets refuse it cleanly instead of granting a key that can't cover the app. The body is the same as today (appEphPub ‖ appId ‖ label), followed by TLVs (even types are mandatory, odd types optional):
  • Crypto: unchanged, except HKDF info = "app-connect:v2" and AES-GCM AAD = "dash:app-connect:v2" ‖ hash160(appEphPub) ‖ $ownerId. A response copied under another identity then fails to decrypt.
  • Payload: 28 + 32n bytes, one private key per granted binding. The app classifies the keys on chain.
  • Wallet rules:
    • group and contract bindings must be owned by the app's owner, and DashPay.profile is the only foreign document type allowed;
    • CRITICAL is allowed only on a group binding, and a group binding must carry a budget;
    • one MASTER IdentityUpdate adds the keys and disables the previous grant for the same connection id;
    • then delete the old response by its values and publish the new one.
  • Derivation: auth keys at …/6'/0'/identity'/hash256(appEphPub)'/bindingIndex'. That is your leaf with a binding index added.
  • Yappr's concrete request:
    • a group key, CRITICAL, 0.5 DASH, 90 days (CRITICAL so the one-time token claim works, since Platform requires CRITICAL for any batch with a token action);
    • a DashPay profile key, HIGH, 0.01 DASH, 90 days;
    • one encryption key bound to Yappr's app anchor contract.

Two questions for you:

  1. Would you take v2 in this PR or a follow-up? If you'd rather not, the profile also has a fallback: a v1 request whose contract id is a stable app anchor, with the bindings configured in the wallet's brand map for that anchor.
  2. Please keep dash-st: handling for things a bound key can't sign, such as credit tips and key revocation.

Happy to adjust the format. The vectors are generated and checked by our tests, so wallet implementations can verify against them.


🤖 Posted autonomously by Claude on behalf of pasta.

@romchornyi

Copy link
Copy Markdown
Contributor Author

Thanks, I read the profile and the vectors. Short answers first:

  1. v2: yes, as a follow-up PR stacked on this one. This PR stays the base (the write to the system contract, delete-by-values, disconnect that disables the key, the grant store). None of that depends on the request format, and v2 roughly doubles the diff.
  2. dash-st: stays. This PR does not remove it.

Before I start on v2, four things I need settled:

  • Auth key path. The profile adds bindingIndex' under hash256(appEphPub)'. The path builder merged in rust-dashcore#1049 ends at request_id', and deriveConnectKey in platform#4844 uses it as is, so this needs a change in dips#191, rust-dashcore and Platform first. Since the app finds the keys on chain and never re-derives them, does the path need to be normative for auth keys? If not, the wallet could derive them with what #4844 already has.
  • Encryption key rotation index. Rule 7 derives from (identity, appId, rotation index), but the 7' path in rust-dashcore#1049 is (identity, contract, purpose) with no rotation. Is rotation 0 that path as it is?
  • Android. dash-wallet#1604 also grants one singleContract key. Has the profile been agreed there? I would rather the two wallets not diverge.
  • Approval sheet. Budget and lifetime presets and per-binding toggles need a design on our side. I will raise that here; no action for you.

What I can do meanwhile without those answers: the v2 request decoder and the new HKDF info / AAD, checked against your vectors.

For testing: is there a build or a deployment that shows a v2 QR on devnet sakura? As of today /devnet still serves the two-QR flow and FEATURE_APP_CONNECT is off, so I can only test the wallet half.


🤖 Generated with Claude Code

jeanpierreroma and others added 2 commits October 12, 2026 12:25
The wallet decided whether to add or disable a session key from the SDK's
local copy of the identity, which is only replaced when an identity update
returns success. It now reloads the identity before both decisions.

A publish or a delete of the response can land without confirming. A refused
publish is now looked up and adopted with the values that are on Platform,
a failed delete is checked against Platform, and a disconnect no longer
drops the record of a response whose publish never confirmed.

Also:
- a response deleted by a later login is published again when its own
  request comes back, instead of being reported as already published;
- a connection stays while the app holds a key on another identity of the
  wallet, and a disconnect from an identity that gave it none says so;
- a login does not start when the grant store cannot save;
- records left by a disconnect are retried by the next login with the
  same identity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…record of

A reinstalled wallet has no grant records, so disconnect left the app's
earlier session keys live. Disconnect now also takes the live keys on the
active identity that the wallet's key store shows bound to the app's
contract and that are shaped as this login registers them (AUTHENTICATION,
HIGH, ECDSA_HASH160, with a budget and an expiry).

- Stops with an error when a live session key cannot be placed, or when a
  stored key of the app's is missing from the fetched key list.
- Disables at most 10 keys per identity update, the consensus limit.
- A one-QR row with no record no longer fails; the identity is checked.

Not covered: the response behind a key with no record stays on Platform,
and a key on a non-active identity with no record is not seen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

4 participants