From 6e2257c1adffae14f13dde51230fda2b30e7e568 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Wed, 5 Nov 2025 09:13:55 +0200 Subject: [PATCH 1/3] Add rawImportBodies field to Wallet model in schema.prisma --- prisma/schema.prisma | 1 + 1 file changed, 1 insertion(+) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fc50e17..47e8823 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,6 +31,7 @@ model Wallet { type String isArchived Boolean @default(false) clarityApiKey String? + rawImportBodies Json? migrationTargetWalletId String? } From a2d6c2538b4eb060724c06a7115d508d89a77f55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Wed, 5 Nov 2025 10:37:26 +0200 Subject: [PATCH 2/3] Feature: added fetch pending transactions via api route --- src/pages/api/v1/pendingTransactions.ts | 72 +++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/pages/api/v1/pendingTransactions.ts diff --git a/src/pages/api/v1/pendingTransactions.ts b/src/pages/api/v1/pendingTransactions.ts new file mode 100644 index 0000000..2725ac4 --- /dev/null +++ b/src/pages/api/v1/pendingTransactions.ts @@ -0,0 +1,72 @@ +import { cors, addCorsCacheBustingHeaders } from "@/lib/cors"; +import { verifyJwt } from "@/lib/verifyJwt"; +import { createCaller } from "@/server/api/root"; +import { db } from "@/server/db"; +import type { NextApiRequest, NextApiResponse } from "next"; + +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 !== "GET") { + return res.status(405).json({ error: "Method Not Allowed" }); + } + + const authHeader = req.headers.authorization; + const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null; + + if (!token) { + return res.status(401).json({ error: "Unauthorized - Missing token" }); + } + + const payload = verifyJwt(token); + if (!payload) { + return res.status(401).json({ error: "Invalid or expired token" }); + } + + const session = { + user: { id: payload.address }, + expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + } as const; + + const { walletId, address } = req.query; + + if (typeof address !== "string") { + return res.status(400).json({ error: "Invalid address parameter" }); + } + if (payload.address !== address) { + return res.status(403).json({ error: "Address mismatch" }); + } + if (typeof walletId !== "string") { + return res.status(400).json({ error: "Invalid walletId parameter" }); + } + + try { + const caller = createCaller({ db, session }); + + const wallet = await caller.wallet.getWallet({ walletId, address }); + if (!wallet) { + return res.status(404).json({ error: "Wallet not found" }); + } + + const pendingTransactions = + await caller.transaction.getPendingTransactions({ walletId }); + + return res.status(200).json(pendingTransactions); + } catch (error) { + console.error("Error in pendingTransactions handler", { + message: (error as Error)?.message, + stack: (error as Error)?.stack, + }); + return res.status(500).json({ error: "Internal Server Error" }); + } +} + From 8eaccba35df1f15d140aee51f306ea511a0e12d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Wed, 5 Nov 2025 11:41:01 +0200 Subject: [PATCH 3/3] Enhance API documentation and add signTransaction endpoint - Updated README.md to include detailed information about the new signTransaction endpoint, including its purpose, authentication requirements, request body, response structure, and error handling. - Added Swagger documentation for the signTransaction endpoint, outlining its functionality and expected request/response formats. --- src/pages/api/v1/README.md | 82 +++++++++--- src/pages/api/v1/signTransaction.ts | 185 ++++++++++++++++++++++++++++ src/utils/swagger.ts | 75 +++++++++++ 3 files changed, 323 insertions(+), 19 deletions(-) create mode 100644 src/pages/api/v1/signTransaction.ts diff --git a/src/pages/api/v1/README.md b/src/pages/api/v1/README.md index b87c990..fb48485 100644 --- a/src/pages/api/v1/README.md +++ b/src/pages/api/v1/README.md @@ -5,6 +5,7 @@ A comprehensive REST API implementation for the multisig wallet application, pro ## Authentication & Security ### JWT-Based Authentication + - **Bearer Token Authentication**: All endpoints require valid JWT tokens - **Address-Based Authorization**: Token payload contains user address for authorization - **Session Management**: 1-hour token expiration with automatic renewal @@ -12,6 +13,7 @@ A comprehensive REST API implementation for the multisig wallet application, pro - **Address Validation**: Strict address matching between token and request parameters ### Security Features + - **Input Validation**: Comprehensive parameter validation and sanitization - **Error Handling**: Detailed error responses without sensitive information exposure - **Rate Limiting**: Built-in protection against abuse (via CORS and validation) @@ -22,6 +24,7 @@ A comprehensive REST API implementation for the multisig wallet application, pro ### Transaction Management #### `addTransaction.ts` - POST `/api/v1/addTransaction` + - **Purpose**: Submit external transactions for multisig wallet processing - **Authentication**: Required (JWT Bearer token) - **Features**: @@ -40,7 +43,24 @@ A comprehensive REST API implementation for the multisig wallet application, pro - **Response**: Transaction object with ID, state, and metadata - **Error Handling**: 400 (validation), 401 (auth), 403 (authorization), 500 (server) +#### `signTransaction.ts` - POST `/api/v1/signTransaction` + +- **Purpose**: Record a signature for a pending multisig transaction +- **Authentication**: Required (JWT Bearer token) +- **Features**: + - Signature tracking with duplicate and rejection safeguards + - Wallet membership validation and JWT address enforcement + - Threshold detection with automatic submission when the final signature is collected +- **Request Body**: + - `walletId`: Wallet identifier + - `transactionId`: Pending transaction identifier + - `address`: Signer address + - `signedTx`: CBOR transaction payload after applying the signature +- **Response**: Updated transaction record with threshold status metadata; includes `txHash` when submission succeeds +- **Error Handling**: 400 (validation), 401 (auth), 403 (authorization), 404 (not found), 409 (duplicate/rejected), 502 (submission failure), 500 (server) + #### `submitDatum.ts` - POST `/api/v1/submitDatum` + - **Purpose**: Submit signable payloads for multisig signature collection - **Authentication**: Required (JWT Bearer token) - **Features**: @@ -63,6 +83,7 @@ A comprehensive REST API implementation for the multisig wallet application, pro ### Wallet Management #### `walletIds.ts` - GET `/api/v1/walletIds` + - **Purpose**: Retrieve all wallet IDs and names for a user address - **Authentication**: Required (JWT Bearer token) - **Features**: @@ -76,6 +97,7 @@ A comprehensive REST API implementation for the multisig wallet application, pro - **Error Handling**: 400 (validation), 401 (auth), 403 (authorization), 404 (not found), 500 (server) #### `nativeScript.ts` - GET `/api/v1/nativeScript` + - **Purpose**: Generate native scripts for multisig wallet operations - **Authentication**: Required (JWT Bearer token) - **Features**: @@ -90,6 +112,7 @@ A comprehensive REST API implementation for the multisig wallet application, pro - **Error Handling**: 400 (validation), 401 (auth), 403 (authorization), 404 (not found), 500 (server) #### `lookupMultisigWallet.ts` - GET `/api/v1/lookupMultisigWallet` + - **Purpose**: Lookup multisig wallet metadata using public key hashes - **Authentication**: Not required (public endpoint) - **Features**: @@ -106,6 +129,7 @@ A comprehensive REST API implementation for the multisig wallet application, pro ### UTxO Management #### `freeUtxos.ts` - GET `/api/v1/freeUtxos` + - **Purpose**: Retrieve unblocked UTxOs for a multisig wallet - **Authentication**: Required (JWT Bearer token) - **Features**: @@ -123,6 +147,7 @@ A comprehensive REST API implementation for the multisig wallet application, pro ### Authentication Endpoints #### `getNonce.ts` - GET `/api/v1/getNonce` + - **Purpose**: Request authentication nonce for address-based signing - **Authentication**: Not required (public endpoint) - **Features**: @@ -136,6 +161,7 @@ A comprehensive REST API implementation for the multisig wallet application, pro - **Error Handling**: 400 (validation), 404 (user not found), 500 (server) #### `authSigner.ts` - POST `/api/v1/authSigner` + - **Purpose**: Verify signed nonce and return JWT bearer token - **Authentication**: Not required (public endpoint) - **Features**: @@ -153,6 +179,7 @@ A comprehensive REST API implementation for the multisig wallet application, pro ### Utility Endpoints #### `og.ts` - GET `/api/v1/og` + - **Purpose**: Extract Open Graph metadata from URLs - **Authentication**: Not required (public endpoint) - **Features**: @@ -168,18 +195,21 @@ A comprehensive REST API implementation for the multisig wallet application, pro ## API Architecture ### Request/Response Patterns + - **RESTful Design**: Standard HTTP methods and status codes - **JSON Format**: All requests and responses use JSON - **Error Consistency**: Standardized error response format - **CORS Support**: Cross-origin requests enabled ### Authentication Flow + 1. **Nonce Request**: Client requests nonce for address 2. **Signature Generation**: Client signs nonce with private key 3. **Token Exchange**: Client exchanges signature for JWT token 4. **API Access**: Client uses JWT token for authenticated requests ### Error Handling + - **HTTP Status Codes**: Proper status code usage - **Error Messages**: Descriptive error messages - **Validation Errors**: Detailed parameter validation @@ -187,6 +217,7 @@ A comprehensive REST API implementation for the multisig wallet application, pro - **Server Errors**: Internal server error handling ### Database Integration + - **Prisma ORM**: Type-safe database operations - **Transaction Management**: Database transaction handling - **Data Validation**: Input validation and sanitization @@ -195,18 +226,21 @@ A comprehensive REST API implementation for the multisig wallet application, pro ## Security Considerations ### Input Validation + - **Parameter Validation**: All input parameters validated - **Type Checking**: Strict type validation for all inputs - **Address Validation**: Cardano address format validation - **Signature Verification**: Cryptographic signature validation ### Authorization + - **Address Matching**: Token address must match request address - **Wallet Access**: Users can only access their own wallets - **Resource Protection**: Sensitive operations require authentication - **Session Management**: Token expiration and renewal ### Data Protection + - **Sensitive Data**: No sensitive data in error messages - **Logging**: Comprehensive logging without sensitive information - **CORS**: Proper cross-origin resource sharing configuration @@ -215,6 +249,7 @@ A comprehensive REST API implementation for the multisig wallet application, pro ## Dependencies ### Core Dependencies + - **Next.js API Routes**: Server-side API implementation - **Prisma**: Database ORM and query builder - **jsonwebtoken**: JWT token generation and verification @@ -222,6 +257,7 @@ A comprehensive REST API implementation for the multisig wallet application, pro - **@meshsdk/core-cst**: Cryptographic signature verification ### Utility Dependencies + - **crypto**: Node.js cryptographic functions - **cors**: Cross-origin resource sharing - **fetch**: HTTP client for external requests @@ -229,11 +265,13 @@ A comprehensive REST API implementation for the multisig wallet application, pro ## Environment Variables ### Required Variables + - `JWT_SECRET`: Secret key for JWT token generation - `NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD`: Preprod network API key - `NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET`: Mainnet network API key ### Database Configuration + - Database connection via Prisma configuration - Environment-specific database URLs - Connection pooling and optimization @@ -241,45 +279,51 @@ A comprehensive REST API implementation for the multisig wallet application, pro ## Usage Examples ### Authentication Flow + ```typescript // 1. Request nonce -const nonceResponse = await fetch('/api/v1/getNonce?address=addr1...'); +const nonceResponse = await fetch("/api/v1/getNonce?address=addr1..."); const { nonce } = await nonceResponse.json(); // 2. Sign nonce and get token const signature = await wallet.signData(nonce, address); -const tokenResponse = await fetch('/api/v1/authSigner', { - method: 'POST', - body: JSON.stringify({ address, signature, key: publicKey }) +const tokenResponse = await fetch("/api/v1/authSigner", { + method: "POST", + body: JSON.stringify({ address, signature, key: publicKey }), }); const { token } = await tokenResponse.json(); ``` ### Transaction Submission + ```typescript -const response = await fetch('/api/v1/addTransaction', { - method: 'POST', +const response = await fetch("/api/v1/addTransaction", { + method: "POST", headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", }, body: JSON.stringify({ - walletId: 'wallet-id', - address: 'addr1...', - txCbor: 'tx-cbor-data', - txJson: 'tx-json-data', - description: 'Transaction description' - }) + walletId: "wallet-id", + address: "addr1...", + txCbor: "tx-cbor-data", + txJson: "tx-json-data", + description: "Transaction description", + }), }); ``` ### UTxO Retrieval + ```typescript -const response = await fetch('/api/v1/freeUtxos?walletId=wallet-id&address=addr1...', { - headers: { - 'Authorization': `Bearer ${token}` - } -}); +const response = await fetch( + "/api/v1/freeUtxos?walletId=wallet-id&address=addr1...", + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, +); const freeUtxos = await response.json(); ``` diff --git a/src/pages/api/v1/signTransaction.ts b/src/pages/api/v1/signTransaction.ts new file mode 100644 index 0000000..8838fa2 --- /dev/null +++ b/src/pages/api/v1/signTransaction.ts @@ -0,0 +1,185 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { cors, addCorsCacheBustingHeaders } from "@/lib/cors"; +import { verifyJwt } from "@/lib/verifyJwt"; +import { createCaller } from "@/server/api/root"; +import { db } from "@/server/db"; +import { getProvider } from "@/utils/get-provider"; + +const HEX_REGEX = /^[0-9a-fA-F]+$/; + +const isHexString = (value: unknown): value is string => + typeof value === "string" && value.length > 0 && HEX_REGEX.test(value); + +const resolveNetworkFromAddress = (bech32Address: string): 0 | 1 => + bech32Address.startsWith("addr_test") || bech32Address.startsWith("stake_test") + ? 0 + : 1; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + 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" }); + } + + const authHeader = req.headers.authorization; + const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null; + + if (!token) { + return res.status(401).json({ error: "Unauthorized - Missing token" }); + } + + const payload = verifyJwt(token); + if (!payload) { + return res.status(401).json({ error: "Invalid or expired token" }); + } + + const session = { + user: { id: payload.address }, + expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + } as const; + + const caller = createCaller({ db, session }); + + const body = + typeof req.body === "object" && req.body !== null + ? (req.body as Record) + : {}; + + const { walletId, transactionId, address, signedTx } = body; + + if (typeof walletId !== "string" || walletId.trim().length === 0) { + return res.status(400).json({ error: "Missing or invalid walletId" }); + } + + if (typeof transactionId !== "string" || transactionId.trim().length === 0) { + return res + .status(400) + .json({ error: "Missing or invalid transactionId" }); + } + + if (typeof address !== "string" || address.trim().length === 0) { + return res.status(400).json({ error: "Missing or invalid address" }); + } + + if (!isHexString(signedTx)) { + return res.status(400).json({ error: "Missing or invalid signedTx" }); + } + + if (signedTx.length % 2 !== 0) { + return res.status(400).json({ error: "Missing or invalid signedTx" }); + } + + if (payload.address !== address) { + return res.status(403).json({ error: "Address mismatch" }); + } + + try { + const wallet = await caller.wallet.getWallet({ walletId, address }); + if (!wallet) { + return res.status(404).json({ error: "Wallet not found" }); + } + + const transaction = await db.transaction.findUnique({ + where: { id: transactionId }, + }); + + if (!transaction) { + return res.status(404).json({ error: "Transaction not found" }); + } + + if (transaction.walletId !== walletId) { + return res + .status(403) + .json({ error: "Transaction does not belong to wallet" }); + } + + if (transaction.state === 1) { + return res + .status(409) + .json({ error: "Transaction already finalized" }); + } + + if (transaction.signedAddresses.includes(address)) { + return res + .status(409) + .json({ error: "Address already signed this transaction" }); + } + + if (transaction.rejectedAddresses.includes(address)) { + return res + .status(409) + .json({ error: "Address has rejected this transaction" }); + } + + const updatedSignedAddresses = [...transaction.signedAddresses, address]; + const updatedRejectedAddresses = [...transaction.rejectedAddresses]; + + const totalSigners = wallet.signersAddresses.length; + const requiredSigners = wallet.numRequiredSigners ?? undefined; + + let thresholdReached = false; + switch (wallet.type) { + case "any": + thresholdReached = true; + break; + case "all": + thresholdReached = updatedSignedAddresses.length >= totalSigners; + break; + case "atLeast": + thresholdReached = + typeof requiredSigners === "number" && + updatedSignedAddresses.length >= requiredSigners; + break; + default: + thresholdReached = false; + } + + let finalTxHash: string | undefined; + if (thresholdReached && !finalTxHash) { + try { + const network = resolveNetworkFromAddress(address); + const blockchainProvider = getProvider(network); + finalTxHash = await blockchainProvider.submitTx(signedTx); + } catch (submitError) { + console.error("Failed to submit transaction", { + message: (submitError as Error)?.message, + stack: (submitError as Error)?.stack, + }); + return res.status(502).json({ error: "Failed to submit transaction" }); + } + } + + const nextState = finalTxHash ? 1 : 0; + + const updatedTransaction = await caller.transaction.updateTransaction({ + transactionId, + txCbor: signedTx, + signedAddresses: updatedSignedAddresses, + rejectedAddresses: updatedRejectedAddresses, + state: nextState, + txHash: finalTxHash, + }); + + return res.status(200).json({ + transaction: updatedTransaction, + thresholdReached, + }); + } catch (error) { + console.error("Error in signTransaction handler", { + message: (error as Error)?.message, + stack: (error as Error)?.stack, + }); + return res.status(500).json({ error: "Internal Server Error" }); + } +} + diff --git a/src/utils/swagger.ts b/src/utils/swagger.ts index 4a47109..72c56fd 100644 --- a/src/utils/swagger.ts +++ b/src/utils/swagger.ts @@ -196,6 +196,81 @@ export const swaggerSpec = swaggerJSDoc({ }, }, }, + "/api/v1/signTransaction": { + post: { + tags: ["V1"], + summary: "Sign a pending multisig transaction", + description: + "Adds a signature to an existing multisig transaction and automatically submits it once the required signatures are met.", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + walletId: { type: "string" }, + transactionId: { type: "string" }, + address: { type: "string" }, + signedTx: { type: "string" }, + }, + required: [ + "walletId", + "transactionId", + "address", + "signedTx", + ], + }, + }, + }, + }, + responses: { + 200: { + description: "Transaction successfully updated", + content: { + "application/json": { + schema: { + type: "object", + properties: { + transaction: { + type: "object", + properties: { + id: { type: "string" }, + walletId: { type: "string" }, + txJson: { type: "string" }, + txCbor: { type: "string" }, + signedAddresses: { + type: "array", + items: { type: "string" }, + }, + rejectedAddresses: { + type: "array", + items: { type: "string" }, + }, + description: { type: "string" }, + state: { type: "number" }, + txHash: { type: "string" }, + createdAt: { type: "string" }, + updatedAt: { type: "string" }, + }, + }, + thresholdReached: { type: "boolean" }, + }, + }, + }, + }, + }, + 400: { description: "Validation error" }, + 401: { description: "Unauthorized" }, + 403: { description: "Authorization error" }, + 404: { description: "Wallet or transaction not found" }, + 409: { description: "Signer already processed this transaction" }, + 502: { description: "Blockchain submission failed" }, + 405: { description: "Method not allowed" }, + 500: { description: "Internal server error" }, + }, + }, + }, "/api/v1/submitDatum": { post: { tags: ["V1"],