From e937ae60a40e2828ef4c054ae896c555c8759d6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Tue, 30 Sep 2025 13:33:40 +0200 Subject: [PATCH 01/20] feat(CBOR-Intergration- wallet-import-api): Create api route and validation script. Also updates NewWallet table in db after validation --- src/pages/api/v1/ejection/redirect.ts | 179 ++++++++++++ src/utils/validateMultisigImport.ts | 382 ++++++++++++++++++++++++++ 2 files changed, 561 insertions(+) create mode 100644 src/pages/api/v1/ejection/redirect.ts create mode 100644 src/utils/validateMultisigImport.ts diff --git a/src/pages/api/v1/ejection/redirect.ts b/src/pages/api/v1/ejection/redirect.ts new file mode 100644 index 00000000..07630a23 --- /dev/null +++ b/src/pages/api/v1/ejection/redirect.ts @@ -0,0 +1,179 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { cors, addCorsCacheBustingHeaders } from "@/lib/cors"; +import { validateMultisigImportPayload } from "@/utils/validateMultisigImport"; +import { db } from "@/server/db"; +import { addressToNetwork } from "@/utils/multisigSDK"; +import { getProvider } from "@/utils/get-provider"; + +// 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 origin = req.headers.origin ?? null; + const userAgent = req.headers["user-agent"] ?? null; + + const result = validateMultisigImportPayload(req.body); + if (!result.ok) { + return res.status(result.status).json(result.body); + } + const { summary, rows } = result; + + console.log("[api/v1/ejection/redirect] Received multisig import POST:", { + receivedAt, + origin, + userAgent, + query: req.query, + multisigId: summary.multisigId, + multisigName: summary.multisigName, + multisigAddress: summary.multisigAddress, + network: summary.network, + signerCount: summary.signerStakeKeys.length, + signerStakeKeys: summary.signerStakeKeys, + signerAddresses: summary.signerAddresses, + rows: rows, + }); + + + // Use aligned signersDescriptions from validator (already tag-stripped and ordered) + const signersDescriptions = Array.isArray(summary.signersDescriptions) ? summary.signersDescriptions : []; + + // Backfill missing signer payment addresses via stake address lookup + const stakeAddresses = Array.isArray(summary.stakeAddressesUsed) ? summary.stakeAddressesUsed : []; + const signerAddresses = Array.isArray(summary.signerAddresses) ? summary.signerAddresses : []; + type BlockchainProvider = { + get: (path: string) => Promise; + fetchAccountInfo?: (stakeAddr: string) => Promise; + }; + function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null; + } + function normalizeArrayResponse(resp: unknown): unknown[] { + if (Array.isArray(resp)) return resp; + if (isRecord(resp) && Array.isArray(resp.data)) return resp.data as unknown[]; + return []; + } + function extractFirstAddress(arr: unknown[]): string | null { + if (!Array.isArray(arr) || arr.length === 0) return null; + const first = arr[0]; + if (typeof first === "string") return first; + if (isRecord(first)) { + const maybe = (first as { address?: unknown }).address; + if (typeof maybe === "string") return maybe; + } + return null; + } + async function fetchFirstPaymentAddressForStake(stakeAddr: string): Promise { + try { + const network = addressToNetwork(stakeAddr); + const blockchainProvider = getProvider(network) as unknown as BlockchainProvider; + const endpoint = `/accounts/${stakeAddr}/addresses?count=1&order=asc`; + const resp = await blockchainProvider.get(endpoint).catch(() => null); + const arr = normalizeArrayResponse(resp); + const addr = extractFirstAddress(arr); + if (addr) { + return addr; + } + } catch (e) { + console.error("[api/v1/ejection/redirect] fetchFirstPaymentAddressForStake error", e); + } + return null; + } + + const paymentAddressesUsed = await Promise.all( + (signerAddresses || []).map(async (addr, idx) => { + const trimmed = typeof addr === "string" ? addr.trim() : ""; + if (trimmed) return trimmed; + const stakeAddr = stakeAddresses[idx]; + if (!stakeAddr) return ""; + const found = await fetchFirstPaymentAddressForStake(stakeAddr); + return found ?? ""; + }) + ); + + // Persist to NewWallet using validated data + let dbUpdated = false; + let newWalletId: string | null = null; + try { + // ownerAddress must strictly be the multisigAddress + const ownerAddress = summary.multisigAddress ?? ""; + if (!ownerAddress) { + return res.status(400).json({ error: "multisigAddress is required as ownerAddress" }); + } + + // Find existing wallet by ownerAddress to avoid duplicates + const existing = await db.newWallet.findFirst({ where: { ownerAddress } }); + if (existing) { + const updated = await db.newWallet.update({ + where: { id: existing.id }, + data: { + name: summary.multisigName ?? existing.name ?? "Imported Multisig", + description: "Imported via ejection redirect", + signersAddresses: paymentAddressesUsed, + signersStakeKeys: summary.signerStakeKeys, + signersDescriptions, + numRequiredSigners: summary.numRequiredSigners, + ownerAddress, + }, + }); + console.log("[api/v1/ejection/redirect] NewWallet update success:", { id: updated.id }); + dbUpdated = true; + newWalletId = updated.id; + } else { + const created = await db.newWallet.create({ + data: { + // Let Prisma generate id (cuid()) when multisigId is not present + ...(summary.multisigId ? { id: summary.multisigId } : {}), + name: summary.multisigName ?? "Imported Multisig", + description: "Imported via ejection redirect", + signersAddresses: paymentAddressesUsed, + signersStakeKeys: summary.signerStakeKeys, + signersDescriptions, + numRequiredSigners: summary.numRequiredSigners, + ownerAddress, + }, + }); + console.log("[api/v1/ejection/redirect] NewWallet create success:", { id: created.id }); + dbUpdated = true; + newWalletId = created.id; + } + } catch (err) { + console.error("[api/v1/ejection/redirect] NewWallet upsert failed:", err); + } + + return res.status(200).json({ + ok: true, + receivedAt, + multisigId: summary.multisigId, + multisigName: summary.multisigName, + multisigAddress: summary.multisigAddress, + network: summary.network, + signerCount: summary.signerStakeKeys.length, + numRequiredSigners: summary.numRequiredSigners, + stakeKeysUsed: summary.stakeAddressesUsed, + paymentKeysUsed: paymentAddressesUsed, + stakeKeyHexes: summary.signerStakeKeys, + dbUpdated, + newWalletId, + }); + } catch (error) { + console.error("[api/v1/ejection/redirect] Error handling POST:", error); + return res.status(500).json({ error: "Internal Server Error" }); + } +} + + diff --git a/src/utils/validateMultisigImport.ts b/src/utils/validateMultisigImport.ts new file mode 100644 index 00000000..76bcfbe1 --- /dev/null +++ b/src/utils/validateMultisigImport.ts @@ -0,0 +1,382 @@ +import { checkValidAddress, addressToNetwork } from "@/utils/multisigSDK"; +import { serializeRewardAddress, resolveStakeKeyHash } from "@meshsdk/core"; +import type { csl } from "@meshsdk/core-csl"; +import { deserializeNativeScript } from "@meshsdk/core-csl"; + +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; + signerStakeKeys: string[]; + signerAddresses: string[]; + signersDescriptions: string[]; + network?: number; + stakeAddressesUsed: string[]; + paymentAddressesUsed: string[]; +}; + +export type ValidationSuccess = { ok: true; rows: ImportedMultisigRow[]; summary: MultisigImportSummary }; +export type ValidationFailure = { ok: false; status: number; body: Record }; +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 function validateMultisigImportPayload(payload: unknown): ValidationResult { + const rowsUnknown = Array.isArray(payload) + ? (payload as unknown[]) + : Array.isArray((payload as { rows?: unknown })?.rows) + ? (((payload as { rows: unknown }).rows as unknown[])) + : []; + const rows = rowsUnknown as ImportedMultisigRow[]; + + if (!Array.isArray(rows) || rows.length === 0) { + return { + ok: false, + status: 400, + body: { error: "Expected an array of rows or { rows: [...] }" }, + }; + } + + 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 row must include user_id and user_stake_pubkey_hash_hex", + invalidIndexes, + }, + }; + } + + // If provided, all multisig_id values must match + const providedIds = rows + .map((r) => r.multisig_id) + .filter((v): v is string => typeof v === "string" && v.trim().length > 0); + const multisigIds = new Set(providedIds); + if (multisigIds.size > 1) { + return { + ok: false, + status: 400, + body: { + error: "All rows must belong to the same multisig_id", + multisigIds: Array.from(multisigIds), + }, + }; + } + + const multisigId = rows[0]!.multisig_id ?? null; + const multisigName = rows[0]!.multisig_name ?? null; + const multisigAddress = rows[0]!.multisig_address ?? null; + + // Shared field consistency + const base = { + multisig_name: normalize(multisigName), + multisig_address: normalize(multisigAddress), + payment_script: normalize(rows[0]!.payment_script ?? null), + stake_script: normalize(rows[0]!.stake_script ?? null), + } as const; + + const fieldMismatches: Record = {}; + for (let i = 0; i < rows.length; i++) { + const r = rows[i]!; + if (normalize(r.multisig_name) !== base.multisig_name) { + (fieldMismatches.multisig_name ||= []).push(i); + } + if (normalize(r.multisig_address) !== base.multisig_address) { + (fieldMismatches.multisig_address ||= []).push(i); + } + if (normalize(r.payment_script) !== base.payment_script) { + (fieldMismatches.payment_script ||= []).push(i); + } + if (normalize(r.stake_script) !== base.stake_script) { + (fieldMismatches.stake_script ||= []).push(i); + } + } + if (Object.keys(fieldMismatches).length > 0) { + return { + ok: false, + status: 400, + body: { + error: "All rows must share the same multisig_name, multisig_address, payment_script, and stake_script", + fieldMismatches, + }, + }; + } + + // 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, and descriptions; ensure deterministic ordering + type CombinedSigner = { stake: string; address: string; description: string }; + const combined: CombinedSigner[] = []; + const seenStake = new Set(); + const stripTags = (v: string) => v.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() : ""; + const desc = typeof r.community_description === "string" ? stripTags(r.community_description) : ""; + combined.push({ stake: hex, address: addr, description: desc }); + } + const signerStakeKeys = combined.map((c) => c.stake); + const signerAddresses = combined.map((c) => c.address); + const signersDescriptions = combined.map((c) => c.description); + + // 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 = resolveStakeKeyHash(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 + let requiredFromPaymentScript: number | undefined = undefined; + const providedPaymentCbor = typeof rows[0]!.payment_script === "string" ? rows[0]!.payment_script.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); + } catch (e) { + // eslint-disable-next-line no-console + console.warn("Failed to decode provided payment_script CBOR:", e); + } + } + + const stakeAddressesUsed = buildStakeAddressesFromHashes(signerStakeKeys, network); + + return { + ok: true, + rows, + summary: { + multisigId, + multisigName, + multisigAddress, + numRequiredSigners: requiredFromPaymentScript ?? null, + signerStakeKeys, + signerAddresses, + signersDescriptions, + network, + stakeAddressesUsed, + paymentAddressesUsed: signerAddresses, + }, + }; +} + +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 = resolveStakeKeyHash(addr)?.toLowerCase(); + 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[] }; + +function decodeNativeScriptFromCbor(cborHex: string): DecodedNativeScript { + const ns = deserializeNativeScript(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 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 "any": + return script.scripts.length > 0 ? 1 : 0; + case "all": { + let total = 0; + for (const s of script.scripts) total += computeRequiredSigners(s); + return total; + } + case "atLeast": + return Math.max(0, script.required); + default: + return 0; + } +} + + From f14c15260e1da2a79a50803879ab2f82ff9c35e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Wed, 1 Oct 2025 12:52:27 +0200 Subject: [PATCH 02/20] refactor(api): streamline multisig import handling and improve wallet description logic --- src/pages/api/v1/ejection/redirect.ts | 90 +++++++++++++-------------- src/utils/validateMultisigImport.ts | 8 +-- 2 files changed, 47 insertions(+), 51 deletions(-) diff --git a/src/pages/api/v1/ejection/redirect.ts b/src/pages/api/v1/ejection/redirect.ts index 07630a23..dae09c7b 100644 --- a/src/pages/api/v1/ejection/redirect.ts +++ b/src/pages/api/v1/ejection/redirect.ts @@ -24,8 +24,6 @@ export default async function handler( try { const receivedAt = new Date().toISOString(); - const origin = req.headers.origin ?? null; - const userAgent = req.headers["user-agent"] ?? null; const result = validateMultisigImportPayload(req.body); if (!result.ok) { @@ -33,31 +31,32 @@ export default async function handler( } const { summary, rows } = result; - console.log("[api/v1/ejection/redirect] Received multisig import POST:", { - receivedAt, - origin, - userAgent, - query: req.query, - multisigId: summary.multisigId, - multisigName: summary.multisigName, - multisigAddress: summary.multisigAddress, - network: summary.network, - signerCount: summary.signerStakeKeys.length, - signerStakeKeys: summary.signerStakeKeys, - signerAddresses: summary.signerAddresses, - rows: rows, - }); - // Use aligned signersDescriptions from validator (already tag-stripped and ordered) - const signersDescriptions = Array.isArray(summary.signersDescriptions) ? summary.signersDescriptions : []; + // Build wallet description from the first non-empty tagless community_description + function stripTags(v: string) { + return v.replace(/<[^>]*>/g, "").trim(); + } + const walletDescription = (() => { + for (const r of rows) { + const desc = (r as { community_description?: unknown }).community_description; + if (typeof desc === "string" && desc.trim().length > 0) { + return stripTags(desc); + } + } + return ""; + })(); + + // Set each signersDescriptions value to a fixed import message + const signersDescriptions = new Array((summary.signerAddresses || []).length).fill( + "Imported via ejection redirect", + ); // Backfill missing signer payment addresses via stake address lookup const stakeAddresses = Array.isArray(summary.stakeAddressesUsed) ? summary.stakeAddressesUsed : []; const signerAddresses = Array.isArray(summary.signerAddresses) ? summary.signerAddresses : []; type BlockchainProvider = { get: (path: string) => Promise; - fetchAccountInfo?: (stakeAddr: string) => Promise; }; function isRecord(v: unknown): v is Record { return typeof v === "object" && v !== null; @@ -109,42 +108,45 @@ export default async function handler( let dbUpdated = false; let newWalletId: string | null = null; try { - // ownerAddress must strictly be the multisigAddress - const ownerAddress = summary.multisigAddress ?? ""; - if (!ownerAddress) { - return res.status(400).json({ error: "multisigAddress is required as ownerAddress" }); - } + const specifiedId = typeof summary.multisigId === "string" && summary.multisigId.trim().length > 0 + ? summary.multisigId.trim() + : null; - // Find existing wallet by ownerAddress to avoid duplicates - const existing = await db.newWallet.findFirst({ where: { ownerAddress } }); - if (existing) { - const updated = await db.newWallet.update({ - where: { id: existing.id }, - data: { - name: summary.multisigName ?? existing.name ?? "Imported Multisig", - description: "Imported via ejection redirect", + if (specifiedId) { + const saved = await db.newWallet.upsert({ + where: { id: specifiedId }, + update: { + name: summary.multisigName ?? "Imported Multisig", + description: walletDescription, + signersAddresses: paymentAddressesUsed, + signersStakeKeys: summary.stakeAddressesUsed, + signersDescriptions, + numRequiredSigners: summary.numRequiredSigners, + }, + create: { + id: specifiedId, + name: summary.multisigName ?? "Imported Multisig", + description: walletDescription, signersAddresses: paymentAddressesUsed, - signersStakeKeys: summary.signerStakeKeys, + signersStakeKeys: summary.stakeAddressesUsed, signersDescriptions, numRequiredSigners: summary.numRequiredSigners, - ownerAddress, + ownerAddress: "", }, }); - console.log("[api/v1/ejection/redirect] NewWallet update success:", { id: updated.id }); + console.log("[api/v1/ejection/redirect] NewWallet upsert success:", { id: saved.id }); dbUpdated = true; - newWalletId = updated.id; + newWalletId = saved.id; } else { const created = await db.newWallet.create({ data: { - // Let Prisma generate id (cuid()) when multisigId is not present - ...(summary.multisigId ? { id: summary.multisigId } : {}), name: summary.multisigName ?? "Imported Multisig", - description: "Imported via ejection redirect", + description: walletDescription, signersAddresses: paymentAddressesUsed, - signersStakeKeys: summary.signerStakeKeys, + signersStakeKeys: summary.stakeAddressesUsed, signersDescriptions, numRequiredSigners: summary.numRequiredSigners, - ownerAddress, + ownerAddress: "", }, }); console.log("[api/v1/ejection/redirect] NewWallet create success:", { id: created.id }); @@ -161,12 +163,6 @@ export default async function handler( multisigId: summary.multisigId, multisigName: summary.multisigName, multisigAddress: summary.multisigAddress, - network: summary.network, - signerCount: summary.signerStakeKeys.length, - numRequiredSigners: summary.numRequiredSigners, - stakeKeysUsed: summary.stakeAddressesUsed, - paymentKeysUsed: paymentAddressesUsed, - stakeKeyHexes: summary.signerStakeKeys, dbUpdated, newWalletId, }); diff --git a/src/utils/validateMultisigImport.ts b/src/utils/validateMultisigImport.ts index 76bcfbe1..c469c8b8 100644 --- a/src/utils/validateMultisigImport.ts +++ b/src/utils/validateMultisigImport.ts @@ -1,5 +1,5 @@ -import { checkValidAddress, addressToNetwork } from "@/utils/multisigSDK"; -import { serializeRewardAddress, resolveStakeKeyHash } from "@meshsdk/core"; +import { checkValidAddress, addressToNetwork, stakeKeyHash } from "@/utils/multisigSDK"; +import { serializeRewardAddress } from "@meshsdk/core"; import type { csl } from "@meshsdk/core-csl"; import { deserializeNativeScript } from "@meshsdk/core-csl"; @@ -214,7 +214,7 @@ export function validateMultisigImportPayload(payload: unknown): ValidationResul for (const netId of networksToTry) { try { const stakeAddr = serializeRewardAddress(hex, false, netId); - const resolved = resolveStakeKeyHash(stakeAddr)?.toLowerCase(); + const resolved = stakeKeyHash(stakeAddr)?.toLowerCase(); if (resolved === hex) { validForAny = true; break; @@ -288,7 +288,7 @@ function buildStakeAddressesFromHashes(stakeKeys: string[], network: number | un for (const netId of tryNetworks) { try { const addr = serializeRewardAddress(hex, false, netId); - const resolved = resolveStakeKeyHash(addr)?.toLowerCase(); + const resolved = stakeKeyHash(addr)?.toLowerCase(); if (resolved === hex) { chosen = addr; break; From a1fbb97cddc806da51ed17b595ef57107eba4135 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Wed, 1 Oct 2025 14:35:12 +0200 Subject: [PATCH 03/20] refactor(api): simplify signer address retrieval in ejection redirect endpoint --- src/pages/api/v1/ejection/redirect.ts | 59 ++++----------------------- 1 file changed, 8 insertions(+), 51 deletions(-) diff --git a/src/pages/api/v1/ejection/redirect.ts b/src/pages/api/v1/ejection/redirect.ts index dae09c7b..d9166f9f 100644 --- a/src/pages/api/v1/ejection/redirect.ts +++ b/src/pages/api/v1/ejection/redirect.ts @@ -2,8 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { cors, addCorsCacheBustingHeaders } from "@/lib/cors"; import { validateMultisigImportPayload } from "@/utils/validateMultisigImport"; import { db } from "@/server/db"; -import { addressToNetwork } from "@/utils/multisigSDK"; -import { getProvider } from "@/utils/get-provider"; + // Draft endpoint: accepts POST request values and logs them export default async function handler( @@ -52,57 +51,15 @@ export default async function handler( "Imported via ejection redirect", ); - // Backfill missing signer payment addresses via stake address lookup + // Backfill missing signer payment addresses using stake keys instead of fetching const stakeAddresses = Array.isArray(summary.stakeAddressesUsed) ? summary.stakeAddressesUsed : []; const signerAddresses = Array.isArray(summary.signerAddresses) ? summary.signerAddresses : []; - type BlockchainProvider = { - get: (path: string) => Promise; - }; - function isRecord(v: unknown): v is Record { - return typeof v === "object" && v !== null; - } - function normalizeArrayResponse(resp: unknown): unknown[] { - if (Array.isArray(resp)) return resp; - if (isRecord(resp) && Array.isArray(resp.data)) return resp.data as unknown[]; - return []; - } - function extractFirstAddress(arr: unknown[]): string | null { - if (!Array.isArray(arr) || arr.length === 0) return null; - const first = arr[0]; - if (typeof first === "string") return first; - if (isRecord(first)) { - const maybe = (first as { address?: unknown }).address; - if (typeof maybe === "string") return maybe; - } - return null; - } - async function fetchFirstPaymentAddressForStake(stakeAddr: string): Promise { - try { - const network = addressToNetwork(stakeAddr); - const blockchainProvider = getProvider(network) as unknown as BlockchainProvider; - const endpoint = `/accounts/${stakeAddr}/addresses?count=1&order=asc`; - const resp = await blockchainProvider.get(endpoint).catch(() => null); - const arr = normalizeArrayResponse(resp); - const addr = extractFirstAddress(arr); - if (addr) { - return addr; - } - } catch (e) { - console.error("[api/v1/ejection/redirect] fetchFirstPaymentAddressForStake error", e); - } - return null; - } - - const paymentAddressesUsed = await Promise.all( - (signerAddresses || []).map(async (addr, idx) => { - const trimmed = typeof addr === "string" ? addr.trim() : ""; - if (trimmed) return trimmed; - const stakeAddr = stakeAddresses[idx]; - if (!stakeAddr) return ""; - const found = await fetchFirstPaymentAddressForStake(stakeAddr); - return found ?? ""; - }) - ); + const paymentAddressesUsed = (signerAddresses || []).map((addr, idx) => { + const trimmed = typeof addr === "string" ? addr.trim() : ""; + if (trimmed) return trimmed; + const stakeAddr = stakeAddresses[idx]; + return typeof stakeAddr === "string" ? stakeAddr : ""; + }); // Persist to NewWallet using validated data let dbUpdated = false; From 4d828b94d5a8818d1e650af8ad5b5e50cd3e72b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Thu, 2 Oct 2025 11:35:27 +0200 Subject: [PATCH 04/20] refactor(api): update signer descriptions and enhance multisig import summary - Changed signer descriptions to a sequential format ("Signer 1", "Signer 2", etc.) for clarity. - Added support for `signersDRepKeys`, `stakeCredentialHash`, and `scriptType` in the multisig import summary. - Updated the API response to include a generated invite URL for the multisig wallet. --- src/pages/api/v1/ejection/redirect.ts | 23 +++++++++++---- src/pages/api/v1/og.ts | 42 +++++++++++++++++++++++++++ src/utils/validateMultisigImport.ts | 14 +++++++-- 3 files changed, 70 insertions(+), 9 deletions(-) create mode 100644 src/pages/api/v1/og.ts diff --git a/src/pages/api/v1/ejection/redirect.ts b/src/pages/api/v1/ejection/redirect.ts index d9166f9f..83654bf8 100644 --- a/src/pages/api/v1/ejection/redirect.ts +++ b/src/pages/api/v1/ejection/redirect.ts @@ -46,9 +46,9 @@ export default async function handler( return ""; })(); - // Set each signersDescriptions value to a fixed import message - const signersDescriptions = new Array((summary.signerAddresses || []).length).fill( - "Imported via ejection redirect", + // Set each signersDescriptions value to "Signer 1", "Signer 2", etc. + const signersDescriptions = Array.from({ length: (summary.signerAddresses || []).length }, (_, index) => + `Signer ${index + 1}` ); // Backfill missing signer payment addresses using stake keys instead of fetching @@ -77,8 +77,11 @@ export default async function handler( description: walletDescription, signersAddresses: paymentAddressesUsed, signersStakeKeys: summary.stakeAddressesUsed, + signersDRepKeys: [], signersDescriptions, numRequiredSigners: summary.numRequiredSigners, + stakeCredentialHash: null, + scriptType: null, }, create: { id: specifiedId, @@ -86,9 +89,12 @@ export default async function handler( description: walletDescription, signersAddresses: paymentAddressesUsed, signersStakeKeys: summary.stakeAddressesUsed, + signersDRepKeys: [], signersDescriptions, numRequiredSigners: summary.numRequiredSigners, ownerAddress: "", + stakeCredentialHash: null, + scriptType: null, }, }); console.log("[api/v1/ejection/redirect] NewWallet upsert success:", { id: saved.id }); @@ -101,9 +107,12 @@ export default async function handler( description: walletDescription, signersAddresses: paymentAddressesUsed, signersStakeKeys: summary.stakeAddressesUsed, + signersDRepKeys: [], signersDescriptions, numRequiredSigners: summary.numRequiredSigners, ownerAddress: "", + stakeCredentialHash: null, + scriptType: null, }, }); console.log("[api/v1/ejection/redirect] NewWallet create success:", { id: created.id }); @@ -114,14 +123,16 @@ export default async function handler( console.error("[api/v1/ejection/redirect] 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, - multisigId: summary.multisigId, - multisigName: summary.multisigName, multisigAddress: summary.multisigAddress, dbUpdated, - newWalletId, + inviteUrl }); } catch (error) { console.error("[api/v1/ejection/redirect] Error handling POST:", 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 index c469c8b8..b19180f7 100644 --- a/src/utils/validateMultisigImport.ts +++ b/src/utils/validateMultisigImport.ts @@ -35,9 +35,12 @@ export type MultisigImportSummary = { signerStakeKeys: string[]; signerAddresses: string[]; signersDescriptions: string[]; + signersDRepKeys: string[]; network?: number; stakeAddressesUsed: string[]; paymentAddressesUsed: string[]; + stakeCredentialHash?: string | null; + scriptType?: string | null; }; export type ValidationSuccess = { ok: true; rows: ImportedMultisigRow[]; summary: MultisigImportSummary }; @@ -166,8 +169,8 @@ export function validateMultisigImportPayload(payload: unknown): ValidationResul }; } - // Build aligned arrays for signer stake keys, addresses, and descriptions; ensure deterministic ordering - type CombinedSigner = { stake: string; address: string; description: string }; + // Build aligned arrays for signer stake keys, addresses, descriptions, and drep keys; ensure deterministic ordering + type CombinedSigner = { stake: string; address: string; description: string; drepKey: string }; const combined: CombinedSigner[] = []; const seenStake = new Set<string>(); const stripTags = (v: string) => v.replace(/<[^>]*>/g, "").trim(); @@ -178,11 +181,13 @@ export function validateMultisigImportPayload(payload: unknown): ValidationResul seenStake.add(hex); const addr = typeof r.user_address_bech32 === "string" ? r.user_address_bech32.trim() : ""; const desc = typeof r.community_description === "string" ? stripTags(r.community_description) : ""; - combined.push({ stake: hex, address: addr, description: desc }); + const drepKey = ""; // Empty for now as requested + combined.push({ stake: hex, address: addr, description: desc, drepKey }); } const signerStakeKeys = combined.map((c) => c.stake); const signerAddresses = combined.map((c) => c.address); const signersDescriptions = combined.map((c) => c.description); + 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; @@ -273,9 +278,12 @@ export function validateMultisigImportPayload(payload: unknown): ValidationResul signerStakeKeys, signerAddresses, signersDescriptions, + signersDRepKeys, network, stakeAddressesUsed, paymentAddressesUsed: signerAddresses, + stakeCredentialHash: null, // Empty for now as requested + scriptType: null, // Empty for now as requested }, }; } From 7795ad684328bbf5c358cde3c85e3d934911f5c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Mon, 13 Oct 2025 21:47:55 +0200 Subject: [PATCH 05/20] feat(wallet): integrate paymentCbor into wallet data structure - Added paymentCbor field to NewWallet model in schema.prisma. - Updated the ejection redirect API to handle paymentCbor during wallet creation and updates. - Enhanced multisig import validation to include paymentCbor in the import summary. --- .../migration.sql | 14 +++ prisma/schema.prisma | 1 + src/pages/api/v1/ejection/README.md | 98 +++++++++++++++++++ src/pages/api/v1/ejection/redirect.ts | 78 ++++++++------- src/utils/validateMultisigImport.ts | 2 + 5 files changed, 157 insertions(+), 36 deletions(-) create mode 100644 prisma/migrations/20251013175010_add_payment_cbor_to_new_wallet/migration.sql create mode 100644 src/pages/api/v1/ejection/README.md diff --git a/prisma/migrations/20251013175010_add_payment_cbor_to_new_wallet/migration.sql b/prisma/migrations/20251013175010_add_payment_cbor_to_new_wallet/migration.sql new file mode 100644 index 00000000..73be1dba --- /dev/null +++ b/prisma/migrations/20251013175010_add_payment_cbor_to_new_wallet/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - Added the required column `paymentCbor` to the `NewWallet` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropIndex +DROP INDEX "BalanceSnapshot_snapshotDate_idx"; + +-- DropIndex +DROP INDEX "BalanceSnapshot_walletId_idx"; + +-- AlterTable +ALTER TABLE "NewWallet" ADD COLUMN "paymentCbor" TEXT NOT NULL, \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ef466d65..09c01b99 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -78,6 +78,7 @@ model NewWallet { ownerAddress String stakeCredentialHash String? scriptType String? + paymentCbor String } model Nonce { diff --git a/src/pages/api/v1/ejection/README.md b/src/pages/api/v1/ejection/README.md new file mode 100644 index 00000000..861b5049 --- /dev/null +++ b/src/pages/api/v1/ejection/README.md @@ -0,0 +1,98 @@ +### Ejection Redirect API + +Simple import endpoint to create/update a multisig wallet from an external data dump and return an invite URL. + +### Endpoint + +- POST `/api/v1/ejection/redirect` + +### What it does + +- Validates incoming rows for a multisig import. +- Upserts a `NewWallet` with signer data and `paymentCbor` (from `payment_script`). +- Returns the invite URL for the newly imported wallet. + +### Request body + +Send an array of rows. Each row should include: + +- Required per row: + - `user_id` (string) + - `user_stake_pubkey_hash_hex` (56-char lowercase hex) +- Shared across all rows (values must match for every row): + - `multisig_id` + - `multisig_name` + - `multisig_address` (validated) + - `payment_script` (CBOR hex of native script) + - `stake_script` +- Optional per row: + - `user_address_bech32` + - `user_name` + - `user_ada_handle` + - `user_profile_photo_url` + - `user_created_at` + - `community_id` + - `community_name` + - `community_description` + - `community_profile_photo_url` + - `community_verified` + - `community_verified_name` + - `community_created_at` + - `multisig_created_at` + - `multisig_updated_at` + +Minimal example payload: + +```json +[ + { + "multisig_id": "104ce812-bbd1...2ee0a", + "multisig_name": "Team Treasury", + "multisig_address": "addr1...7nj", + "payment_script": "82018183...34", + "stake_script": "82018183...34", + "user_id": "51d3015c-04b1...107eb", + "user_address_bech32": "", + "user_stake_pubkey_hash_hex": "5a4006...5b1" + }, + { + "multisig_id": "104ce812-bbd1...2ee0a", + "multisig_name": "Team Treasury", + "multisig_address": "addr1...7nj", + "payment_script": "82018183...34", + "stake_script": "82018183...34", + "user_id": "97f9d721-7246...76", + "user_address_bech32": "addr1...3zka", + "user_stake_pubkey_hash_hex": "f7f32d1a...9d1" + }, +] +``` + +### Curl example + +```bash +curl -X POST \ + -H "Content-Type: application/json" \ + -d '[ + {"multisig_id":"msig_123","multisig_name":"Team Treasury","multisig_address":"addr1...","payment_script":"4a50...c0","user_id":"u1","user_stake_pubkey_hash_hex":"abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd"}, + {"multisig_id":"msig_123","user_id":"u2","user_stake_pubkey_hash_hex":"1234123412341234123412341234123412341234123412341234"} + ]' \ + https://multisig.meshjs.dev/api/v1/ejection/redirect +``` + +### Response + +```json +{ + "ok": true, + "receivedAt": "2025-10-13T17:50:10.123Z", + "multisigAddress": "addr1...", + "dbUpdated": true, + "inviteUrl": "https://multisig.meshjs.dev/wallets/invite/<newWalletId>" +} +``` + +### Notes + +- CORS is enabled; `OPTIONS` requests return 200. + diff --git a/src/pages/api/v1/ejection/redirect.ts b/src/pages/api/v1/ejection/redirect.ts index 83654bf8..5ff14ed0 100644 --- a/src/pages/api/v1/ejection/redirect.ts +++ b/src/pages/api/v1/ejection/redirect.ts @@ -70,50 +70,56 @@ export default async function handler( : null; if (specifiedId) { + const updateData: any = { + name: summary.multisigName ?? "Imported Multisig", + description: walletDescription, + signersAddresses: paymentAddressesUsed, + signersStakeKeys: summary.stakeAddressesUsed, + signersDRepKeys: [], + signersDescriptions, + numRequiredSigners: summary.numRequiredSigners, + stakeCredentialHash: null, + scriptType: null, + paymentCbor: summary.paymentCbor ?? "", + }; + const createDataWithId: any = { + id: specifiedId, + name: summary.multisigName ?? "Imported Multisig", + description: walletDescription, + signersAddresses: paymentAddressesUsed, + signersStakeKeys: summary.stakeAddressesUsed, + signersDRepKeys: [], + signersDescriptions, + numRequiredSigners: summary.numRequiredSigners, + ownerAddress: "", + stakeCredentialHash: null, + scriptType: null, + paymentCbor: summary.paymentCbor ?? "", + }; const saved = await db.newWallet.upsert({ where: { id: specifiedId }, - update: { - name: summary.multisigName ?? "Imported Multisig", - description: walletDescription, - signersAddresses: paymentAddressesUsed, - signersStakeKeys: summary.stakeAddressesUsed, - signersDRepKeys: [], - signersDescriptions, - numRequiredSigners: summary.numRequiredSigners, - stakeCredentialHash: null, - scriptType: null, - }, - create: { - id: specifiedId, - name: summary.multisigName ?? "Imported Multisig", - description: walletDescription, - signersAddresses: paymentAddressesUsed, - signersStakeKeys: summary.stakeAddressesUsed, - signersDRepKeys: [], - signersDescriptions, - numRequiredSigners: summary.numRequiredSigners, - ownerAddress: "", - stakeCredentialHash: null, - scriptType: null, - }, + update: updateData, + create: createDataWithId, }); console.log("[api/v1/ejection/redirect] 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: "", + stakeCredentialHash: null, + scriptType: null, + paymentCbor: summary.paymentCbor ?? "", + }; const created = await db.newWallet.create({ - data: { - name: summary.multisigName ?? "Imported Multisig", - description: walletDescription, - signersAddresses: paymentAddressesUsed, - signersStakeKeys: summary.stakeAddressesUsed, - signersDRepKeys: [], - signersDescriptions, - numRequiredSigners: summary.numRequiredSigners, - ownerAddress: "", - stakeCredentialHash: null, - scriptType: null, - }, + data: createData, }); console.log("[api/v1/ejection/redirect] NewWallet create success:", { id: created.id }); dbUpdated = true; diff --git a/src/utils/validateMultisigImport.ts b/src/utils/validateMultisigImport.ts index b19180f7..6677114c 100644 --- a/src/utils/validateMultisigImport.ts +++ b/src/utils/validateMultisigImport.ts @@ -32,6 +32,7 @@ export type MultisigImportSummary = { multisigName: string | null; multisigAddress: string | null; numRequiredSigners?: number | null; + paymentCbor: string; signerStakeKeys: string[]; signerAddresses: string[]; signersDescriptions: string[]; @@ -275,6 +276,7 @@ export function validateMultisigImportPayload(payload: unknown): ValidationResul multisigName, multisigAddress, numRequiredSigners: requiredFromPaymentScript ?? null, + paymentCbor: providedPaymentCbor ?? "", signerStakeKeys, signerAddresses, signersDescriptions, From 7771e5f802f644ef52533985c8034cc212012ad8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Mon, 13 Oct 2025 22:03:13 +0200 Subject: [PATCH 06/20] fix(wallet): make paymentCbor field optional in NewWallet model - Updated the paymentCbor field in the NewWallet model to be optional in schema.prisma. - Removed the migration file that previously enforced paymentCbor as a required field. --- .../migration.sql | 14 -------------- .../migration.sql | 9 +++++++++ prisma/schema.prisma | 2 +- 3 files changed, 10 insertions(+), 15 deletions(-) delete mode 100644 prisma/migrations/20251013175010_add_payment_cbor_to_new_wallet/migration.sql create mode 100644 prisma/migrations/20251013200000_add_payment_cbor_to_new_wallet/migration.sql diff --git a/prisma/migrations/20251013175010_add_payment_cbor_to_new_wallet/migration.sql b/prisma/migrations/20251013175010_add_payment_cbor_to_new_wallet/migration.sql deleted file mode 100644 index 73be1dba..00000000 --- a/prisma/migrations/20251013175010_add_payment_cbor_to_new_wallet/migration.sql +++ /dev/null @@ -1,14 +0,0 @@ -/* - Warnings: - - - Added the required column `paymentCbor` to the `NewWallet` table without a default value. This is not possible if the table is not empty. - -*/ --- DropIndex -DROP INDEX "BalanceSnapshot_snapshotDate_idx"; - --- DropIndex -DROP INDEX "BalanceSnapshot_walletId_idx"; - --- AlterTable -ALTER TABLE "NewWallet" ADD COLUMN "paymentCbor" TEXT NOT NULL, \ No newline at end of file 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..3b49499d --- /dev/null +++ b/prisma/migrations/20251013200000_add_payment_cbor_to_new_wallet/migration.sql @@ -0,0 +1,9 @@ +-- DropIndex +DROP INDEX "BalanceSnapshot_snapshotDate_idx"; + +-- DropIndex +DROP INDEX "BalanceSnapshot_walletId_idx"; + +-- AlterTable +ALTER TABLE "NewWallet" ADD COLUMN "paymentCbor" TEXT, + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 09c01b99..2324e56f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -78,7 +78,7 @@ model NewWallet { ownerAddress String stakeCredentialHash String? scriptType String? - paymentCbor String + paymentCbor String? } model Nonce { From d37e8f33cce5c377482d966eb94ca7df31ab4fec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Tue, 14 Oct 2025 07:09:12 +0200 Subject: [PATCH 07/20] docs(ejection): update README to remove optional user and community fields - Removed optional fields related to user and community information from the ejection API documentation to streamline the payload requirements. --- src/pages/api/v1/ejection/README.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/pages/api/v1/ejection/README.md b/src/pages/api/v1/ejection/README.md index 861b5049..42dec44c 100644 --- a/src/pages/api/v1/ejection/README.md +++ b/src/pages/api/v1/ejection/README.md @@ -27,19 +27,7 @@ Send an array of rows. Each row should include: - `stake_script` - Optional per row: - `user_address_bech32` - - `user_name` - - `user_ada_handle` - - `user_profile_photo_url` - - `user_created_at` - - `community_id` - - `community_name` - `community_description` - - `community_profile_photo_url` - - `community_verified` - - `community_verified_name` - - `community_created_at` - - `multisig_created_at` - - `multisig_updated_at` Minimal example payload: From 2cab4ecd4deb038ef4b3d55258ade329172aedef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Tue, 14 Oct 2025 12:57:33 +0200 Subject: [PATCH 08/20] feat(wallet): enhance NewWallet model and API to support additional CBOR fields - Added `stakeCbor` and `usesStored` fields to the NewWallet model in schema.prisma. - Updated the migration script to include the new `paymentCbor` field correctly. - Modified the ejection redirect API to handle `stakeCbor` and `usesStored` during wallet creation and updates. - Enhanced multisig import validation to incorporate the new fields in the import summary. --- .../migration.sql | 3 +- .../migration.sql | 3 + prisma/schema.prisma | 2 + src/pages/api/v1/ejection/redirect.ts | 25 ++-- src/utils/validateMultisigImport.ts | 123 +++++++++++++++++- 5 files changed, 136 insertions(+), 20 deletions(-) create mode 100644 prisma/migrations/20251014101325_add_stake_cbor_to_new_wallet/migration.sql 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 index 3b49499d..091ab75e 100644 --- a/prisma/migrations/20251013200000_add_payment_cbor_to_new_wallet/migration.sql +++ b/prisma/migrations/20251013200000_add_payment_cbor_to_new_wallet/migration.sql @@ -5,5 +5,4 @@ DROP INDEX "BalanceSnapshot_snapshotDate_idx"; DROP INDEX "BalanceSnapshot_walletId_idx"; -- AlterTable -ALTER TABLE "NewWallet" ADD COLUMN "paymentCbor" TEXT, - +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/schema.prisma b/prisma/schema.prisma index 2324e56f..03bed1fa 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -78,7 +78,9 @@ model NewWallet { ownerAddress String stakeCredentialHash String? scriptType String? + usesStored Boolean @default(false) paymentCbor String? + stakeCbor String? } model Nonce { diff --git a/src/pages/api/v1/ejection/redirect.ts b/src/pages/api/v1/ejection/redirect.ts index 5ff14ed0..7684955f 100644 --- a/src/pages/api/v1/ejection/redirect.ts +++ b/src/pages/api/v1/ejection/redirect.ts @@ -51,15 +51,10 @@ export default async function handler( `Signer ${index + 1}` ); - // Backfill missing signer payment addresses using stake keys instead of fetching - const stakeAddresses = Array.isArray(summary.stakeAddressesUsed) ? summary.stakeAddressesUsed : []; - const signerAddresses = Array.isArray(summary.signerAddresses) ? summary.signerAddresses : []; - const paymentAddressesUsed = (signerAddresses || []).map((addr, idx) => { - const trimmed = typeof addr === "string" ? addr.trim() : ""; - if (trimmed) return trimmed; - const stakeAddr = stakeAddresses[idx]; - return typeof stakeAddr === "string" ? stakeAddr : ""; - }); + // Use signer payment addresses as provided; leave empty string if missing + const paymentAddressesUsed = Array.isArray(summary.signerAddresses) + ? summary.signerAddresses.map((addr) => (typeof addr === "string" ? addr.trim() : "")) + : []; // Persist to NewWallet using validated data let dbUpdated = false; @@ -79,8 +74,10 @@ export default async function handler( signersDescriptions, numRequiredSigners: summary.numRequiredSigners, stakeCredentialHash: null, - scriptType: null, + scriptType: summary.scriptType ?? null, paymentCbor: summary.paymentCbor ?? "", + stakeCbor: summary.stakeCbor ?? "", + usesStored: Boolean(summary.usesStored), }; const createDataWithId: any = { id: specifiedId, @@ -93,8 +90,10 @@ export default async function handler( numRequiredSigners: summary.numRequiredSigners, ownerAddress: "", stakeCredentialHash: null, - scriptType: null, + scriptType: summary.scriptType ?? null, paymentCbor: summary.paymentCbor ?? "", + stakeCbor: summary.stakeCbor ?? "", + usesStored: Boolean(summary.usesStored), }; const saved = await db.newWallet.upsert({ where: { id: specifiedId }, @@ -115,8 +114,10 @@ export default async function handler( numRequiredSigners: summary.numRequiredSigners, ownerAddress: "", stakeCredentialHash: null, - scriptType: null, + scriptType: summary.scriptType ?? null, paymentCbor: summary.paymentCbor ?? "", + stakeCbor: summary.stakeCbor ?? "", + usesStored: Boolean(summary.usesStored), }; const created = await db.newWallet.create({ data: createData, diff --git a/src/utils/validateMultisigImport.ts b/src/utils/validateMultisigImport.ts index 6677114c..2885aec6 100644 --- a/src/utils/validateMultisigImport.ts +++ b/src/utils/validateMultisigImport.ts @@ -33,6 +33,7 @@ export type MultisigImportSummary = { multisigAddress: string | null; numRequiredSigners?: number | null; paymentCbor: string; + stakeCbor: string; signerStakeKeys: string[]; signerAddresses: string[]; signersDescriptions: string[]; @@ -42,6 +43,7 @@ export type MultisigImportSummary = { paymentAddressesUsed: string[]; stakeCredentialHash?: string | null; scriptType?: string | null; + usesStored: boolean; }; export type ValidationSuccess = { ok: true; rows: ImportedMultisigRow[]; summary: MultisigImportSummary }; @@ -250,8 +252,10 @@ export function validateMultisigImportPayload(payload: unknown): ValidationResul }; } - // If a payment_script CBOR is provided, attempt to decode it to extract the required signers count + // 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; const providedPaymentCbor = typeof rows[0]!.payment_script === "string" ? rows[0]!.payment_script.trim() : null; if (providedPaymentCbor) { try { @@ -260,12 +264,31 @@ export function validateMultisigImportPayload(payload: unknown): ValidationResul // 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); } 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 rows[0]!.stake_script === "string" ? rows[0]!.stake_script.trim() : null; + let isStakeHierarchical = false; + 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); + } catch (e) { + // eslint-disable-next-line no-console + console.warn("Failed to decode provided stake_script CBOR:", e); + } + } + const stakeAddressesUsed = buildStakeAddressesFromHashes(signerStakeKeys, network); return { @@ -277,6 +300,7 @@ export function validateMultisigImportPayload(payload: unknown): ValidationResul multisigAddress, numRequiredSigners: requiredFromPaymentScript ?? null, paymentCbor: providedPaymentCbor ?? "", + stakeCbor: providedStakeCbor ?? "", signerStakeKeys, signerAddresses, signersDescriptions, @@ -285,11 +309,45 @@ export function validateMultisigImportPayload(payload: unknown): ValidationResul stakeAddressesUsed, paymentAddressesUsed: signerAddresses, stakeCredentialHash: null, // Empty for now as requested - scriptType: null, // Empty for now as requested + scriptType: scriptTypeFromPaymentScript ?? null, + usesStored: Boolean(isPaymentHierarchical || isStakeHierarchical), }, }; } +// 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) { @@ -318,10 +376,20 @@ type DecodedNativeScript = | { type: "sig"; keyHash: string } | { type: "all"; scripts: DecodedNativeScript[] } | { type: "any"; scripts: DecodedNativeScript[] } - | { type: "atLeast"; required: number; scripts: DecodedNativeScript[] }; + | { type: "atLeast"; required: number; scripts: DecodedNativeScript[] } + | { type: "timelockStart" } + | { type: "timelockExpiry" }; + +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(cborHex); + const ns = deserializeNativeScript(normalizeCborHex(cborHex)); return decodeNativeScriptFromCsl(ns); } @@ -332,6 +400,16 @@ function decodeNativeScriptFromCsl(ns: csl.NativeScript): DecodedNativeScript { 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(); @@ -375,18 +453,51 @@ function computeRequiredSigners(script: DecodedNativeScript): number { switch (script.type) { case "sig": return 1; + case "timelockStart": + case "timelockExpiry": + return 0; case "any": - return script.scripts.length > 0 ? 1 : 0; + 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": - return Math.max(0, script.required); + 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; +} + From 9976a24fa53eeab7d35dd2f8661064edd6554750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Wed, 15 Oct 2025 10:05:46 +0200 Subject: [PATCH 09/20] feat(multisig): enhance validation to support payment and stake signature matching - Added functionality to collect and match signature key hashes for both payment and stake scripts during multisig import validation. - Introduced new `sigMatches` field in the import summary to provide detailed results of signature matches. - Updated the validation logic to reorder signer addresses based on payment script key hash order. --- src/utils/validateMultisigImport.ts | 128 +++++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 3 deletions(-) diff --git a/src/utils/validateMultisigImport.ts b/src/utils/validateMultisigImport.ts index 2885aec6..63f5ad04 100644 --- a/src/utils/validateMultisigImport.ts +++ b/src/utils/validateMultisigImport.ts @@ -1,4 +1,4 @@ -import { checkValidAddress, addressToNetwork, stakeKeyHash } from "@/utils/multisigSDK"; +import { checkValidAddress, addressToNetwork, stakeKeyHash, paymentKeyHash } from "@/utils/multisigSDK"; import { serializeRewardAddress } from "@meshsdk/core"; import type { csl } from "@meshsdk/core-csl"; import { deserializeNativeScript } from "@meshsdk/core-csl"; @@ -44,6 +44,10 @@ export type MultisigImportSummary = { stakeCredentialHash?: string | null; scriptType?: string | null; usesStored: boolean; + sigMatches?: { + payment: SigMatch[]; + stake: SigMatch[]; + }; }; export type ValidationSuccess = { ok: true; rows: ImportedMultisigRow[]; summary: MultisigImportSummary }; @@ -256,6 +260,8 @@ export function validateMultisigImportPayload(payload: unknown): ValidationResul let requiredFromPaymentScript: number | undefined = undefined; let scriptTypeFromPaymentScript: "all" | "any" | "atLeast" | undefined = undefined; let isPaymentHierarchical = false; + let paymentSigKeyHashes: string[] = []; + let paymentSigMatches: SigMatch[] = []; const providedPaymentCbor = typeof rows[0]!.payment_script === "string" ? rows[0]!.payment_script.trim() : null; if (providedPaymentCbor) { try { @@ -268,6 +274,12 @@ export function validateMultisigImportPayload(payload: unknown): ValidationResul // 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); + paymentSigMatches = matchPaymentSigs(paymentSigKeyHashes, signerAddresses); + // 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); @@ -277,12 +289,20 @@ export function validateMultisigImportPayload(payload: unknown): ValidationResul // If a stake_script CBOR is provided, attempt to decode it and log for visibility const providedStakeCbor = typeof rows[0]!.stake_script === "string" ? rows[0]!.stake_script.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); @@ -291,6 +311,24 @@ export function validateMultisigImportPayload(payload: unknown): ValidationResul const stakeAddressesUsed = buildStakeAddressesFromHashes(signerStakeKeys, network); + // Reorder signer addresses to match payment script key hash order; fill gaps with key hashes + let signerAddressesOrdered: string[] = signerAddresses; + 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; + }); + } + return { ok: true, rows, @@ -302,15 +340,19 @@ export function validateMultisigImportPayload(payload: unknown): ValidationResul paymentCbor: providedPaymentCbor ?? "", stakeCbor: providedStakeCbor ?? "", signerStakeKeys, - signerAddresses, + signerAddresses: signerAddressesOrdered, signersDescriptions, signersDRepKeys, network, stakeAddressesUsed, - paymentAddressesUsed: signerAddresses, + paymentAddressesUsed: signerAddressesOrdered, stakeCredentialHash: null, // Empty for now as requested scriptType: scriptTypeFromPaymentScript ?? null, usesStored: Boolean(isPaymentHierarchical || isStakeHierarchical), + sigMatches: { + payment: paymentSigMatches, + stake: stakeSigMatches, + }, }, }; } @@ -380,6 +422,15 @@ type 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")) { @@ -500,4 +551,75 @@ function hasSigWithLogicalDepth(node: DecodedNativeScript, logicalDepth: number) 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; +} + +// 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; +} + From d656e85d7f688a09fb01b13b87758ea4ab866201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Wed, 15 Oct 2025 11:59:32 +0200 Subject: [PATCH 10/20] feat(multisig): improve stake key ordering and address resolution in validation - Enhanced the validation logic to order signer stake keys based on payment script key hash order when necessary. - Implemented logic to build reward addresses for matched stake keys, falling back to key hashes when address construction fails. - Updated the return structure to reflect the new ordering of signer stake keys and resolved stake addresses used. --- src/utils/validateMultisigImport.ts | 56 +++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/src/utils/validateMultisigImport.ts b/src/utils/validateMultisigImport.ts index 63f5ad04..60535360 100644 --- a/src/utils/validateMultisigImport.ts +++ b/src/utils/validateMultisigImport.ts @@ -329,6 +329,58 @@ export function validateMultisigImportPayload(payload: unknown): ValidationResul }); } + // 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 ((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, @@ -339,12 +391,12 @@ export function validateMultisigImportPayload(payload: unknown): ValidationResul numRequiredSigners: requiredFromPaymentScript ?? null, paymentCbor: providedPaymentCbor ?? "", stakeCbor: providedStakeCbor ?? "", - signerStakeKeys, + signerStakeKeys: signerStakeKeysOrdered, signerAddresses: signerAddressesOrdered, signersDescriptions, signersDRepKeys, network, - stakeAddressesUsed, + stakeAddressesUsed: stakeAddressesUsedFinal, paymentAddressesUsed: signerAddressesOrdered, stakeCredentialHash: null, // Empty for now as requested scriptType: scriptTypeFromPaymentScript ?? null, From ea8cf40e86b486552974cedff6789a65b655fa69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Thu, 16 Oct 2025 10:20:09 +0200 Subject: [PATCH 11/20] feat(multisig): update validation to support asynchronous payload processing and expanded address matching - Changed `validateMultisigImportPayload` to be asynchronous, allowing for improved handling of address resolution. - Introduced `matchPaymentSigsExpanded` to fetch additional payment addresses from stake accounts when necessary. - Enhanced the logic for processing signer addresses to ensure proper matching with required key hashes. --- src/pages/api/v1/ejection/redirect.ts | 4 +- src/utils/validateMultisigImport.ts | 155 +++++++++++++++++++++++++- 2 files changed, 154 insertions(+), 5 deletions(-) diff --git a/src/pages/api/v1/ejection/redirect.ts b/src/pages/api/v1/ejection/redirect.ts index 7684955f..82899e35 100644 --- a/src/pages/api/v1/ejection/redirect.ts +++ b/src/pages/api/v1/ejection/redirect.ts @@ -24,7 +24,7 @@ export default async function handler( try { const receivedAt = new Date().toISOString(); - const result = validateMultisigImportPayload(req.body); + const result = await validateMultisigImportPayload(req.body); if (!result.ok) { return res.status(result.status).json(result.body); } @@ -53,7 +53,7 @@ export default async function handler( // Use signer payment addresses as provided; leave empty string if missing const paymentAddressesUsed = Array.isArray(summary.signerAddresses) - ? summary.signerAddresses.map((addr) => (typeof addr === "string" ? addr.trim() : "")) + ? summary.signerAddresses.map((addr: string) => (typeof addr === "string" ? addr.trim() : "")) : []; // Persist to NewWallet using validated data diff --git a/src/utils/validateMultisigImport.ts b/src/utils/validateMultisigImport.ts index 60535360..9938462e 100644 --- a/src/utils/validateMultisigImport.ts +++ b/src/utils/validateMultisigImport.ts @@ -1,7 +1,8 @@ import { checkValidAddress, addressToNetwork, stakeKeyHash, paymentKeyHash } from "@/utils/multisigSDK"; -import { serializeRewardAddress } from "@meshsdk/core"; +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; @@ -57,7 +58,7 @@ 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 function validateMultisigImportPayload(payload: unknown): ValidationResult { +export async function validateMultisigImportPayload(payload: unknown): Promise<ValidationResult> { const rowsUnknown = Array.isArray(payload) ? (payload as unknown[]) : Array.isArray((payload as { rows?: unknown })?.rows) @@ -277,7 +278,15 @@ export function validateMultisigImportPayload(payload: unknown): ValidationResul paymentSigKeyHashes = collectSigKeyHashes(decoded); // eslint-disable-next-line no-console console.log("Payment script sig key hashes:", paymentSigKeyHashes); - paymentSigMatches = matchPaymentSigs(paymentSigKeyHashes, signerAddresses); + // 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) { @@ -409,6 +418,29 @@ export function validateMultisigImportPayload(payload: unknown): ValidationResul }; } +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" @@ -451,6 +483,7 @@ function buildStakeAddressesFromHashes(stakeKeys: string[], network: number | un 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; @@ -649,6 +682,122 @@ function matchPaymentSigs(sigKeyHashes: string[], signerAddresses: string[]): Si 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>(); From ec29fa3d110f59c8cd77c2b75a98952b1a2168a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Thu, 16 Oct 2025 13:26:20 +0200 Subject: [PATCH 12/20] docs(ejection): update README and redirect API for multisig wallet enhancements - Expanded README to include new `stakeCbor` field in the multisig wallet upsert process. - Added notes on handling `multisig_id`, community description, and database write failures. - Updated redirect API to normalize request body for signersDescriptions, ensuring consistent handling of input data. --- src/pages/api/v1/ejection/README.md | 5 ++++- src/pages/api/v1/ejection/redirect.ts | 16 ++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/pages/api/v1/ejection/README.md b/src/pages/api/v1/ejection/README.md index 42dec44c..79464153 100644 --- a/src/pages/api/v1/ejection/README.md +++ b/src/pages/api/v1/ejection/README.md @@ -9,7 +9,7 @@ Simple import endpoint to create/update a multisig wallet from an external data ### What it does - Validates incoming rows for a multisig import. -- Upserts a `NewWallet` with signer data and `paymentCbor` (from `payment_script`). +- Upserts a `NewWallet` with signer data, `paymentCbor` (from `payment_script`), and `stakeCbor` (from `stake_script`). - Returns the invite URL for the newly imported wallet. ### Request body @@ -83,4 +83,7 @@ curl -X POST \ ### Notes - CORS is enabled; `OPTIONS` requests return 200. +- If a `multisig_id` is supplied, the wallet is upserted with that id; otherwise a new id is created. +- The first non-empty `community_description` is used for the wallet description (HTML tags are stripped). +- If the database write fails, `dbUpdated` will be `false` and `inviteUrl` will be `null`. diff --git a/src/pages/api/v1/ejection/redirect.ts b/src/pages/api/v1/ejection/redirect.ts index 82899e35..17225eb9 100644 --- a/src/pages/api/v1/ejection/redirect.ts +++ b/src/pages/api/v1/ejection/redirect.ts @@ -30,8 +30,12 @@ export default async function handler( } 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 stripTags(v: string) { return v.replace(/<[^>]*>/g, "").trim(); @@ -46,10 +50,10 @@ export default async function handler( return ""; })(); - // Set each signersDescriptions value to "Signer 1", "Signer 2", etc. - const signersDescriptions = Array.from({ length: (summary.signerAddresses || []).length }, (_, index) => - `Signer ${index + 1}` - ); + // Build signersDescriptions from the request body array; stringify non-strings to ensure consistency + const signersDescriptions = bodyAsArray.map((item) => + typeof item === "string" ? item : JSON.stringify(item) + ); // Use signer payment addresses as provided; leave empty string if missing const paymentAddressesUsed = Array.isArray(summary.signerAddresses) From 484b2e34544fd79b49c0489bfa9c7a15382deab0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Fri, 17 Oct 2025 10:06:40 +0200 Subject: [PATCH 13/20] feat(multisig): enhance validation and API to support user names and raw import bodies - Added `user_name` field to the ejection API documentation and payload structure. - Updated the `NewWallet` model to include `rawImportBodies` for improved data handling. - Modified the validation logic to incorporate user names in the signer descriptions, ensuring consistent data processing. --- .../migration.sql | 2 ++ prisma/schema.prisma | 1 + src/pages/api/v1/ejection/README.md | 3 +++ src/pages/api/v1/ejection/redirect.ts | 16 +++++++++---- src/utils/validateMultisigImport.ts | 23 ++++++++++++++----- 5 files changed, 35 insertions(+), 10 deletions(-) create mode 100644 prisma/migrations/20251017075609_add_raw_import_bodies_to_new_wallet/migration.sql 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 03bed1fa..feccdec8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -81,6 +81,7 @@ model NewWallet { usesStored Boolean @default(false) paymentCbor String? stakeCbor String? + rawImportBodies Json? } model Nonce { diff --git a/src/pages/api/v1/ejection/README.md b/src/pages/api/v1/ejection/README.md index 79464153..5a104789 100644 --- a/src/pages/api/v1/ejection/README.md +++ b/src/pages/api/v1/ejection/README.md @@ -27,6 +27,7 @@ Send an array of rows. Each row should include: - `stake_script` - Optional per row: - `user_address_bech32` + - `user_name` - `community_description` Minimal example payload: @@ -40,6 +41,7 @@ Minimal example payload: "payment_script": "82018183...34", "stake_script": "82018183...34", "user_id": "51d3015c-04b1...107eb", + "user_name": "Bob", "user_address_bech32": "", "user_stake_pubkey_hash_hex": "5a4006...5b1" }, @@ -50,6 +52,7 @@ Minimal example payload: "payment_script": "82018183...34", "stake_script": "82018183...34", "user_id": "97f9d721-7246...76", + "user_name": "Carol", "user_address_bech32": "addr1...3zka", "user_stake_pubkey_hash_hex": "f7f32d1a...9d1" }, diff --git a/src/pages/api/v1/ejection/redirect.ts b/src/pages/api/v1/ejection/redirect.ts index 17225eb9..49b6e385 100644 --- a/src/pages/api/v1/ejection/redirect.ts +++ b/src/pages/api/v1/ejection/redirect.ts @@ -50,16 +50,21 @@ export default async function handler( return ""; })(); - // Build signersDescriptions from the request body array; stringify non-strings to ensure consistency - const signersDescriptions = bodyAsArray.map((item) => - typeof item === "string" ? item : JSON.stringify(item) - ); + // 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() : "")) : []; + // Prepare raw import bodies to persist: prefer rows if provided, otherwise normalize req.body + const rawImportBodies = Array.isArray((req.body as any)?.rows) + ? (req.body as any).rows + : Array.isArray(req.body) + ? req.body + : [req.body]; + // Persist to NewWallet using validated data let dbUpdated = false; let newWalletId: string | null = null; @@ -82,6 +87,7 @@ export default async function handler( paymentCbor: summary.paymentCbor ?? "", stakeCbor: summary.stakeCbor ?? "", usesStored: Boolean(summary.usesStored), + rawImportBodies, }; const createDataWithId: any = { id: specifiedId, @@ -98,6 +104,7 @@ export default async function handler( paymentCbor: summary.paymentCbor ?? "", stakeCbor: summary.stakeCbor ?? "", usesStored: Boolean(summary.usesStored), + rawImportBodies, }; const saved = await db.newWallet.upsert({ where: { id: specifiedId }, @@ -122,6 +129,7 @@ export default async function handler( paymentCbor: summary.paymentCbor ?? "", stakeCbor: summary.stakeCbor ?? "", usesStored: Boolean(summary.usesStored), + rawImportBodies, }; const created = await db.newWallet.create({ data: createData, diff --git a/src/utils/validateMultisigImport.ts b/src/utils/validateMultisigImport.ts index 9938462e..ad5152c4 100644 --- a/src/utils/validateMultisigImport.ts +++ b/src/utils/validateMultisigImport.ts @@ -177,8 +177,8 @@ export async function validateMultisigImportPayload(payload: unknown): Promise<V }; } - // Build aligned arrays for signer stake keys, addresses, descriptions, and drep keys; ensure deterministic ordering - type CombinedSigner = { stake: string; address: string; description: string; drepKey: string }; + // 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 stripTags = (v: string) => v.replace(/<[^>]*>/g, "").trim(); @@ -188,13 +188,14 @@ export async function validateMultisigImportPayload(payload: unknown): Promise<V if (!hex || seenStake.has(hex)) continue; seenStake.add(hex); const addr = typeof r.user_address_bech32 === "string" ? r.user_address_bech32.trim() : ""; - const desc = typeof r.community_description === "string" ? stripTags(r.community_description) : ""; + // Use user_name for descriptions as requested + const name = typeof r.user_name === "string" ? r.user_name.trim() : ""; const drepKey = ""; // Empty for now as requested - combined.push({ stake: hex, address: addr, description: desc, drepKey }); + combined.push({ stake: hex, address: addr, name, drepKey }); } const signerStakeKeys = combined.map((c) => c.stake); const signerAddresses = combined.map((c) => c.address); - const signersDescriptions = combined.map((c) => c.description); + 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 @@ -322,6 +323,7 @@ export async function validateMultisigImportPayload(payload: unknown): Promise<V // 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) { @@ -336,6 +338,15 @@ export async function validateMultisigImportPayload(payload: unknown): Promise<V // 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: @@ -402,7 +413,7 @@ export async function validateMultisigImportPayload(payload: unknown): Promise<V stakeCbor: providedStakeCbor ?? "", signerStakeKeys: signerStakeKeysOrdered, signerAddresses: signerAddressesOrdered, - signersDescriptions, + signersDescriptions: signersDescriptionsOrdered, signersDRepKeys, network, stakeAddressesUsed: stakeAddressesUsedFinal, From b2a033a03ded83f87506916aca53090efd93991e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Fri, 17 Oct 2025 10:14:15 +0200 Subject: [PATCH 14/20] fix(ejection): standardize ownerAddress in redirect API to "all" - Updated the ownerAddress field in the ejection redirect API to consistently use "all" across multiple instances, ensuring uniformity in the request payload. --- src/pages/api/v1/ejection/redirect.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pages/api/v1/ejection/redirect.ts b/src/pages/api/v1/ejection/redirect.ts index 49b6e385..6c91ae19 100644 --- a/src/pages/api/v1/ejection/redirect.ts +++ b/src/pages/api/v1/ejection/redirect.ts @@ -82,6 +82,7 @@ export default async function handler( signersDRepKeys: [], signersDescriptions, numRequiredSigners: summary.numRequiredSigners, + ownerAddress: "all", stakeCredentialHash: null, scriptType: summary.scriptType ?? null, paymentCbor: summary.paymentCbor ?? "", @@ -98,7 +99,7 @@ export default async function handler( signersDRepKeys: [], signersDescriptions, numRequiredSigners: summary.numRequiredSigners, - ownerAddress: "", + ownerAddress: "all", stakeCredentialHash: null, scriptType: summary.scriptType ?? null, paymentCbor: summary.paymentCbor ?? "", @@ -123,7 +124,7 @@ export default async function handler( signersDRepKeys: [], signersDescriptions, numRequiredSigners: summary.numRequiredSigners, - ownerAddress: "", + ownerAddress: "all", stakeCredentialHash: null, scriptType: summary.scriptType ?? null, paymentCbor: summary.paymentCbor ?? "", From a7af71ee9eb333c2434c2b697573b74e94064b45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Mon, 20 Oct 2025 07:21:38 +0200 Subject: [PATCH 15/20] refactor(ejection): remove deprecated redirect API and documentation - Deleted the ejection redirect API implementation and its associated README documentation as part of a cleanup effort. - Updated validation logic to accommodate new payload structure for multisig imports, ensuring compatibility with the latest API design. --- src/pages/api/v1/ejection/README.md | 92 -------------- src/pages/api/v1/import/README.md | 117 ++++++++++++++++++ .../redirect.ts => import/summon.ts} | 21 ++-- src/utils/validateMultisigImport.ts | 110 +++++++--------- 4 files changed, 174 insertions(+), 166 deletions(-) delete mode 100644 src/pages/api/v1/ejection/README.md create mode 100644 src/pages/api/v1/import/README.md rename src/pages/api/v1/{ejection/redirect.ts => import/summon.ts} (89%) diff --git a/src/pages/api/v1/ejection/README.md b/src/pages/api/v1/ejection/README.md deleted file mode 100644 index 5a104789..00000000 --- a/src/pages/api/v1/ejection/README.md +++ /dev/null @@ -1,92 +0,0 @@ -### Ejection Redirect API - -Simple import endpoint to create/update a multisig wallet from an external data dump and return an invite URL. - -### Endpoint - -- POST `/api/v1/ejection/redirect` - -### What it does - -- Validates incoming rows for a multisig import. -- Upserts a `NewWallet` with signer data, `paymentCbor` (from `payment_script`), and `stakeCbor` (from `stake_script`). -- Returns the invite URL for the newly imported wallet. - -### Request body - -Send an array of rows. Each row should include: - -- Required per row: - - `user_id` (string) - - `user_stake_pubkey_hash_hex` (56-char lowercase hex) -- Shared across all rows (values must match for every row): - - `multisig_id` - - `multisig_name` - - `multisig_address` (validated) - - `payment_script` (CBOR hex of native script) - - `stake_script` -- Optional per row: - - `user_address_bech32` - - `user_name` - - `community_description` - -Minimal example payload: - -```json -[ - { - "multisig_id": "104ce812-bbd1...2ee0a", - "multisig_name": "Team Treasury", - "multisig_address": "addr1...7nj", - "payment_script": "82018183...34", - "stake_script": "82018183...34", - "user_id": "51d3015c-04b1...107eb", - "user_name": "Bob", - "user_address_bech32": "", - "user_stake_pubkey_hash_hex": "5a4006...5b1" - }, - { - "multisig_id": "104ce812-bbd1...2ee0a", - "multisig_name": "Team Treasury", - "multisig_address": "addr1...7nj", - "payment_script": "82018183...34", - "stake_script": "82018183...34", - "user_id": "97f9d721-7246...76", - "user_name": "Carol", - "user_address_bech32": "addr1...3zka", - "user_stake_pubkey_hash_hex": "f7f32d1a...9d1" - }, -] -``` - -### Curl example - -```bash -curl -X POST \ - -H "Content-Type: application/json" \ - -d '[ - {"multisig_id":"msig_123","multisig_name":"Team Treasury","multisig_address":"addr1...","payment_script":"4a50...c0","user_id":"u1","user_stake_pubkey_hash_hex":"abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd"}, - {"multisig_id":"msig_123","user_id":"u2","user_stake_pubkey_hash_hex":"1234123412341234123412341234123412341234123412341234"} - ]' \ - https://multisig.meshjs.dev/api/v1/ejection/redirect -``` - -### Response - -```json -{ - "ok": true, - "receivedAt": "2025-10-13T17:50:10.123Z", - "multisigAddress": "addr1...", - "dbUpdated": true, - "inviteUrl": "https://multisig.meshjs.dev/wallets/invite/<newWalletId>" -} -``` - -### Notes - -- CORS is enabled; `OPTIONS` requests return 200. -- If a `multisig_id` is supplied, the wallet is upserted with that id; otherwise a new id is created. -- The first non-empty `community_description` is used for the wallet description (HTML tags are stripped). -- If the database write fails, `dbUpdated` will be `false` and `inviteUrl` will be `null`. - 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": "<p>A DAO for decentralized audits, research, and safety on Cardano. </p>", + "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": "<p>Our team treasury</p>", "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/<newWalletId>" +} +``` + +### 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/ejection/redirect.ts b/src/pages/api/v1/import/summon.ts similarity index 89% rename from src/pages/api/v1/ejection/redirect.ts rename to src/pages/api/v1/import/summon.ts index 6c91ae19..4219f943 100644 --- a/src/pages/api/v1/ejection/redirect.ts +++ b/src/pages/api/v1/import/summon.ts @@ -41,6 +41,11 @@ export default async function handler( return v.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 stripTags(maybeDesc); + } for (const r of rows) { const desc = (r as { community_description?: unknown }).community_description; if (typeof desc === "string" && desc.trim().length > 0) { @@ -58,12 +63,8 @@ export default async function handler( ? summary.signerAddresses.map((addr: string) => (typeof addr === "string" ? addr.trim() : "")) : []; - // Prepare raw import bodies to persist: prefer rows if provided, otherwise normalize req.body - const rawImportBodies = Array.isArray((req.body as any)?.rows) - ? (req.body as any).rows - : Array.isArray(req.body) - ? req.body - : [req.body]; + // 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; @@ -112,7 +113,7 @@ export default async function handler( update: updateData, create: createDataWithId, }); - console.log("[api/v1/ejection/redirect] NewWallet upsert success:", { id: saved.id }); + console.log("[api/v1/import/summon] NewWallet upsert success:", { id: saved.id }); dbUpdated = true; newWalletId = saved.id; } else { @@ -135,12 +136,12 @@ export default async function handler( const created = await db.newWallet.create({ data: createData, }); - console.log("[api/v1/ejection/redirect] NewWallet create success:", { id: created.id }); + console.log("[api/v1/import/summon] NewWallet create success:", { id: created.id }); dbUpdated = true; newWalletId = created.id; } } catch (err) { - console.error("[api/v1/ejection/redirect] NewWallet upsert failed:", err); + console.error("[api/v1/import/summon] NewWallet upsert failed:", err); } // Generate the URL for the multisig wallet invite page @@ -155,7 +156,7 @@ export default async function handler( inviteUrl }); } catch (error) { - console.error("[api/v1/ejection/redirect] Error handling POST:", error); + console.error("[api/v1/import/summon] Error handling POST:", error); return res.status(500).json({ error: "Internal Server Error" }); } } diff --git a/src/utils/validateMultisigImport.ts b/src/utils/validateMultisigImport.ts index ad5152c4..56b9e738 100644 --- a/src/utils/validateMultisigImport.ts +++ b/src/utils/validateMultisigImport.ts @@ -59,18 +59,46 @@ const normalize = (v?: string | null) => (typeof v === "string" ? v.trim() : nul const requiredField = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0; export async function validateMultisigImportPayload(payload: unknown): Promise<ValidationResult> { - const rowsUnknown = Array.isArray(payload) - ? (payload as unknown[]) - : Array.isArray((payload as { rows?: unknown })?.rows) - ? (((payload as { rows: unknown }).rows as unknown[])) - : []; - const rows = rowsUnknown as ImportedMultisigRow[]; + // 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) { return { ok: false, status: 400, - body: { error: "Expected an array of rows or { rows: [...] }" }, + body: { error: "Expected payload: { community, multisig, users }" }, }; } @@ -86,66 +114,16 @@ export async function validateMultisigImportPayload(payload: unknown): Promise<V ok: false, status: 400, body: { - error: "Each row must include user_id and user_stake_pubkey_hash_hex", + error: "Each user must include id and stake_pubkey_hash_hex", invalidIndexes, }, }; } - // If provided, all multisig_id values must match - const providedIds = rows - .map((r) => r.multisig_id) - .filter((v): v is string => typeof v === "string" && v.trim().length > 0); - const multisigIds = new Set(providedIds); - if (multisigIds.size > 1) { - return { - ok: false, - status: 400, - body: { - error: "All rows must belong to the same multisig_id", - multisigIds: Array.from(multisigIds), - }, - }; - } - - const multisigId = rows[0]!.multisig_id ?? null; - const multisigName = rows[0]!.multisig_name ?? null; - const multisigAddress = rows[0]!.multisig_address ?? null; - - // Shared field consistency - const base = { - multisig_name: normalize(multisigName), - multisig_address: normalize(multisigAddress), - payment_script: normalize(rows[0]!.payment_script ?? null), - stake_script: normalize(rows[0]!.stake_script ?? null), - } as const; - - const fieldMismatches: Record<string, number[]> = {}; - for (let i = 0; i < rows.length; i++) { - const r = rows[i]!; - if (normalize(r.multisig_name) !== base.multisig_name) { - (fieldMismatches.multisig_name ||= []).push(i); - } - if (normalize(r.multisig_address) !== base.multisig_address) { - (fieldMismatches.multisig_address ||= []).push(i); - } - if (normalize(r.payment_script) !== base.payment_script) { - (fieldMismatches.payment_script ||= []).push(i); - } - if (normalize(r.stake_script) !== base.stake_script) { - (fieldMismatches.stake_script ||= []).push(i); - } - } - if (Object.keys(fieldMismatches).length > 0) { - return { - ok: false, - status: 400, - body: { - error: "All rows must share the same multisig_name, multisig_address, payment_script, and stake_script", - fieldMismatches, - }, - }; - } + // 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) { @@ -264,7 +242,9 @@ export async function validateMultisigImportPayload(payload: unknown): Promise<V let isPaymentHierarchical = false; let paymentSigKeyHashes: string[] = []; let paymentSigMatches: SigMatch[] = []; - const providedPaymentCbor = typeof rows[0]!.payment_script === "string" ? rows[0]!.payment_script.trim() : null; + 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); @@ -297,7 +277,9 @@ export async function validateMultisigImportPayload(payload: unknown): Promise<V } // If a stake_script CBOR is provided, attempt to decode it and log for visibility - const providedStakeCbor = typeof rows[0]!.stake_script === "string" ? rows[0]!.stake_script.trim() : null; + 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[] = []; From e574333143e7e3b58495817b93dedcb5786a1bd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Mon, 20 Oct 2025 08:20:59 +0200 Subject: [PATCH 16/20] feat(multisig): enhance validation to derive users from stakeCbor when user array is empty - Added logic to fallback on deriving users from stakeCbor if the users array is empty during multisig import validation. - Implemented error handling to log warnings if the user derivation process fails, ensuring robustness in user data processing. --- src/utils/validateMultisigImport.ts | 50 +++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/utils/validateMultisigImport.ts b/src/utils/validateMultisigImport.ts index 56b9e738..e557a78b 100644 --- a/src/utils/validateMultisigImport.ts +++ b/src/utils/validateMultisigImport.ts @@ -94,6 +94,56 @@ export async function validateMultisigImportPayload(payload: unknown): Promise<V }); } + 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, From 850cafd9945f52c88b74a4e5d5eef2a8031ed0f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Thu, 23 Oct 2025 09:20:06 +0200 Subject: [PATCH 17/20] feat(multisig): refine stake key ordering and address resolution in validation logic - Enhanced the validation process to align stake keys with signer addresses when payment and stake scripts are identical. - Implemented logic to recompute stake addresses used based on the reordered stake keys, ensuring positional alignment. - Improved fallback mechanisms for address resolution to handle cases where key hashes do not match directly. --- src/utils/validateMultisigImport.ts | 33 ++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/utils/validateMultisigImport.ts b/src/utils/validateMultisigImport.ts index e557a78b..cf0ce40f 100644 --- a/src/utils/validateMultisigImport.ts +++ b/src/utils/validateMultisigImport.ts @@ -392,7 +392,38 @@ export async function validateMultisigImportPayload(payload: unknown): Promise<V let signerStakeKeysOrdered: string[] = signerStakeKeys; let stakeAddressesUsedFinal: string[] = stakeAddressesUsed; - if ((noPaymentMatches || stakeScriptDiffers) && stakeSigKeyHashes.length > 0) { + // 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] || sig).toLowerCase(); + } + // Fallback to stake script's key hash at same position, or the payment sig itself + return (stakeSigKeyHashes[i] || sig).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); From 811c6c1beb234446d2c80b858f644c463245aa1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Thu, 23 Oct 2025 12:19:40 +0200 Subject: [PATCH 18/20] fix(multisig): correct fallback logic in multisig import validation - Updated the fallback mechanism in the validation logic to return an empty string instead of the payment signature when no matching stake key is found. - Ensured consistency in handling stake key hashes by aligning the return values with the expected format. --- src/utils/validateMultisigImport.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utils/validateMultisigImport.ts b/src/utils/validateMultisigImport.ts index cf0ce40f..763131c3 100644 --- a/src/utils/validateMultisigImport.ts +++ b/src/utils/validateMultisigImport.ts @@ -403,10 +403,10 @@ export async function validateMultisigImportPayload(payload: unknown): Promise<V 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] || sig).toLowerCase(); + return hex && /^[0-9a-f]{56}$/.test(hex) ? hex : (stakeSigKeyHashes[i] || "").toLowerCase(); } - // Fallback to stake script's key hash at same position, or the payment sig itself - return (stakeSigKeyHashes[i] || sig).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) => { From 1e3379338632c6983c068adfba62950323e0e4bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Thu, 23 Oct 2025 16:24:09 +0200 Subject: [PATCH 19/20] refactor(api): enhance description sanitization in wallet import and multisig validation - Updated the description handling in the wallet import API to use a new `sanitizeDescription` function, improving security by escaping HTML entities. - Refactored the multisig import validation to utilize a `sanitizeText` function for user names, ensuring consistent sanitization across both modules. --- src/pages/api/v1/import/summon.ts | 16 ++++++++++++---- src/utils/validateMultisigImport.ts | 17 ++++++++++++++--- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/pages/api/v1/import/summon.ts b/src/pages/api/v1/import/summon.ts index 4219f943..7fc31c93 100644 --- a/src/pages/api/v1/import/summon.ts +++ b/src/pages/api/v1/import/summon.ts @@ -37,19 +37,27 @@ export default async function handler( ? (req.body as unknown[]) : [req.body]; // Build wallet description from the first non-empty tagless community_description - function stripTags(v: string) { - return v.replace(/<[^>]*>/g, "").trim(); + function sanitizeDescription(v: string) { + const noTags = v.replace(/<[^>]*>/g, ""); + return noTags + .replace(/&/g, "&") + .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 stripTags(maybeDesc); + 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 stripTags(desc); + return sanitizeDescription(desc); } } return ""; diff --git a/src/utils/validateMultisigImport.ts b/src/utils/validateMultisigImport.ts index 763131c3..d362f9ad 100644 --- a/src/utils/validateMultisigImport.ts +++ b/src/utils/validateMultisigImport.ts @@ -209,15 +209,26 @@ export async function validateMultisigImportPayload(payload: unknown): Promise<V type CombinedSigner = { stake: string; address: string; name: string; drepKey: string }; const combined: CombinedSigner[] = []; const seenStake = new Set<string>(); - const stripTags = (v: string) => v.replace(/<[^>]*>/g, "").trim(); + const sanitizeText = (v: string) => { + const noTags = v.replace(/<[^>]*>/g, ""); + 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 - const name = typeof r.user_name === "string" ? r.user_name.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 }); } From f41f8103480faa63dc4cef762a7f680ba24633e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Thu, 23 Oct 2025 16:35:02 +0200 Subject: [PATCH 20/20] refactor(api): unify tag removal logic in description sanitization - Introduced a new `removeTagsLinear` function for consistent tag removal in both wallet import and multisig validation processes. - Updated the `sanitizeDescription` and `sanitizeText` functions to utilize the new tag removal logic, enhancing code maintainability and readability. --- src/pages/api/v1/import/summon.ts | 15 +++++++++++++-- src/utils/validateMultisigImport.ts | 13 ++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/pages/api/v1/import/summon.ts b/src/pages/api/v1/import/summon.ts index 7fc31c93..c3cb6b39 100644 --- a/src/pages/api/v1/import/summon.ts +++ b/src/pages/api/v1/import/summon.ts @@ -37,13 +37,24 @@ export default async function handler( ? (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 = v.replace(/<[^>]*>/g, ""); + const noTags = removeTagsLinear(v); return noTags .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") - .replace(/\"/g, """) + .replace(/"/g, """) .replace(/'/g, "'") .replace(/`/g, "`") .trim(); diff --git a/src/utils/validateMultisigImport.ts b/src/utils/validateMultisigImport.ts index d362f9ad..0643deca 100644 --- a/src/utils/validateMultisigImport.ts +++ b/src/utils/validateMultisigImport.ts @@ -209,8 +209,19 @@ export async function validateMultisigImportPayload(payload: unknown): Promise<V 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 = v.replace(/<[^>]*>/g, ""); + const noTags = removeTagsLinear(v); return noTags .replace(/&/g, "&") .replace(/</g, "<")