Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions infrastructure/eid-wallet/src/lib/utils/socialBinding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<SentBindingStatus> {
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<SocialBindingDocsPage>(
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<SocialBindingSummary[]> {
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
// ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -430,7 +430,10 @@ async function loadSocialBindings(): Promise<void> {
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
Expand Down Expand Up @@ -460,6 +463,7 @@ async function loadSocialBindings(): Promise<void> {
: hasSent
? "sent"
: "received";
const pending = !group.some((b) => b.mutuallySigned);

let name = counterpartyEname;
try {
Expand All @@ -480,6 +484,7 @@ async function loadSocialBindings(): Promise<void> {
counterpartyEname,
counterpartyName: name,
role,
pending,
bindings: group,
};
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,13 @@ function close() {

<div class="flex justify-center">
<span
class="inline-flex items-center rounded-full bg-success-200 px-3 py-1 text-sm font-semibold text-black-900"
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-semibold {contact.pending
? 'bg-amber-100 text-amber-800'
: 'bg-success-200 text-black-900'}"
>
{roleLabel(contact.role)}
{contact.pending
? "Awaiting confirmation"
: roleLabel(contact.role)}
</span>
</div>

Expand All @@ -72,6 +76,11 @@ function close() {
<div class="flex-1 min-w-0">
<p class="font-semibold text-black-900 text-sm">
{binding.role === "sent" ? "Sent" : "Received"}
{#if !binding.mutuallySigned}
<span class="font-normal text-amber-600"
>· Awaiting confirmation</span
>
{/if}
</p>
{#if binding.relationDescription}
<p
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ $effect(() => {
: 'text-black-900'}"
>
{success
? "Binding signed!"
? "Request sent"
: "You have scanned a\nsocial binding QR code"}
</h4>
<p class="text-sm leading-relaxed text-black-500 text-center">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand All @@ -76,6 +79,7 @@ async function init() {
: hasSent
? "sent"
: "received";
const pending = !group.some((b) => b.mutuallySigned);

let name = counterpartyEname;
try {
Expand All @@ -92,6 +96,7 @@ async function init() {
counterpartyEname,
counterpartyName: name,
role,
pending,
bindings: group,
};
},
Expand Down Expand Up @@ -153,6 +158,11 @@ const subtitle = $derived(
</p>
<p class="text-black-500 leading-tight">
{roleLabel(contact.role)}
{#if contact.pending}
<span class="text-amber-600"
>· Awaiting confirmation</span
>
{/if}
</p>
</div>
<ChevronIcon
Expand Down
Loading