diff --git a/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts b/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts index 2a7262e74..8032eaaea 100644 --- a/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts +++ b/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts @@ -305,6 +305,34 @@ const SOCIAL_BINDING_DOCS_QUERY = ` } `; +// Paginated variant for fetchSentBindingStatus, which must walk every page +// before concluding a doc is gone (see there). +const SOCIAL_BINDING_DOCS_PAGE_QUERY = ` + query($after: String) { + bindingDocuments(type: social_connection, first: 100, after: $after) { + edges { + node { + id + parsed + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +`; + +// Named so fetchSentBindingStatus can annotate `data` and avoid a circular +// inference error (TS7022) from reassigning the cursor inside the paging loop. +interface SocialBindingDocsPage { + bindingDocuments: { + edges: BindingDocEdge[]; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + }; +} + export interface CreateBindingDocResult { createBindingDocument: { metaEnvelopeId: string | null; @@ -623,6 +651,152 @@ export async function fetchSocialBindings( return out; } +// --------------------------------------------------------------------------- +// Reconciling scanner-side ("sent") mirrors against the source of truth +// --------------------------------------------------------------------------- + +/** + * True status of a scanner-initiated ("sent") binding, determined by reading + * the primary doc in the counterparty's vault — the source of truth. The + * scanner only holds a single-signature mirror; the real doc lives in the + * counterparty's vault. + * + * - `confirmed`: the counterparty counter-signed (doc has 2 signatures). + * - `pending`: the counterparty hasn't acted yet (doc has 1 signature). + * - `declined`: the counterparty declined and deleted the doc (it's gone). + */ +export type SentBindingStatus = "confirmed" | "pending" | "declined"; + +/** + * Read the counterparty's vault to determine the true status of a binding the + * caller initiated by scanning. Reuses the same cross-vault read path as + * fetchNameFromVault (X-ENAME scopes the query to the counterparty's data). + * + * Throws if the counterparty vault can't be resolved or reached — callers MUST + * treat a throw as "unknown" and leave the local mirror untouched, so a + * transient network error never deletes a still-valid binding. + */ +export async function fetchSentBindingStatus( + selfEname: string, + counterpartyEname: string, +): Promise { + const normalizedSelf = selfEname.startsWith("@") + ? selfEname + : `@${selfEname}`; + const normalizedCounter = counterpartyEname.startsWith("@") + ? counterpartyEname + : `@${counterpartyEname}`; + + const foreignGqlUrl = await resolveVaultUri(normalizedCounter); + + // The primary doc has subject=@counterparty and lists both parties; any + // 2-signature match means confirmed (repeat scans can leave several). Only + // conclude "declined" — which deletes the mirror — after all pages are checked. + let after: string | null = null; + let sawMatch = false; + do { + const data: SocialBindingDocsPage = + await vaultGqlRequest( + foreignGqlUrl, + normalizedCounter, + SOCIAL_BINDING_DOCS_PAGE_QUERY, + { after: after ?? undefined }, + ); + + const connection = data.bindingDocuments; + for (const edge of connection?.edges ?? []) { + const parsed = edge.node.parsed; + if (!parsed || parsed.type !== "social_connection") continue; + if (parsed.subject !== normalizedCounter) continue; + const parties = Array.isArray(parsed.data?.parties) + ? (parsed.data.parties as string[]) + : []; + if (!parties.includes(normalizedSelf)) continue; + + sawMatch = true; + // A 2-signature match is terminal — the counterparty counter-signed. + const sigs = parsed.signatures; + if (Array.isArray(sigs) && sigs.length >= 2) return "confirmed"; + } + + const pageInfo = connection?.pageInfo; + after = pageInfo?.hasNextPage ? (pageInfo?.endCursor ?? null) : null; + } while (after !== null); + + // No matching doc on any page → the counterparty deleted it (declined). + // Otherwise we only ever saw single-signature matches → still pending. + return sawMatch ? "pending" : "declined"; +} + +/** + * Fetch the caller's social bindings and reconcile every scanner-initiated + * ("sent") mirror that isn't yet mutually signed against the counterparty's + * vault (the source of truth): + * + * - counterparty counter-signed → mark the mirror mutually signed. + * - counterparty declined (gone) → drop it from the list AND delete the now + * orphaned local mirror, so a rejected binding stops showing as successful + * (the whole point of this reconcile — see issue #990). + * - still pending / unreachable → keep it as an unconfirmed (pending) binding. + * + * A confirmed or already-mutually-signed binding needs no remote read. + */ +export async function fetchReconciledSocialBindings( + ownGqlUrl: string, + callerEname: string, +): Promise { + const summaries = await fetchSocialBindings(ownGqlUrl, callerEname); + + const reconciled = await Promise.all( + summaries.map(async (summary) => { + // Only scanner-initiated mirrors that aren't yet mutually signed + // need a remote check; everything else is already authoritative. + if (summary.role !== "sent" || summary.mutuallySigned) { + return summary; + } + try { + const status = await fetchSentBindingStatus( + callerEname, + summary.counterpartyEname, + ); + if (status === "confirmed") { + return { ...summary, mutuallySigned: true }; + } + if (status === "declined") { + // The counterparty rejected the request and deleted their + // copy — remove our orphaned mirror so it stops showing as + // a successful binding, then drop it from this list. + void deleteSocialBindingDoc( + ownGqlUrl, + callerEname, + summary.docId, + ).catch((err) => + console.warn( + "[socialBinding] failed to delete declined mirror", + summary.docId, + err, + ), + ); + return null; + } + // pending — keep it as an unconfirmed binding. + return summary; + } catch (err) { + // Couldn't reach the counterparty vault — treat as unknown and + // keep the mirror; never delete on a transient failure. + console.warn( + "[socialBinding] could not reconcile sent binding with", + summary.counterpartyEname, + err, + ); + return summary; + } + }), + ); + + return reconciled.filter((s): s is SocialBindingSummary => s !== null); +} + // --------------------------------------------------------------------------- // Scanner-side mirror write // --------------------------------------------------------------------------- diff --git a/infrastructure/eid-wallet/src/routes/(app)/main/+page.svelte b/infrastructure/eid-wallet/src/routes/(app)/main/+page.svelte index 7610c802e..6a05cadb5 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/main/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/main/+page.svelte @@ -40,7 +40,7 @@ import { isPermissionGranted } from "@choochmeque/tauri-plugin-notifications-api import { openAppSettings } from "@tauri-apps/plugin-barcode-scanner"; import { fetchNameFromVault, - fetchSocialBindings, + fetchReconciledSocialBindings, resolveVaultUri, } from "$lib/utils"; import { getCanonicalBindingDocString } from "$lib/utils/bindingDocHash"; @@ -430,7 +430,10 @@ async function loadSocialBindings(): Promise { const gqlUrl = new URL("/graphql", vault.uri).toString(); try { - const bindings = await fetchSocialBindings(gqlUrl, callerEname); + const bindings = await fetchReconciledSocialBindings( + gqlUrl, + callerEname, + ); // Group by counterparty. The same person can show up across multiple // docs (one for each direction the binding was scanned in); we want @@ -460,6 +463,7 @@ async function loadSocialBindings(): Promise { : hasSent ? "sent" : "received"; + const pending = !group.some((b) => b.mutuallySigned); let name = counterpartyEname; try { @@ -480,6 +484,7 @@ async function loadSocialBindings(): Promise { counterpartyEname, counterpartyName: name, role, + pending, bindings: group, }; }), diff --git a/infrastructure/eid-wallet/src/routes/(app)/main/components/SocialBindingAccordion.svelte b/infrastructure/eid-wallet/src/routes/(app)/main/components/SocialBindingAccordion.svelte index 0f360c215..4792d5fbb 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/main/components/SocialBindingAccordion.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/main/components/SocialBindingAccordion.svelte @@ -15,6 +15,12 @@ export interface SocialBindingDisplay { * each other's QRs at the same coffee). */ role: "sent" | "received" | "both"; + /** + * True when no binding with this contact is mutually signed yet — every + * doc is still awaiting the counterparty's counter-signature. Confirmed + * (mutually signed) bindings clear this even if others are still pending. + */ + pending: boolean; /** * Underlying per-doc summaries kept around so the details bottom sheet * can list them without re-fetching. diff --git a/infrastructure/eid-wallet/src/routes/(app)/main/components/SocialBindingDetailsSheet.svelte b/infrastructure/eid-wallet/src/routes/(app)/main/components/SocialBindingDetailsSheet.svelte index af9985cb8..3fac4910d 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/main/components/SocialBindingDetailsSheet.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/main/components/SocialBindingDetailsSheet.svelte @@ -58,9 +58,13 @@ function close() {
- {roleLabel(contact.role)} + {contact.pending + ? "Awaiting confirmation" + : roleLabel(contact.role)}
@@ -72,6 +76,11 @@ function close() {

{binding.role === "sent" ? "Sent" : "Received"} + {#if !binding.mutuallySigned} + · Awaiting confirmation + {/if}

{#if binding.relationDescription}

{ : 'text-black-900'}" > {success - ? "Binding signed!" + ? "Request sent" : "You have scanned a\nsocial binding QR code"}

diff --git a/infrastructure/eid-wallet/src/routes/(app)/social-bindings/+page.svelte b/infrastructure/eid-wallet/src/routes/(app)/social-bindings/+page.svelte index 320263795..5e82bee2c 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/social-bindings/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/social-bindings/+page.svelte @@ -5,7 +5,7 @@ import { ChevronIcon } from "$lib/ui/icons"; import { type SocialBindingSummary, fetchNameFromVault, - fetchSocialBindings, + fetchReconciledSocialBindings, resolveVaultUri, } from "$lib/utils"; import { getContext, onMount } from "svelte"; @@ -51,7 +51,10 @@ async function init() { : `@${vault.ename}`; const gqlUrl = new URL("/graphql", vault.uri).toString(); - const summaries = await fetchSocialBindings(gqlUrl, callerEname); + const summaries = await fetchReconciledSocialBindings( + gqlUrl, + callerEname, + ); // Group by counterparty so each person shows once with a combined // role label, matching the home-screen accordion. @@ -76,6 +79,7 @@ async function init() { : hasSent ? "sent" : "received"; + const pending = !group.some((b) => b.mutuallySigned); let name = counterpartyEname; try { @@ -92,6 +96,7 @@ async function init() { counterpartyEname, counterpartyName: name, role, + pending, bindings: group, }; }, @@ -153,6 +158,11 @@ const subtitle = $derived(

{roleLabel(contact.role)} + {#if contact.pending} + · Awaiting confirmation + {/if}