diff --git a/prisma/migrations/20251013200000_add_payment_cbor_to_new_wallet/migration.sql b/prisma/migrations/20251013200000_add_payment_cbor_to_new_wallet/migration.sql new file mode 100644 index 00000000..091ab75e --- /dev/null +++ b/prisma/migrations/20251013200000_add_payment_cbor_to_new_wallet/migration.sql @@ -0,0 +1,8 @@ +-- DropIndex +DROP INDEX "BalanceSnapshot_snapshotDate_idx"; + +-- DropIndex +DROP INDEX "BalanceSnapshot_walletId_idx"; + +-- AlterTable +ALTER TABLE "NewWallet" ADD COLUMN "paymentCbor" TEXT; diff --git a/prisma/migrations/20251014101325_add_stake_cbor_to_new_wallet/migration.sql b/prisma/migrations/20251014101325_add_stake_cbor_to_new_wallet/migration.sql new file mode 100644 index 00000000..2d863bed --- /dev/null +++ b/prisma/migrations/20251014101325_add_stake_cbor_to_new_wallet/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "NewWallet" ADD COLUMN "stakeCbor" TEXT, +ADD COLUMN "usesStored" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/migrations/20251017075609_add_raw_import_bodies_to_new_wallet/migration.sql b/prisma/migrations/20251017075609_add_raw_import_bodies_to_new_wallet/migration.sql new file mode 100644 index 00000000..9a6bd6c2 --- /dev/null +++ b/prisma/migrations/20251017075609_add_raw_import_bodies_to_new_wallet/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "NewWallet" ADD COLUMN "rawImportBodies" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ef466d65..feccdec8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -78,6 +78,10 @@ model NewWallet { ownerAddress String stakeCredentialHash String? scriptType String? + usesStored Boolean @default(false) + paymentCbor String? + stakeCbor String? + rawImportBodies Json? } model Nonce { diff --git a/src/pages/api/v1/import/README.md b/src/pages/api/v1/import/README.md new file mode 100644 index 00000000..f48898d9 --- /dev/null +++ b/src/pages/api/v1/import/README.md @@ -0,0 +1,117 @@ +### Import Summon API + +Simple import endpoint to create/update a multisig wallet from an external data dump and return an invite URL. + +### Endpoint + +- POST `/api/v1/import/summon` + +### What it does + +- Validates an incoming `{ community, multisig, users }` payload for a multisig import. +- Upserts a `NewWallet` with signer data, `paymentCbor` (from `multisig.payment_script`), and `stakeCbor` (from `multisig.stake_script`). +- Returns the invite URL for the newly imported wallet. + +### Request body + +Send a single JSON object with these properties: + +- `community` (object) + - `id` (string) + - `name` (string) + - `description` (string; HTML allowed, tags are stripped for storage) + - `profile_photo_url` (string) + - `verified` (boolean) + - `verified_name` (string) +- `multisig` (object) + - `id` (string) + - `name` (string) + - `address` (string; validated) + - `created_at` (string; ISO or timestamp-like) + - `payment_script` (string; CBOR hex of native script) + - `stake_script` (string; CBOR hex of native script) +- `users` (array of objects) + - `id` (string) + - `name` (string) + - `address_bech32` (string; optional) + - `stake_pubkey_hash_hex` (string; 56-char lowercase hex; required) + - `ada_handle` (string; optional) + - `profile_photo_url` (string; optional) + +Minimal example payload: + +```json +{ + "community": { + "id": "28e2bee1-9b21-4393-bf7b-ec68c012a795", + "name": "Smart Contract Audit Token (SCATDAO)", + "description": "

A DAO for decentralized audits, research, and safety on Cardano.

", + "profile_photo_url": "https://scatdao.b-cdn.net/wp-content/uploads/2021/09/scatdao_graphic.png", + "verified": true, + "verified_name": "scatdao" + }, + "multisig": { + "id": "40b18160-684c-42b2-8523-577165bba8ec", + "name": "Test Community Treasury 2024/25 (1)", + "address": "addr1x809f8t6jy...", + "created_at": "2024-12-06 10:12:39.606+00", + "payment_script": "82018183030386...", + "stake_script": "82018183030386..." + }, + "users": [ + { + "id": "1876cf5b-37c7-4785-8194-73751134ddbe", + "name": "Alice", + "address_bech32": "addr1q8772af8wuvzksqx5p679p5wqsq...", + "stake_pubkey_hash_hex": "40b39bac8ce1b8b527899f8ad19e51...", + "ada_handle": "", + "profile_photo_url": "" + }, + { + "id": "af2b5725-accb-4de9-8fdc-5f439848a2cc", + "name": "Bob", + "address_bech32": "", + "stake_pubkey_hash_hex": "b7d2352a2a8a6661df9657f3fbe93a...", + "ada_handle": "", + "profile_photo_url": "" + } + ] +} +``` + +### Curl example + +```bash +curl -X POST \ + -H "Content-Type: application/json" \ + -d '{ + "community": {"id": "c1", "name": "Team", "description": "

Our team treasury

", "verified": true, "verified_name": "team"}, + "multisig": {"id": "msig_123", "name": "Team Treasury", "address": "addr1...", "payment_script": "4a50...c0", "stake_script": "4a50...c0"}, + "users": [ + {"id": "u1", "name": "Bob", "address_bech32": "", "stake_pubkey_hash_hex": "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd"}, + {"id": "u2", "name": "Carol", "address_bech32": "addr1...3zka", "stake_pubkey_hash_hex": "1234123412341234123412341234123412341234123412341234"} + ] + }' \ + https://multisig.meshjs.dev/api/v1/import/summon +``` + +### Response + +```json +{ + "ok": true, + "receivedAt": "2025-10-13T17:50:10.123Z", + "multisigAddress": "addr1...", + "dbUpdated": true, + "inviteUrl": "https://multisig.meshjs.dev/wallets/invite/" +} +``` + +### Notes + +- Only the`{ community, multisig, users }` shape is accepted. +- The wallet description prefers `community.description` (HTML tags are stripped); +- If a `multisig.id` is supplied, the wallet is upserted with that id; otherwise a new id is created. +- The raw request body is stored in `NewWallet.rawImportBodies`. +- CORS is enabled; `OPTIONS` requests return 200. +- If the database write fails, `dbUpdated` will be `false` and `inviteUrl` will be `null`. \ No newline at end of file diff --git a/src/pages/api/v1/import/summon.ts b/src/pages/api/v1/import/summon.ts new file mode 100644 index 00000000..c3cb6b39 --- /dev/null +++ b/src/pages/api/v1/import/summon.ts @@ -0,0 +1,183 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { cors, addCorsCacheBustingHeaders } from "@/lib/cors"; +import { validateMultisigImportPayload } from "@/utils/validateMultisigImport"; +import { db } from "@/server/db"; + + +// Draft endpoint: accepts POST request values and logs them +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + // Add cache-busting headers for CORS + addCorsCacheBustingHeaders(res); + + await cors(req, res); + if (req.method === "OPTIONS") { + return res.status(200).end(); + } + + if (req.method !== "POST") { + return res.status(405).json({ error: "Method Not Allowed" }); + } + + try { + const receivedAt = new Date().toISOString(); + + const result = await validateMultisigImportPayload(req.body); + if (!result.ok) { + return res.status(result.status).json(result.body); + } + const { summary, rows } = result; + + // Normalize request body into an array for consistent handling of signersDescriptions + const bodyAsArray: unknown[] = req.body == null + ? [] + : Array.isArray(req.body) + ? (req.body as unknown[]) + : [req.body]; + // Build wallet description from the first non-empty tagless community_description + function removeTagsLinear(input: string) { + let out = ""; + let inTag = false; + for (let i = 0; i < input.length; i++) { + const ch = input[i]!; + if (ch === "<") { inTag = true; continue; } + if (ch === ">") { inTag = false; continue; } + if (!inTag) out += ch; + } + return out; + } + function sanitizeDescription(v: string) { + const noTags = removeTagsLinear(v); + return noTags + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(/`/g, "`") + .trim(); + } + const walletDescription = (() => { + // Prefer new shape: req.body.community.description + const maybeDesc = (req.body as any)?.community?.description; + if (typeof maybeDesc === "string" && maybeDesc.trim().length > 0) { + return sanitizeDescription(maybeDesc); + } + for (const r of rows) { + const desc = (r as { community_description?: unknown }).community_description; + if (typeof desc === "string" && desc.trim().length > 0) { + return sanitizeDescription(desc); + } + } + return ""; + })(); + + // Use descriptions computed from validation (user_name aligned with signerAddresses) + const signersDescriptions = Array.isArray(summary.signersDescriptions) ? summary.signersDescriptions : []; + + // Use signer payment addresses as provided; leave empty string if missing + const paymentAddressesUsed = Array.isArray(summary.signerAddresses) + ? summary.signerAddresses.map((addr: string) => (typeof addr === "string" ? addr.trim() : "")) + : []; + + // Store the raw request body directly (new API only receives new shape) + const rawImportBodies = req.body; + + // Persist to NewWallet using validated data + let dbUpdated = false; + let newWalletId: string | null = null; + try { + const specifiedId = typeof summary.multisigId === "string" && summary.multisigId.trim().length > 0 + ? summary.multisigId.trim() + : null; + + if (specifiedId) { + const updateData: any = { + name: summary.multisigName ?? "Imported Multisig", + description: walletDescription, + signersAddresses: paymentAddressesUsed, + signersStakeKeys: summary.stakeAddressesUsed, + signersDRepKeys: [], + signersDescriptions, + numRequiredSigners: summary.numRequiredSigners, + ownerAddress: "all", + stakeCredentialHash: null, + scriptType: summary.scriptType ?? null, + paymentCbor: summary.paymentCbor ?? "", + stakeCbor: summary.stakeCbor ?? "", + usesStored: Boolean(summary.usesStored), + rawImportBodies, + }; + const createDataWithId: any = { + id: specifiedId, + name: summary.multisigName ?? "Imported Multisig", + description: walletDescription, + signersAddresses: paymentAddressesUsed, + signersStakeKeys: summary.stakeAddressesUsed, + signersDRepKeys: [], + signersDescriptions, + numRequiredSigners: summary.numRequiredSigners, + ownerAddress: "all", + stakeCredentialHash: null, + scriptType: summary.scriptType ?? null, + paymentCbor: summary.paymentCbor ?? "", + stakeCbor: summary.stakeCbor ?? "", + usesStored: Boolean(summary.usesStored), + rawImportBodies, + }; + const saved = await db.newWallet.upsert({ + where: { id: specifiedId }, + update: updateData, + create: createDataWithId, + }); + console.log("[api/v1/import/summon] NewWallet upsert success:", { id: saved.id }); + dbUpdated = true; + newWalletId = saved.id; + } else { + const createData: any = { + name: summary.multisigName ?? "Imported Multisig", + description: walletDescription, + signersAddresses: paymentAddressesUsed, + signersStakeKeys: summary.stakeAddressesUsed, + signersDRepKeys: [], + signersDescriptions, + numRequiredSigners: summary.numRequiredSigners, + ownerAddress: "all", + stakeCredentialHash: null, + scriptType: summary.scriptType ?? null, + paymentCbor: summary.paymentCbor ?? "", + stakeCbor: summary.stakeCbor ?? "", + usesStored: Boolean(summary.usesStored), + rawImportBodies, + }; + const created = await db.newWallet.create({ + data: createData, + }); + console.log("[api/v1/import/summon] NewWallet create success:", { id: created.id }); + dbUpdated = true; + newWalletId = created.id; + } + } catch (err) { + console.error("[api/v1/import/summon] NewWallet upsert failed:", err); + } + + // Generate the URL for the multisig wallet invite page + const baseUrl = "https://multisig.meshjs.dev"; + const inviteUrl = newWalletId ? `${baseUrl}/wallets/invite/${newWalletId}` : null; + + return res.status(200).json({ + ok: true, + receivedAt, + multisigAddress: summary.multisigAddress, + dbUpdated, + inviteUrl + }); + } catch (error) { + console.error("[api/v1/import/summon] Error handling POST:", error); + return res.status(500).json({ error: "Internal Server Error" }); + } +} + + diff --git a/src/pages/api/v1/og.ts b/src/pages/api/v1/og.ts new file mode 100644 index 00000000..9d5089fc --- /dev/null +++ b/src/pages/api/v1/og.ts @@ -0,0 +1,42 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +// Simple OG metadata extractor using fetch + regex fallbacks +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { url } = req.query; + if (typeof url !== "string") { + res.status(400).json({ error: "Missing url" }); + return; + } + + try { + const response = await fetch(url, { method: "GET" }); + const html = await response.text(); + + const extract = (property: string, nameFallback?: string) => { + const ogRegex = new RegExp(`]+property=["']${property}["'][^>]+content=["']([^"']+)["']`, "i"); + const ogMatch = ogRegex.exec(html); + if (ogMatch?.[1]) return ogMatch[1]; + if (nameFallback) { + const nameRegex = new RegExp(`]+name=["']${nameFallback}["'][^>]+content=["']([^"']+)["']`, "i"); + const nameMatch = nameRegex.exec(html); + if (nameMatch?.[1]) return nameMatch[1]; + } + return undefined; + }; + + const title = extract("og:title", "title") ?? (() => { + const titleRegex = /([^<]+)<\/title>/i; + const titleMatch = titleRegex.exec(html); + return titleMatch?.[1]; + })(); + const description = extract("og:description", "description"); + const image = extract("og:image"); + const siteName = extract("og:site_name"); + + res.status(200).json({ title, description, image, siteName, url }); + } catch (e: unknown) { + const errorMessage = e instanceof Error ? e.message : "Failed to fetch OG"; + res.status(500).json({ error: errorMessage }); + } +} + diff --git a/src/utils/validateMultisigImport.ts b/src/utils/validateMultisigImport.ts new file mode 100644 index 00000000..0643deca --- /dev/null +++ b/src/utils/validateMultisigImport.ts @@ -0,0 +1,922 @@ +import { checkValidAddress, addressToNetwork, stakeKeyHash, paymentKeyHash } from "@/utils/multisigSDK"; +import { serializeRewardAddress, pubKeyAddress } from "@meshsdk/core"; +import type { csl } from "@meshsdk/core-csl"; +import { deserializeNativeScript } from "@meshsdk/core-csl"; +import { getProvider } from "@/utils/get-provider"; + +export type ImportedMultisigRow = { + multisig_id?: string; + multisig_name?: string | null; + multisig_address?: string; + multisig_created_at?: string | null; + multisig_updated_at?: string | null; + payment_script?: string | null; + stake_script?: string | null; + user_id?: string; + user_name?: string | null; + user_address_bech32?: string; + user_stake_pubkey_hash_hex?: string | null; + user_ada_handle?: string | null; + user_profile_photo_url?: string | null; + user_created_at?: string | null; + community_id?: string | null; + community_name?: string | null; + community_description?: string | null; + community_profile_photo_url?: string | null; + community_verified?: boolean | null; + community_verified_name?: string | null; + community_created_at?: string | null; +}; + +export type MultisigImportSummary = { + multisigId?: string | null; + multisigName: string | null; + multisigAddress: string | null; + numRequiredSigners?: number | null; + paymentCbor: string; + stakeCbor: string; + signerStakeKeys: string[]; + signerAddresses: string[]; + signersDescriptions: string[]; + signersDRepKeys: string[]; + network?: number; + stakeAddressesUsed: string[]; + paymentAddressesUsed: string[]; + stakeCredentialHash?: string | null; + scriptType?: string | null; + usesStored: boolean; + sigMatches?: { + payment: SigMatch[]; + stake: SigMatch[]; + }; +}; + +export type ValidationSuccess = { ok: true; rows: ImportedMultisigRow[]; summary: MultisigImportSummary }; +export type ValidationFailure = { ok: false; status: number; body: Record<string, unknown> }; +export type ValidationResult = ValidationSuccess | ValidationFailure; + +const normalize = (v?: string | null) => (typeof v === "string" ? v.trim() : null); +const requiredField = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0; + +export async function validateMultisigImportPayload(payload: unknown): Promise<ValidationResult> { + // Accept only new shape: { community, multisig, users } + const p = (payload as Record<string, unknown>) ?? {}; + const community = (p as any)?.community ?? {}; + const multisig = (p as any)?.multisig ?? {}; + const users = Array.isArray((p as any)?.users) ? ((p as any).users as any[]) : []; + + let rows: ImportedMultisigRow[] = []; + if (users.length > 0 && (multisig?.id || multisig?.address || multisig?.payment_script || multisig?.stake_script)) { + rows = users.map((u: any) => { + const stakeHex = typeof u?.stake_pubkey_hash_hex === "string" ? u.stake_pubkey_hash_hex.trim().toLowerCase() : null; + const out: ImportedMultisigRow = { + multisig_id: typeof multisig?.id === "string" ? multisig.id : undefined, + multisig_name: typeof multisig?.name === "string" ? multisig.name : null, + multisig_address: typeof multisig?.address === "string" ? multisig.address : undefined, + multisig_created_at: typeof multisig?.created_at === "string" ? multisig.created_at : null, + payment_script: typeof multisig?.payment_script === "string" ? multisig.payment_script : null, + stake_script: typeof multisig?.stake_script === "string" ? multisig.stake_script : null, + user_id: typeof u?.id === "string" ? u.id : undefined, + user_name: typeof u?.name === "string" ? u.name : "", + user_address_bech32: typeof u?.address_bech32 === "string" ? u.address_bech32 : "", + user_stake_pubkey_hash_hex: stakeHex, + user_ada_handle: typeof u?.ada_handle === "string" ? u.ada_handle : "", + user_profile_photo_url: typeof u?.profile_photo_url === "string" ? u.profile_photo_url : null, + community_id: typeof community?.id === "string" ? community.id : null, + community_name: typeof community?.name === "string" ? community.name : null, + community_description: typeof community?.description === "string" ? community.description : null, + community_profile_photo_url: typeof community?.profile_photo_url === "string" ? community.profile_photo_url : null, + community_verified: typeof community?.verified === "boolean" ? community.verified : null, + community_verified_name: typeof community?.verified_name === "string" ? community.verified_name : null, + community_created_at: null, + }; + return out; + }); + } + + if (!Array.isArray(rows) || rows.length === 0) { + // Fallback: when users array is empty, derive users from stakeCbor if it differs from paymentCbor + try { + const paymentCborRaw = typeof (multisig as any)?.payment_script === "string" + ? ((multisig as any).payment_script as string).trim() + : null; + const stakeCborRaw = typeof (multisig as any)?.stake_script === "string" + ? ((multisig as any).stake_script as string).trim() + : null; + const paymentCborNorm = paymentCborRaw ? normalizeCborHex(paymentCborRaw) : ""; + const stakeCborNorm = stakeCborRaw ? normalizeCborHex(stakeCborRaw) : ""; + const differs = paymentCborNorm && stakeCborNorm && paymentCborNorm !== stakeCborNorm; + + if (differs && stakeCborRaw) { + const decodedStakeForUsers = decodeNativeScriptFromCbor(stakeCborRaw); + const stakeHashesForUsers = collectSigKeyHashes(decodedStakeForUsers); + if (Array.isArray(stakeHashesForUsers) && stakeHashesForUsers.length > 0) { + rows = stakeHashesForUsers.map((hex) => { + const stakeHex = (hex || "").toLowerCase(); + const out: ImportedMultisigRow = { + multisig_id: typeof (multisig as any)?.id === "string" ? (multisig as any).id : undefined, + multisig_name: typeof (multisig as any)?.name === "string" ? (multisig as any).name : null, + multisig_address: typeof (multisig as any)?.address === "string" ? (multisig as any).address : undefined, + multisig_created_at: typeof (multisig as any)?.created_at === "string" ? (multisig as any).created_at : null, + payment_script: typeof (multisig as any)?.payment_script === "string" ? (multisig as any).payment_script : null, + stake_script: typeof (multisig as any)?.stake_script === "string" ? (multisig as any).stake_script : null, + user_id: stakeHex, // Use stake hash as a deterministic id + user_name: "", + user_address_bech32: "", + user_stake_pubkey_hash_hex: stakeHex, + user_ada_handle: "", + user_profile_photo_url: null, + community_id: typeof (community as any)?.id === "string" ? (community as any).id : null, + community_name: typeof (community as any)?.name === "string" ? (community as any).name : null, + community_description: typeof (community as any)?.description === "string" ? (community as any).description : null, + community_profile_photo_url: typeof (community as any)?.profile_photo_url === "string" ? (community as any).profile_photo_url : null, + community_verified: typeof (community as any)?.verified === "boolean" ? (community as any).verified : null, + community_verified_name: typeof (community as any)?.verified_name === "string" ? (community as any).verified_name : null, + community_created_at: null, + }; + return out; + }); + } + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn("Failed to derive users from stakeCbor fallback:", e); + } + } + + if (!Array.isArray(rows) || rows.length === 0) { + return { + ok: false, + status: 400, + body: { error: "Expected payload: { community, multisig, users }" }, + }; + } + + const invalidIndexes: number[] = []; + for (let i = 0; i < rows.length; i++) { + const r = rows[i]!; + if (!requiredField(r.user_id) || !requiredField(r.user_stake_pubkey_hash_hex)) { + invalidIndexes.push(i); + } + } + if (invalidIndexes.length > 0) { + return { + ok: false, + status: 400, + body: { + error: "Each user must include id and stake_pubkey_hash_hex", + invalidIndexes, + }, + }; + } + + // Read multisig fields from top-level payload + const multisigId = typeof (payload as any)?.multisig?.id === "string" ? (payload as any).multisig.id : null; + const multisigName = typeof (payload as any)?.multisig?.name === "string" ? (payload as any).multisig.name : null; + const multisigAddress = typeof (payload as any)?.multisig?.address === "string" ? (payload as any).multisig.address : null; + + // Validate multisig address if present + if (typeof multisigAddress === "string" && multisigAddress.trim().length > 0) { + const isValid = checkValidAddress(multisigAddress); + if (!isValid) { + return { ok: false, status: 400, body: { error: "Invalid multisig_address format" } }; + } + } + + // Validate any provided user addresses + const invalidUserAddressIndexes: number[] = []; + for (let i = 0; i < rows.length; i++) { + const addr = rows[i]!.user_address_bech32; + if (typeof addr === "string" && addr.trim().length > 0) { + const valid = checkValidAddress(addr); + if (!valid) { + invalidUserAddressIndexes.push(i); + } + } + } + if (invalidUserAddressIndexes.length > 0) { + return { + ok: false, + status: 400, + body: { + error: "One or more user_address_bech32 values are invalid", + invalidUserAddressIndexes, + }, + }; + } + + // Build aligned arrays for signer stake keys, addresses, user names, and drep keys; ensure deterministic ordering + type CombinedSigner = { stake: string; address: string; name: string; drepKey: string }; + const combined: CombinedSigner[] = []; + const seenStake = new Set<string>(); + const removeTagsLinear = (input: string) => { + let out = ""; + let inTag = false; + for (let i = 0; i < input.length; i++) { + const ch = input[i]!; + if (ch === "<") { inTag = true; continue; } + if (ch === ">") { inTag = false; continue; } + if (!inTag) out += ch; + } + return out; + }; + const sanitizeText = (v: string) => { + const noTags = removeTagsLinear(v); + return noTags + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(/`/g, "`") + .trim(); + }; + for (const r of rows) { + const raw = r.user_stake_pubkey_hash_hex!; + const hex = typeof raw === "string" ? raw.trim().toLowerCase() : ""; + if (!hex || seenStake.has(hex)) continue; + seenStake.add(hex); + const addr = typeof r.user_address_bech32 === "string" ? r.user_address_bech32.trim() : ""; + // Use user_name for descriptions as requested, sanitized for safety + const nameRaw = typeof r.user_name === "string" ? r.user_name.trim() : ""; + const name = nameRaw ? sanitizeText(nameRaw) : ""; + const drepKey = ""; // Empty for now as requested + combined.push({ stake: hex, address: addr, name, drepKey }); + } + const signerStakeKeys = combined.map((c) => c.stake); + const signerAddresses = combined.map((c) => c.address); + const signerNames = combined.map((c) => c.name); + const signersDRepKeys = combined.map((c) => c.drepKey); + + // Infer network: prefer multisigAddress; if absent, fall back to the first valid user_address_bech32 + let network: 0 | 1 | undefined = undefined; + if (typeof multisigAddress === "string" && multisigAddress.trim().length > 0) { + network = addressToNetwork(multisigAddress) as 0 | 1; + } + if (network === undefined) { + for (const r of rows) { + const maybeAddr = r.user_address_bech32; + if (typeof maybeAddr === "string" && maybeAddr.trim().length > 0 && checkValidAddress(maybeAddr)) { + network = addressToNetwork(maybeAddr) as 0 | 1; + break; + } + } + } + + // Validate stake key hashes by reconstructing reward address and resolving back + const invalidStakeKeyIndexes: number[] = []; + for (let i = 0; i < rows.length; i++) { + const raw = rows[i]!.user_stake_pubkey_hash_hex!; + const hex = typeof raw === "string" ? raw.trim().toLowerCase() : ""; + // Expect 28-byte (56 hex chars) hash + if (!/^[0-9a-f]{56}$/.test(hex)) { + invalidStakeKeyIndexes.push(i); + continue; + } + const networksToTry: Array<0 | 1> = network === undefined ? [0, 1] : [network]; + let validForAny = false; + for (const netId of networksToTry) { + try { + const stakeAddr = serializeRewardAddress(hex, false, netId); + const resolved = stakeKeyHash(stakeAddr)?.toLowerCase(); + if (resolved === hex) { + validForAny = true; + break; + } + } catch { + // ignore and try next + } + } + if (!validForAny) { + invalidStakeKeyIndexes.push(i); + } + } + if (invalidStakeKeyIndexes.length > 0) { + const invalidStakeKeyDetails = invalidStakeKeyIndexes.map((idx) => ({ + index: idx, + user_id: rows[idx]!.user_id ?? null, + user_stake_pubkey_hash_hex: rows[idx]!.user_stake_pubkey_hash_hex ?? null, + })); + return { + ok: false, + status: 400, + body: { + error: "One or more user_stake_pubkey_hash_hex values are invalid", + invalidStakeKeyIndexes, + invalidStakeKeyDetails, + }, + }; + } + + // If a payment_script CBOR is provided, attempt to decode it to extract the required signers count and script type + let requiredFromPaymentScript: number | undefined = undefined; + let scriptTypeFromPaymentScript: "all" | "any" | "atLeast" | undefined = undefined; + let isPaymentHierarchical = false; + let paymentSigKeyHashes: string[] = []; + let paymentSigMatches: SigMatch[] = []; + const providedPaymentCbor = typeof (payload as any)?.multisig?.payment_script === "string" + ? ((payload as any).multisig.payment_script as string).trim() + : null; + if (providedPaymentCbor) { + try { + const decoded = decodeNativeScriptFromCbor(providedPaymentCbor); + // Log the decoded native script structure for visibility/debugging + // eslint-disable-next-line no-console + console.log("Imported payment native script (decoded):", JSON.stringify(decoded, null, 2)); + requiredFromPaymentScript = computeRequiredSigners(decoded); + // Determine script type by inspecting parents of "sig" leaves. + // Prefer the parent type of any signature node (atLeast > all > any). If none, fall back to root or "atLeast". + scriptTypeFromPaymentScript = detectTypeFromSigParents(decoded); + isPaymentHierarchical = isHierarchicalScript(decoded); + paymentSigKeyHashes = collectSigKeyHashes(decoded); + // eslint-disable-next-line no-console + console.log("Payment script sig key hashes:", paymentSigKeyHashes); + // Build stake addresses early so we can expand candidate payment addresses via stake keys + const stakeAddressesUsedForExpansion = buildStakeAddressesFromHashes(signerStakeKeys, network); + // Try to match using provided payment addresses and any addresses fetched from stake accounts + paymentSigMatches = await matchPaymentSigsExpanded( + paymentSigKeyHashes, + signerAddresses, + stakeAddressesUsedForExpansion, + network, + ); + // eslint-disable-next-line no-console + console.log("Payment sig match results:", JSON.stringify(paymentSigMatches, null, 2)); + } catch (e) { + // eslint-disable-next-line no-console + console.warn("Failed to decode provided payment_script CBOR:", e); + } + } + + // If a stake_script CBOR is provided, attempt to decode it and log for visibility + const providedStakeCbor = typeof (payload as any)?.multisig?.stake_script === "string" + ? ((payload as any).multisig.stake_script as string).trim() + : null; + let isStakeHierarchical = false; + let stakeSigKeyHashes: string[] = []; + let stakeSigMatches: SigMatch[] = []; + if (providedStakeCbor) { + try { + const decodedStake = decodeNativeScriptFromCbor(providedStakeCbor); + // eslint-disable-next-line no-console + console.log("Imported stake native script (decoded):", JSON.stringify(decodedStake, null, 2)); + isStakeHierarchical = isHierarchicalScript(decodedStake); + stakeSigKeyHashes = collectSigKeyHashes(decodedStake); + // eslint-disable-next-line no-console + console.log("Stake script sig key hashes:", stakeSigKeyHashes); + stakeSigMatches = matchStakeSigs(stakeSigKeyHashes, signerStakeKeys); + // eslint-disable-next-line no-console + console.log("Stake sig match results:", JSON.stringify(stakeSigMatches, null, 2)); + } catch (e) { + // eslint-disable-next-line no-console + console.warn("Failed to decode provided stake_script CBOR:", e); + } + } + + const stakeAddressesUsed = buildStakeAddressesFromHashes(signerStakeKeys, network); + + // Reorder signer addresses to match payment script key hash order; fill gaps with key hashes + let signerAddressesOrdered: string[] = signerAddresses; + let signersDescriptionsOrdered: string[] = signerNames; // will realign to addresses order + if (paymentSigKeyHashes.length > 0) { + const lowerMatches = new Map<string, SigMatch>(); + for (const m of paymentSigMatches) { + lowerMatches.set(m.sigKeyHash.toLowerCase(), m); + } + signerAddressesOrdered = paymentSigKeyHashes.map((sig) => { + const m = lowerMatches.get(sig.toLowerCase()); + const addr = m?.signerAddress; + if (m?.matched && typeof addr === "string" && addr.trim().length > 0) { + return addr; + } + // Fallback: use the keyHash itself where no payment address was provided + return sig; + }); + // Build descriptions in the same order as signerAddressesOrdered + signersDescriptionsOrdered = paymentSigKeyHashes.map((sig) => { + const m = lowerMatches.get(sig.toLowerCase()); + if (m?.matched && typeof m.signerIndex === "number") { + const idx = m.signerIndex; + return typeof signerNames[idx] === "string" ? signerNames[idx] : ""; + } + return ""; // unknown when no address match + }); + } + + // Decide whether to order stake keys by stake script key hash order: + // - If no payment addresses matched the payment script key hashes, OR + // - If the stake script differs from the payment script. + const paymentCborNorm = providedPaymentCbor ? normalizeCborHex(providedPaymentCbor) : ""; + const stakeCborNorm = providedStakeCbor ? normalizeCborHex(providedStakeCbor) : ""; + const stakeScriptDiffers = paymentCborNorm !== stakeCborNorm; + const anyPaymentMatch = Array.isArray(paymentSigMatches) && paymentSigMatches.some((m) => m.matched); + const noPaymentMatches = paymentSigKeyHashes.length > 0 ? !anyPaymentMatch : false; + + let signerStakeKeysOrdered: string[] = signerStakeKeys; + let stakeAddressesUsedFinal: string[] = stakeAddressesUsed; + // If payment and stake scripts are identical, align stake keys order to signer addresses order + if (!stakeScriptDiffers && paymentSigKeyHashes.length > 0) { + const lowerMatches = new Map<string, SigMatch>(); + for (const m of paymentSigMatches) { + lowerMatches.set(m.sigKeyHash.toLowerCase(), m); + } + signerStakeKeysOrdered = paymentSigKeyHashes.map((sig, i) => { + const m = lowerMatches.get(sig.toLowerCase()); + if (m?.matched && typeof m.signerIndex === "number") { + const idx = m.signerIndex; + const hex = (signerStakeKeys[idx] || "").toLowerCase(); + return hex && /^[0-9a-f]{56}$/.test(hex) ? hex : (stakeSigKeyHashes[i] || "").toLowerCase(); + } + // Fallback to stake script's key hash at same position, or empty string + return (stakeSigKeyHashes[i] || "").toLowerCase(); + }); + // Recompute stakeAddressesUsed to stay positionally aligned with the reordered stake keys + stakeAddressesUsedFinal = signerStakeKeysOrdered.map((hex) => { + const h = (hex || "").toLowerCase(); + const tryNetworks: Array<0 | 1> = network === undefined ? [0, 1] : [network as 0 | 1]; + for (const netId of tryNetworks) { + try { + const addr = serializeRewardAddress(h, false, netId); + const resolved = stakeKeyHash(addr)?.toLowerCase(); + if (resolved === h) return addr; + } catch { + // try next + } + } + return h; + }); + } else if ((noPaymentMatches || stakeScriptDiffers) && stakeSigKeyHashes.length > 0) { + const lowerMatches = new Map<string, SigMatch>(); + for (const m of stakeSigMatches) { + lowerMatches.set(m.sigKeyHash.toLowerCase(), m); + } + signerStakeKeysOrdered = stakeSigKeyHashes.map((sig) => { + const m = lowerMatches.get(sig.toLowerCase()); + const providedStakeHex = m?.signerStakeKey; + if (m?.matched && typeof providedStakeHex === "string" && providedStakeHex.trim().length > 0) { + return providedStakeHex.toLowerCase(); + } + // Fallback: use the keyHash itself where no stake key was provided + return sig.toLowerCase(); + }); + // Build stakeAddressesUsed positionally: reward addresses for matched keys, else raw key hash placeholders + stakeAddressesUsedFinal = stakeSigKeyHashes.map((sig) => { + const m = lowerMatches.get(sig.toLowerCase()); + const providedStakeHex = m?.signerStakeKey; + if (m?.matched && typeof providedStakeHex === "string" && providedStakeHex.trim().length > 0) { + // Try to compute a reward address for the matched stake key hash + const hex = providedStakeHex.toLowerCase(); + const tryNetworks: Array<0 | 1> = network === undefined ? [0, 1] : [network as 0 | 1]; + for (const netId of tryNetworks) { + try { + const addr = serializeRewardAddress(hex, false, netId); + const resolved = stakeKeyHash(addr)?.toLowerCase(); + if (resolved === hex) { + return addr; + } + } catch { + // continue to next + } + } + // If we couldn't build a valid reward address, fall back to the hex itself + return hex; + } + // No match: use the script key hash in this position + return sig.toLowerCase(); + }); + } + + return { + ok: true, + rows, + summary: { + multisigId, + multisigName, + multisigAddress, + numRequiredSigners: requiredFromPaymentScript ?? null, + paymentCbor: providedPaymentCbor ?? "", + stakeCbor: providedStakeCbor ?? "", + signerStakeKeys: signerStakeKeysOrdered, + signerAddresses: signerAddressesOrdered, + signersDescriptions: signersDescriptionsOrdered, + signersDRepKeys, + network, + stakeAddressesUsed: stakeAddressesUsedFinal, + paymentAddressesUsed: signerAddressesOrdered, + stakeCredentialHash: null, // Empty for now as requested + scriptType: scriptTypeFromPaymentScript ?? null, + usesStored: Boolean(isPaymentHierarchical || isStakeHierarchical), + sigMatches: { + payment: paymentSigMatches, + stake: stakeSigMatches, + }, + }, + }; +} + +async function fetchStakePaymentAddresses(stakeKey: string, network: number): Promise<string[]> { + const blockchainProvider = getProvider(network); + const res = await blockchainProvider.get(`/accounts/${stakeKey}/addresses`); + // Normalize provider response to an array of bech32 strings + const arr: unknown[] = Array.isArray(res) ? res : []; + const out: string[] = []; + for (const item of arr) { + if (typeof item === "string") { + out.push(item); + continue; + } + if (item && typeof item === "object") { + const maybe = (item as Record<string, unknown>).address + ?? (item as Record<string, unknown>).bech32 + ?? (item as Record<string, unknown>).paymentAddress + ?? (item as Record<string, unknown>).payment_address; + if (typeof maybe === "string") out.push(maybe); + } + } + const unique = Array.from(new Set(out.filter((s) => typeof s === "string" && s.trim().length > 0))); + return unique; +} + +// Returns the signature rule type for storage by inspecting parents of "sig" leaves: +// - If any signature's parent is "atLeast", return "atLeast" +// - Else if any signature's parent is "all", return "all" +// - Else if any signature's parent is "any", return "any" +// - Else fall back to the root type if it is "all" or "any"; otherwise return "atLeast" (1 of 1) +function detectTypeFromSigParents(script: DecodedNativeScript): "all" | "any" | "atLeast" { + const parentTypes = new Set<"all" | "any" | "atLeast">(); + collectSigParentTypes(script, null, parentTypes); + if (parentTypes.has("atLeast")) return "atLeast"; + if (parentTypes.has("all")) return "all"; + if (parentTypes.has("any")) return "any"; + if (script.type === "all" || script.type === "any") return script.type; + return "atLeast"; +} + +function collectSigParentTypes( + node: DecodedNativeScript, + parentType: "all" | "any" | "atLeast" | null, + out: Set<"all" | "any" | "atLeast"> +): void { + if (node.type === "sig") { + if (parentType) out.add(parentType); + return; + } + if (node.type === "all" || node.type === "any" || node.type === "atLeast") { + for (const child of node.scripts) { + collectSigParentTypes(child, node.type, out); + } + return; + } + // timelock nodes: nothing to traverse further +} + +function buildStakeAddressesFromHashes(stakeKeys: string[], network: number | undefined): string[] { + const out: string[] = []; + for (const hex of stakeKeys) { + const tryNetworks: Array<0 | 1> = network === undefined ? [0, 1] : [network as 0 | 1]; + let chosen: string | undefined; + for (const netId of tryNetworks) { + try { + const addr = serializeRewardAddress(hex, false, netId); + const resolved = stakeKeyHash(addr)?.toLowerCase(); + console.log("resolved", resolved, "hex", hex, "addr", addr); + if (resolved === hex) { + chosen = addr; + break; + } + } catch { + // continue to next network option + } + } + if (chosen) out.push(chosen); + } + return Array.from(new Set(out)); +} + +// --- Native script decoding helpers --- + +type DecodedNativeScript = + | { type: "sig"; keyHash: string } + | { type: "all"; scripts: DecodedNativeScript[] } + | { type: "any"; scripts: DecodedNativeScript[] } + | { type: "atLeast"; required: number; scripts: DecodedNativeScript[] } + | { type: "timelockStart" } + | { type: "timelockExpiry" }; + +type SigMatch = { + sigKeyHash: string; + matched: boolean; + matchedBy?: "paymentAddress" | "stakeKey"; + signerIndex?: number; + signerAddress?: string; + signerStakeKey?: string; +}; + +function normalizeCborHex(cborHex: string): string { + const trimmed = (cborHex || "").trim(); + if (trimmed.startsWith("0x") || trimmed.startsWith("0X")) { + return trimmed.slice(2); + } + return trimmed; +} + +function decodeNativeScriptFromCbor(cborHex: string): DecodedNativeScript { + const ns = deserializeNativeScript(normalizeCborHex(cborHex)); + return decodeNativeScriptFromCsl(ns); +} + +function decodeNativeScriptFromCsl(ns: csl.NativeScript): DecodedNativeScript { + const sp = ns.as_script_pubkey(); + if (sp) { + const keyHash = sp.addr_keyhash().to_hex(); + return { type: "sig", keyHash }; + } + + const tls = ns.as_timelock_start?.(); + if (tls) { + return { type: "timelockStart" }; + } + + const tle = ns.as_timelock_expiry?.(); + if (tle) { + return { type: "timelockExpiry" }; + } + + const saAll = ns.as_script_all(); + if (saAll) { + const list = saAll.native_scripts(); + const scripts: DecodedNativeScript[] = []; + for (let i = 0; i < list.len(); i++) { + const child = list.get(i); + scripts.push(decodeNativeScriptFromCsl(child)); + } + return { type: "all", scripts }; + } + + const saAny = ns.as_script_any(); + if (saAny) { + const list = saAny.native_scripts(); + const scripts: DecodedNativeScript[] = []; + for (let i = 0; i < list.len(); i++) { + const child = list.get(i); + scripts.push(decodeNativeScriptFromCsl(child)); + } + return { type: "any", scripts }; + } + + const sn = ns.as_script_n_of_k(); + if (sn) { + const list = sn.native_scripts(); + const scripts: DecodedNativeScript[] = []; + for (let i = 0; i < list.len(); i++) { + const child = list.get(i); + scripts.push(decodeNativeScriptFromCsl(child)); + } + const n = sn.n(); + const required = typeof n === "number" ? n : Number((n as unknown as { to_str?: () => string }).to_str?.() ?? n as unknown as number); + return { type: "atLeast", required, scripts }; + } + + // Unknown variant; default to requiring 1 signature + return { type: "atLeast", required: 1, scripts: [] }; +} + +function computeRequiredSigners(script: DecodedNativeScript): number { + switch (script.type) { + case "sig": + return 1; + case "timelockStart": + case "timelockExpiry": + return 0; + case "any": + if (script.scripts.length === 0) return 0; + return Math.min(...script.scripts.map((s) => computeRequiredSigners(s))); + case "all": { + let total = 0; + for (const s of script.scripts) total += computeRequiredSigners(s); + return total; + } + case "atLeast": + if (script.scripts.length === 0) return Math.max(0, script.required); + const childReqs = script.scripts.map((s) => computeRequiredSigners(s)).sort((a, b) => a - b); + const need = Math.max(0, Math.min(script.required, childReqs.length)); + let sum = 0; + for (let i = 0; i < need; i++) sum += childReqs[i]!; + return sum; + default: + return 0; + } +} + +// A script is considered hierarchical ONLY if some signature node is nested under +// two or more logical groups (all/any/atLeast). Examples: +// - atLeast(sig, sig) -> NOT hierarchical (sig depth = 1) +// - all(any(sig, ...), ...) -> hierarchical (sig depth = 2) +// Timelock nodes are ignored and do not contribute to depth. +function isHierarchicalScript(script: DecodedNativeScript): boolean { + return hasSigWithLogicalDepth(script, 0); +} + +function hasSigWithLogicalDepth(node: DecodedNativeScript, logicalDepth: number): boolean { + if (node.type === "sig") { + // Require depth >= 2: root logical group -> child logical group -> sig + return logicalDepth >= 2; + } + if (node.type === "all" || node.type === "any" || node.type === "atLeast") { + for (const child of node.scripts) { + if (hasSigWithLogicalDepth(child, logicalDepth + 1)) return true; + } + return false; + } + // Timelock nodes do not count towards logical depth and have no children to traverse + return false; +} + +// Collect all sig key hashes from a decoded native script tree +function collectSigKeyHashes(node: DecodedNativeScript): string[] { + if (node.type === "sig") return [node.keyHash.toLowerCase()]; + if (node.type === "all" || node.type === "any" || node.type === "atLeast") { + const out: string[] = []; + for (const child of node.scripts) out.push(...collectSigKeyHashes(child)); + // De-duplicate in case of repeated leaves + return Array.from(new Set(out)); + } + return []; +} + +// Match payment script sig key hashes to signer payment addresses +function matchPaymentSigs(sigKeyHashes: string[], signerAddresses: string[]): SigMatch[] { + // Build mapping from payment key hash -> first signer index that yields it + const pkhToIndex = new Map<string, number>(); + const indexToAddress = new Map<number, string>(); + for (let i = 0; i < signerAddresses.length; i++) { + const addr = signerAddresses[i]; + if (typeof addr !== "string" || addr.trim().length === 0) continue; + try { + const pkh = paymentKeyHash(addr).toLowerCase(); + if (!pkhToIndex.has(pkh)) pkhToIndex.set(pkh, i); + indexToAddress.set(i, addr); + } catch { + // ignore invalid address + } + } + const matches: SigMatch[] = []; + for (const sig of sigKeyHashes) { + const idx = pkhToIndex.get(sig.toLowerCase()); + if (idx !== undefined) { + matches.push({ + sigKeyHash: sig, + matched: true, + matchedBy: "paymentAddress", + signerIndex: idx, + signerAddress: indexToAddress.get(idx), + }); + } else { + matches.push({ sigKeyHash: sig, matched: false }); + } + } + return matches; +} + +// Expanded matcher: also fetch payment bech32s from stake accounts when +// - a signer has no provided payment address, or +// - a provided address's payment key hash is not among the required key hashes +async function matchPaymentSigsExpanded( + sigKeyHashes: string[], + signerAddresses: string[], + stakeAddresses: string[], + network: number | undefined, +): Promise<SigMatch[]> { + const required = new Set(sigKeyHashes.map((s) => s.toLowerCase())); + + // Map payment key hash -> preferred candidate { index, address } + const pkhToCandidate = new Map<string, { index: number; address: string }>(); + + // First, consider provided signer addresses (no network calls) + for (let i = 0; i < signerAddresses.length; i++) { + const addr = signerAddresses[i]; + if (!addr || addr.trim().length === 0) continue; + try { + const pkh = paymentKeyHash(addr).toLowerCase(); + // Only store if it's one of the required hashes + if (required.has(pkh) && !pkhToCandidate.has(pkh)) { + pkhToCandidate.set(pkh, { index: i, address: addr }); + } + } catch { + // ignore invalid address + } + } + + + // Determine which indices need expansion via stake account fetch + const indicesNeedingExpansion: number[] = []; + for (let i = 0; i < signerAddresses.length; i++) { + const addr = signerAddresses[i]; + let needs = false; + if (!addr || addr.trim().length === 0) { + needs = true; + } else { + try { + const pkh = paymentKeyHash(addr).toLowerCase(); + if (!required.has(pkh)) needs = true; + } catch { + needs = true; + } + } + if (needs) indicesNeedingExpansion.push(i); + } + + // If network unknown, skip fetching + if (network !== 0 && network !== 1) { + // Build matches from whatever candidates we already have + const matches = sigKeyHashes.map((sig) => { + const lower = sig.toLowerCase(); + const cand = pkhToCandidate.get(lower); + if (cand) { + return { + sigKeyHash: sig, + matched: true, + matchedBy: "paymentAddress", + signerIndex: cand.index, + signerAddress: cand.address, + } as SigMatch; + } + return { sigKeyHash: sig, matched: false } as SigMatch; + }); + + return matches; + } + + // Fetch additional addresses per stake account for the indices needing expansion + const fetchPromises: Array<Promise<void>> = []; + for (const i of indicesNeedingExpansion) { + const stakeAddr = stakeAddresses[i]; + if (!stakeAddr || stakeAddr.trim().length === 0) continue; + fetchPromises.push((async () => { + try { + const addrs = await fetchStakePaymentAddresses(stakeAddr, network); + + for (const a of addrs) { + try { + const pkh = paymentKeyHash(a).toLowerCase(); + if (required.has(pkh) && !pkhToCandidate.has(pkh)) { + pkhToCandidate.set(pkh, { index: i, address: a }); + } + } catch { + // ignore invalid address + } + } + + } catch { + // ignore fetch failure, continue + } + })()); + } + + await Promise.all(fetchPromises); + + // Build final matches, preserving order of sigKeyHashes + const results = sigKeyHashes.map((sig) => { + const lower = sig.toLowerCase(); + const cand = pkhToCandidate.get(lower); + if (cand) { + return { + sigKeyHash: sig, + matched: true, + matchedBy: "paymentAddress", + signerIndex: cand.index, + signerAddress: cand.address, + } as SigMatch; + } + return { sigKeyHash: sig, matched: false } as SigMatch; + }); + + return results; +} + +// Match stake script sig key hashes to signer stake key hashes +function matchStakeSigs(sigKeyHashes: string[], signerStakeKeys: string[]): SigMatch[] { + const stakeSet = new Map<string, number>(); + for (let i = 0; i < signerStakeKeys.length; i++) { + const hex = (signerStakeKeys[i] || "").toLowerCase(); + if (/^[0-9a-f]{56}$/.test(hex) && !stakeSet.has(hex)) stakeSet.set(hex, i); + } + const matches: SigMatch[] = []; + for (const sig of sigKeyHashes) { + const idx = stakeSet.get(sig.toLowerCase()); + if (idx !== undefined) { + matches.push({ + sigKeyHash: sig, + matched: true, + matchedBy: "stakeKey", + signerIndex: idx, + signerStakeKey: signerStakeKeys[idx], + }); + } else { + matches.push({ sigKeyHash: sig, matched: false }); + } + } + return matches; +} + +