Skip to content

Commit b215bd0

Browse files
committed
Refactor signTransaction endpoint
- Removed outdated details from README.md regarding the signTransaction endpoint and streamlined the description. - Updated the signTransaction handler to improve request validation and error handling, replacing the signedTx parameter with txCbor. - Enhanced the logic for broadcasting transactions and managing state transitions. - Removed Swagger documentation for the signTransaction endpoint to reflect the changes in request parameters and responses.
1 parent 8063fdf commit b215bd0

File tree

3 files changed

+102
-206
lines changed

3 files changed

+102
-206
lines changed

src/pages/api/v1/README.md

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -43,22 +43,6 @@ A comprehensive REST API implementation for the multisig wallet application, pro
4343
- **Response**: Transaction object with ID, state, and metadata
4444
- **Error Handling**: 400 (validation), 401 (auth), 403 (authorization), 500 (server)
4545

46-
#### `signTransaction.ts` - POST `/api/v1/signTransaction`
47-
48-
- **Purpose**: Record a signature for a pending multisig transaction
49-
- **Authentication**: Required (JWT Bearer token)
50-
- **Features**:
51-
- Signature tracking with duplicate and rejection safeguards
52-
- Wallet membership validation and JWT address enforcement
53-
- Threshold detection with automatic submission when the final signature is collected
54-
- **Request Body**:
55-
- `walletId`: Wallet identifier
56-
- `transactionId`: Pending transaction identifier
57-
- `address`: Signer address
58-
- `signedTx`: CBOR transaction payload after applying the signature
59-
- **Response**: Updated transaction record with threshold status metadata; includes `txHash` when submission succeeds
60-
- **Error Handling**: 400 (validation), 401 (auth), 403 (authorization), 404 (not found), 409 (duplicate/rejected), 502 (submission failure), 500 (server)
61-
6246
#### `pendingTransactions.ts` - GET `/api/v1/pendingTransactions`
6347

6448
- **Purpose**: Retrieve all pending multisig transactions for a wallet

src/pages/api/v1/signTransaction.ts

Lines changed: 102 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,17 @@ import { verifyJwt } from "@/lib/verifyJwt";
55
import { createCaller } from "@/server/api/root";
66
import { db } from "@/server/db";
77
import { getProvider } from "@/utils/get-provider";
8-
import { paymentKeyHash } from "@/utils/multisigSDK";
9-
import { csl } from "@meshsdk/core-csl";
10-
11-
const HEX_REGEX = /^[0-9a-fA-F]+$/;
12-
13-
const isHexString = (value: unknown): value is string =>
14-
typeof value === "string" && value.length > 0 && HEX_REGEX.test(value);
15-
16-
const resolveNetworkFromAddress = (bech32Address: string): 0 | 1 =>
17-
bech32Address.startsWith("addr_test") || bech32Address.startsWith("stake_test")
18-
? 0
19-
: 1;
8+
import { addressToNetwork } from "@/utils/multisigSDK";
9+
10+
function coerceBoolean(value: unknown, fallback = false): boolean {
11+
if (typeof value === "boolean") return value;
12+
if (typeof value === "string") {
13+
const normalized = value.trim().toLowerCase();
14+
if (normalized === "true") return true;
15+
if (normalized === "false") return false;
16+
}
17+
return fallback;
18+
}
2019

2120
export default async function handler(
2221
req: NextApiRequest,
@@ -34,7 +33,9 @@ export default async function handler(
3433
}
3534

3635
const authHeader = req.headers.authorization;
37-
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
36+
const token = authHeader?.startsWith("Bearer ")
37+
? authHeader.slice(7)
38+
: null;
3839

3940
if (!token) {
4041
return res.status(401).json({ error: "Unauthorized - Missing token" });
@@ -50,42 +51,49 @@ export default async function handler(
5051
expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
5152
} as const;
5253

53-
const caller = createCaller({ db, session });
54-
55-
const body =
56-
typeof req.body === "object" && req.body !== null
57-
? (req.body as Record<string, unknown>)
58-
: {};
59-
60-
const { walletId, transactionId, address, signedTx } = body;
61-
62-
if (typeof walletId !== "string" || walletId.trim().length === 0) {
54+
type SignTransactionRequestBody = {
55+
walletId?: unknown;
56+
transactionId?: unknown;
57+
address?: unknown;
58+
txCbor?: unknown;
59+
broadcast?: unknown;
60+
txHash?: unknown;
61+
};
62+
63+
const {
64+
walletId,
65+
transactionId,
66+
address,
67+
txCbor,
68+
broadcast: rawBroadcast,
69+
txHash: rawTxHash,
70+
} = (req.body ?? {}) as SignTransactionRequestBody;
71+
72+
if (typeof walletId !== "string" || walletId.trim() === "") {
6373
return res.status(400).json({ error: "Missing or invalid walletId" });
6474
}
6575

66-
if (typeof transactionId !== "string" || transactionId.trim().length === 0) {
76+
if (typeof transactionId !== "string" || transactionId.trim() === "") {
6777
return res
6878
.status(400)
6979
.json({ error: "Missing or invalid transactionId" });
7080
}
7181

72-
if (typeof address !== "string" || address.trim().length === 0) {
82+
if (typeof address !== "string" || address.trim() === "") {
7383
return res.status(400).json({ error: "Missing or invalid address" });
7484
}
7585

76-
if (!isHexString(signedTx)) {
77-
return res.status(400).json({ error: "Missing or invalid signedTx" });
78-
}
79-
80-
if (signedTx.length % 2 !== 0) {
81-
return res.status(400).json({ error: "Missing or invalid signedTx" });
86+
if (typeof txCbor !== "string" || txCbor.trim() === "") {
87+
return res.status(400).json({ error: "Missing or invalid txCbor" });
8288
}
8389

8490
if (payload.address !== address) {
8591
return res.status(403).json({ error: "Address mismatch" });
8692
}
8793

8894
try {
95+
const caller = createCaller({ db, session });
96+
8997
const wallet = await caller.wallet.getWallet({ walletId, address });
9098
if (!wallet) {
9199
return res.status(404).json({ error: "Wallet not found" });
@@ -114,111 +122,91 @@ export default async function handler(
114122
if (transaction.signedAddresses.includes(address)) {
115123
return res
116124
.status(409)
117-
.json({ error: "Address already signed this transaction" });
125+
.json({ error: "Address has already signed this transaction" });
118126
}
119127

120128
if (transaction.rejectedAddresses.includes(address)) {
121129
return res
122130
.status(409)
123-
.json({ error: "Address has rejected this transaction" });
124-
}
125-
126-
let signerKeyHash: string;
127-
try {
128-
signerKeyHash = paymentKeyHash(address).toLowerCase();
129-
} catch (deriveError) {
130-
console.error("Failed to derive payment key hash", {
131-
message: (deriveError as Error)?.message,
132-
});
133-
return res.status(400).json({ error: "Unable to derive signer key" });
131+
.json({ error: "Address has already rejected this transaction" });
134132
}
135133

136-
let signerWitnessFound = false;
137-
try {
138-
const tx = csl.Transaction.from_hex(signedTx);
139-
const vkeys = tx.witness_set()?.vkeys();
140-
141-
if (vkeys) {
142-
for (let i = 0; i < vkeys.len(); i += 1) {
143-
const witness = vkeys.get(i);
144-
const witnessKeyHash = Buffer.from(
145-
witness.vkey().public_key().hash().to_bytes(),
146-
)
147-
.toString("hex")
148-
.toLowerCase();
149-
150-
if (witnessKeyHash === signerKeyHash) {
151-
signerWitnessFound = true;
152-
break;
153-
}
154-
}
134+
const updatedSignedAddresses = [
135+
...transaction.signedAddresses,
136+
address,
137+
];
138+
139+
const shouldAttemptBroadcast = coerceBoolean(rawBroadcast, true);
140+
141+
const threshold = (() => {
142+
switch (wallet.type) {
143+
case "atLeast":
144+
return wallet.numRequiredSigners ?? wallet.signersAddresses.length;
145+
case "all":
146+
return wallet.signersAddresses.length;
147+
case "any":
148+
return 1;
149+
default:
150+
return wallet.numRequiredSigners ?? 1;
155151
}
156-
} catch (decodeError) {
157-
console.error("Failed to inspect transaction witnesses", {
158-
message: (decodeError as Error)?.message,
159-
stack: (decodeError as Error)?.stack,
160-
});
161-
return res.status(400).json({ error: "Invalid signedTx payload" });
162-
}
163-
164-
if (!signerWitnessFound) {
165-
return res.status(400).json({
166-
error: "Signed transaction does not include caller signature",
167-
});
168-
}
169-
170-
const updatedSignedAddresses = [...transaction.signedAddresses, address];
171-
const updatedRejectedAddresses = [...transaction.rejectedAddresses];
172-
173-
const totalSigners = wallet.signersAddresses.length;
174-
const requiredSigners = wallet.numRequiredSigners ?? undefined;
175-
176-
let thresholdReached = false;
177-
switch (wallet.type) {
178-
case "any":
179-
thresholdReached = true;
180-
break;
181-
case "all":
182-
thresholdReached = updatedSignedAddresses.length >= totalSigners;
183-
break;
184-
case "atLeast":
185-
thresholdReached =
186-
typeof requiredSigners === "number" &&
187-
updatedSignedAddresses.length >= requiredSigners;
188-
break;
189-
default:
190-
thresholdReached = false;
191-
}
192-
193-
let finalTxHash: string | undefined;
194-
if (thresholdReached && !finalTxHash) {
152+
})();
153+
154+
const providedTxHash =
155+
typeof rawTxHash === "string" && rawTxHash.trim() !== ""
156+
? rawTxHash.trim()
157+
: undefined;
158+
159+
let nextState = transaction.state;
160+
let finalTxHash = providedTxHash ?? transaction.txHash ?? undefined;
161+
let submissionError: string | undefined;
162+
163+
if (
164+
shouldAttemptBroadcast &&
165+
threshold > 0 &&
166+
updatedSignedAddresses.length >= threshold &&
167+
!providedTxHash
168+
) {
195169
try {
196-
const network = resolveNetworkFromAddress(address);
197-
const blockchainProvider = getProvider(network);
198-
finalTxHash = await blockchainProvider.submitTx(signedTx);
199-
} catch (submitError) {
200-
console.error("Failed to submit transaction", {
201-
message: (submitError as Error)?.message,
202-
stack: (submitError as Error)?.stack,
170+
const networkSource = wallet.signersAddresses[0] ?? address;
171+
const network = addressToNetwork(networkSource);
172+
const provider = getProvider(network);
173+
const submittedHash = await provider.submitTx(txCbor);
174+
finalTxHash = submittedHash;
175+
nextState = 1;
176+
} catch (error) {
177+
console.error("Error submitting signed transaction", {
178+
transactionId,
179+
error,
203180
});
204-
return res.status(502).json({ error: "Failed to submit transaction" });
181+
submissionError = (error as Error)?.message ?? "Failed to submit transaction";
205182
}
206183
}
207184

208-
const nextState = finalTxHash ? 1 : 0;
185+
if (providedTxHash) {
186+
nextState = 1;
187+
}
188+
189+
// Ensure we do not downgrade a completed transaction back to pending
190+
if (transaction.state === 1) {
191+
nextState = 1;
192+
} else if (nextState !== 1) {
193+
nextState = 0;
194+
}
209195

210196
const updatedTransaction = await caller.transaction.updateTransaction({
211197
transactionId,
212-
txCbor: signedTx,
198+
txCbor,
213199
signedAddresses: updatedSignedAddresses,
214-
rejectedAddresses: updatedRejectedAddresses,
200+
rejectedAddresses: transaction.rejectedAddresses,
215201
state: nextState,
216-
txHash: finalTxHash,
202+
...(finalTxHash ? { txHash: finalTxHash } : {}),
217203
});
218204

219205
return res.status(200).json({
220206
transaction: updatedTransaction,
221-
thresholdReached,
207+
submitted: nextState === 1,
208+
txHash: finalTxHash,
209+
...(submissionError ? { submissionError } : {}),
222210
});
223211
} catch (error) {
224212
console.error("Error in signTransaction handler", {
@@ -228,4 +216,3 @@ export default async function handler(
228216
return res.status(500).json({ error: "Internal Server Error" });
229217
}
230218
}
231-

src/utils/swagger.ts

Lines changed: 0 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -196,81 +196,6 @@ export const swaggerSpec = swaggerJSDoc({
196196
},
197197
},
198198
},
199-
"/api/v1/signTransaction": {
200-
post: {
201-
tags: ["V1"],
202-
summary: "Sign a pending multisig transaction",
203-
description:
204-
"Adds a signature to an existing multisig transaction and automatically submits it once the required signatures are met.",
205-
requestBody: {
206-
required: true,
207-
content: {
208-
"application/json": {
209-
schema: {
210-
type: "object",
211-
properties: {
212-
walletId: { type: "string" },
213-
transactionId: { type: "string" },
214-
address: { type: "string" },
215-
signedTx: { type: "string" },
216-
},
217-
required: [
218-
"walletId",
219-
"transactionId",
220-
"address",
221-
"signedTx",
222-
],
223-
},
224-
},
225-
},
226-
},
227-
responses: {
228-
200: {
229-
description: "Transaction successfully updated",
230-
content: {
231-
"application/json": {
232-
schema: {
233-
type: "object",
234-
properties: {
235-
transaction: {
236-
type: "object",
237-
properties: {
238-
id: { type: "string" },
239-
walletId: { type: "string" },
240-
txJson: { type: "string" },
241-
txCbor: { type: "string" },
242-
signedAddresses: {
243-
type: "array",
244-
items: { type: "string" },
245-
},
246-
rejectedAddresses: {
247-
type: "array",
248-
items: { type: "string" },
249-
},
250-
description: { type: "string" },
251-
state: { type: "number" },
252-
txHash: { type: "string" },
253-
createdAt: { type: "string" },
254-
updatedAt: { type: "string" },
255-
},
256-
},
257-
thresholdReached: { type: "boolean" },
258-
},
259-
},
260-
},
261-
},
262-
},
263-
400: { description: "Validation error" },
264-
401: { description: "Unauthorized" },
265-
403: { description: "Authorization error" },
266-
404: { description: "Wallet or transaction not found" },
267-
409: { description: "Signer already processed this transaction" },
268-
502: { description: "Blockchain submission failed" },
269-
405: { description: "Method not allowed" },
270-
500: { description: "Internal server error" },
271-
},
272-
},
273-
},
274199
"/api/v1/pendingTransactions": {
275200
get: {
276201
tags: ["V1"],

0 commit comments

Comments
 (0)