Skip to content

Commit b0f3ef3

Browse files
committed
Merge branch 'feat/multisig-escrow'
# Conflicts: # src/app/api/escrow/multisig/[id]/broadcast/route.ts # src/app/api/escrow/multisig/[id]/dispute/route.ts # src/app/api/escrow/multisig/[id]/sign/route.ts # src/app/api/escrow/multisig/route.ts # src/lib/multisig/adapters/btc-multisig.test.ts # src/lib/multisig/adapters/btc-multisig.ts # src/lib/multisig/adapters/evm-safe.ts # src/lib/multisig/adapters/solana-multisig.test.ts # src/lib/multisig/adapters/solana-multisig.ts # src/lib/multisig/engine.test.ts # src/lib/multisig/engine.ts # src/lib/multisig/index.ts # src/lib/multisig/types.ts # src/lib/multisig/validation.test.ts # src/lib/multisig/validation.ts # supabase/migrations/20260301000000_multisig_escrow.sql
2 parents ffaab97 + fdced4d commit b0f3ef3

File tree

17 files changed

+343
-0
lines changed

17 files changed

+343
-0
lines changed

src/app/api/escrow/multisig/[id]/broadcast/route.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import {
1111
broadcastTransaction,
1212
broadcastTransactionSchema,
1313
} from '@/lib/multisig';
14+
<<<<<<< HEAD
1415
import { requireMultisigAuth } from '../../auth';
16+
=======
17+
>>>>>>> feat/multisig-escrow
1518

1619
function getSupabase() {
1720
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
@@ -25,9 +28,12 @@ export async function POST(
2528
{ params }: { params: Promise<{ id: string }> },
2629
) {
2730
try {
31+
<<<<<<< HEAD
2832
const auth = await requireMultisigAuth(request);
2933
if (!auth.ok) return auth.response;
3034

35+
=======
36+
>>>>>>> feat/multisig-escrow
3137
const { id: escrowId } = await params;
3238
const supabase = getSupabase();
3339
const body = await request.json();
@@ -54,8 +60,11 @@ export async function POST(
5460
return NextResponse.json({
5561
tx_hash: result.tx_hash,
5662
proposal: result.proposal,
63+
<<<<<<< HEAD
5764
broadcasted: result.broadcasted === true,
5865
stage: result.stage!,
66+
=======
67+
>>>>>>> feat/multisig-escrow
5968
});
6069
} catch (error) {
6170
console.error('Failed to broadcast transaction:', error);

src/app/api/escrow/multisig/[id]/dispute/route.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import {
1212
disputeMultisigEscrow,
1313
disputeSchema,
1414
} from '@/lib/multisig';
15+
<<<<<<< HEAD
1516
import { requireMultisigAuth } from '../../auth';
17+
=======
18+
>>>>>>> feat/multisig-escrow
1619

1720
function getSupabase() {
1821
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
@@ -26,9 +29,12 @@ export async function POST(
2629
{ params }: { params: Promise<{ id: string }> },
2730
) {
2831
try {
32+
<<<<<<< HEAD
2933
const auth = await requireMultisigAuth(request);
3034
if (!auth.ok) return auth.response;
3135

36+
=======
37+
>>>>>>> feat/multisig-escrow
3238
const { id: escrowId } = await params;
3339
const supabase = getSupabase();
3440
const body = await request.json();
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* POST /api/escrow/multisig/:id/propose
3+
*
4+
* Propose a transaction (release or refund) for a multisig escrow.
5+
* Returns transaction data that signers need to sign.
6+
*/
7+
8+
import { NextRequest, NextResponse } from 'next/server';
9+
import { createClient } from '@supabase/supabase-js';
10+
import {
11+
proposeTransaction,
12+
proposeTransactionSchema,
13+
} from '@/lib/multisig';
14+
15+
function getSupabase() {
16+
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
17+
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
18+
if (!url || !key) throw new Error('Supabase not configured');
19+
return createClient(url, key);
20+
}
21+
22+
export async function POST(
23+
request: NextRequest,
24+
{ params }: { params: Promise<{ id: string }> },
25+
) {
26+
try {
27+
const { id: escrowId } = await params;
28+
const supabase = getSupabase();
29+
const body = await request.json();
30+
31+
// Validate input
32+
const parsed = proposeTransactionSchema.safeParse(body);
33+
if (!parsed.success) {
34+
return NextResponse.json(
35+
{ error: parsed.error.errors[0].message },
36+
{ status: 400 },
37+
);
38+
}
39+
40+
const result = await proposeTransaction(
41+
supabase,
42+
escrowId,
43+
parsed.data.proposal_type,
44+
parsed.data.to_address,
45+
parsed.data.signer_pubkey,
46+
);
47+
48+
if (!result.success) {
49+
return NextResponse.json({ error: result.error }, { status: 400 });
50+
}
51+
52+
return NextResponse.json({
53+
proposal: result.proposal,
54+
tx_data: result.tx_data,
55+
}, { status: 201 });
56+
} catch (error) {
57+
console.error('Failed to propose transaction:', error);
58+
return NextResponse.json(
59+
{ error: 'Internal server error' },
60+
{ status: 500 },
61+
);
62+
}
63+
}

src/app/api/escrow/multisig/[id]/sign/route.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import {
1212
signProposal,
1313
signProposalSchema,
1414
} from '@/lib/multisig';
15+
<<<<<<< HEAD
1516
import { requireMultisigAuth } from '../../auth';
17+
=======
18+
>>>>>>> feat/multisig-escrow
1619

1720
function getSupabase() {
1821
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
@@ -26,9 +29,12 @@ export async function POST(
2629
{ params }: { params: Promise<{ id: string }> },
2730
) {
2831
try {
32+
<<<<<<< HEAD
2933
const auth = await requireMultisigAuth(request);
3034
if (!auth.ok) return auth.response;
3135

36+
=======
37+
>>>>>>> feat/multisig-escrow
3238
const { id: escrowId } = await params;
3339
const supabase = getSupabase();
3440
const body = await request.json();

src/app/api/escrow/multisig/route.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,20 @@
88

99
import { NextRequest, NextResponse } from 'next/server';
1010
import { createClient } from '@supabase/supabase-js';
11+
<<<<<<< HEAD
12+
=======
13+
import { authenticateRequest } from '@/lib/auth/middleware';
14+
>>>>>>> feat/multisig-escrow
1115
import {
1216
createMultisigEscrow,
1317
getMultisigEscrow,
1418
createMultisigEscrowSchema,
1519
isMultisigEnabled,
1620
} from '@/lib/multisig';
21+
<<<<<<< HEAD
1722
import { requireMultisigAuth } from './auth';
23+
=======
24+
>>>>>>> feat/multisig-escrow
1825

1926
function getSupabase() {
2027
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
@@ -36,10 +43,41 @@ export async function POST(request: NextRequest) {
3643
);
3744
}
3845

46+
<<<<<<< HEAD
3947
const auth = await requireMultisigAuth(request);
4048
if (!auth.ok) return auth.response;
4149

4250
const supabase = getSupabase();
51+
=======
52+
const supabase = getSupabase();
53+
54+
// Authentication required
55+
const authHeader = request.headers.get('authorization');
56+
const apiKeyHeader = request.headers.get('x-api-key');
57+
58+
if (!authHeader && !apiKeyHeader) {
59+
return NextResponse.json(
60+
{ error: 'Authentication required. Provide Authorization header or X-API-Key.' },
61+
{ status: 401 },
62+
);
63+
}
64+
65+
try {
66+
const authResult = await authenticateRequest(supabase, authHeader || apiKeyHeader);
67+
if (!authResult.success) {
68+
return NextResponse.json(
69+
{ error: 'Invalid or expired authentication' },
70+
{ status: 401 },
71+
);
72+
}
73+
} catch {
74+
return NextResponse.json(
75+
{ error: 'Authentication failed' },
76+
{ status: 401 },
77+
);
78+
}
79+
80+
>>>>>>> feat/multisig-escrow
4381
const body = await request.json();
4482

4583
// Validate input

src/lib/multisig/adapters/btc-multisig.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
*/
66

77
import { describe, it, expect, beforeEach } from 'vitest';
8+
<<<<<<< HEAD
89
import * as bitcoin from 'bitcoinjs-lib';
910
import * as secp256k1 from 'tiny-secp256k1';
11+
=======
12+
>>>>>>> feat/multisig-escrow
1013
import { BtcMultisigAdapter } from './btc-multisig';
1114

1215
// Sample compressed public keys (33 bytes hex)
@@ -139,6 +142,7 @@ describe('BtcMultisigAdapter', () => {
139142
});
140143

141144
describe('verifySignature', () => {
145+
<<<<<<< HEAD
142146
it('should verify a valid secp256k1 signature against tx_hash_to_sign', async () => {
143147
const privkey = Buffer.alloc(32, 1);
144148
const pubkey = Buffer.from(secp256k1.pointFromScalar(privkey, true)!);
@@ -190,6 +194,26 @@ describe('BtcMultisigAdapter', () => {
190194
'BTC',
191195
{ witness_script: 'abcdef', pubkeys: [PUB_KEY_2], tx_hash_to_sign: msgHash },
192196
'aa'.repeat(64),
197+
=======
198+
it('should validate signature format', async () => {
199+
// 64-byte signature (128 hex chars)
200+
const validSig = 'aa'.repeat(64);
201+
const valid = await adapter.verifySignature(
202+
'BTC',
203+
{ witness_script: 'abcdef', pubkeys: [PUB_KEY_1] },
204+
validSig,
205+
PUB_KEY_1,
206+
);
207+
expect(valid).toBe(true);
208+
});
209+
210+
it('should reject unknown pubkey', async () => {
211+
const validSig = 'aa'.repeat(64);
212+
const valid = await adapter.verifySignature(
213+
'BTC',
214+
{ witness_script: 'abcdef', pubkeys: [PUB_KEY_2] },
215+
validSig,
216+
>>>>>>> feat/multisig-escrow
193217
PUB_KEY_1, // not in pubkeys list
194218
);
195219
expect(valid).toBe(false);

src/lib/multisig/adapters/btc-multisig.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@
1919
*/
2020

2121
import * as bitcoin from 'bitcoinjs-lib';
22+
<<<<<<< HEAD
2223
import * as secp256k1 from 'tiny-secp256k1';
24+
=======
25+
>>>>>>> feat/multisig-escrow
2326
import type { ChainAdapter } from './interface';
2427
import type {
2528
MultisigChain,
@@ -242,14 +245,26 @@ export class BtcMultisigAdapter implements ChainAdapter {
242245
): Promise<boolean> {
243246
try {
244247
const pubkeyBuf = parsePubkey(signerPubkey);
248+
<<<<<<< HEAD
245249
const txHash = txData.tx_hash_to_sign as string | undefined;
246250

247251
// Check that the pubkey is one of the expected participants when provided.
252+
=======
253+
const sigBuf = Buffer.from(signature, 'hex');
254+
255+
// Verify the hash matches the pubkey and signature
256+
// In production, this would verify the actual PSBT input signature
257+
const witnessScript = txData.witness_script as string;
258+
if (!witnessScript) return false;
259+
260+
// Check that the pubkey is one of the multisig participants
261+
>>>>>>> feat/multisig-escrow
248262
const pubkeys = txData.pubkeys as string[] | undefined;
249263
if (pubkeys && !pubkeys.includes(signerPubkey)) {
250264
return false;
251265
}
252266

267+
<<<<<<< HEAD
253268
// tx_hash_to_sign is required for cryptographic verification.
254269
// Fail closed when missing/malformed.
255270
if (!txHash || txHash.length !== 64) {
@@ -274,6 +289,10 @@ export class BtcMultisigAdapter implements ChainAdapter {
274289
if (sigBuf.length !== 64) return false;
275290
return secp256k1.verify(msgHash, pubkeyBuf, sigBuf);
276291
}
292+
=======
293+
// Basic signature format validation (DER-encoded or Schnorr)
294+
return sigBuf.length >= 64 && pubkeyBuf.length >= 33;
295+
>>>>>>> feat/multisig-escrow
277296
} catch {
278297
return false;
279298
}
@@ -293,7 +312,11 @@ export class BtcMultisigAdapter implements ChainAdapter {
293312
}
294313

295314
if (signatures.length < 2) {
315+
<<<<<<< HEAD
296316
return { tx_hash: '', success: false, broadcasted: false };
317+
=======
318+
return { tx_hash: '', success: false };
319+
>>>>>>> feat/multisig-escrow
297320
}
298321

299322
// In production:
@@ -311,7 +334,10 @@ export class BtcMultisigAdapter implements ChainAdapter {
311334
return {
312335
tx_hash: txid,
313336
success: true,
337+
<<<<<<< HEAD
314338
broadcasted: false,
339+
=======
340+
>>>>>>> feat/multisig-escrow
315341
};
316342
}
317343
}

src/lib/multisig/adapters/evm-safe.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,11 +214,19 @@ export class EvmSafeAdapter implements ChainAdapter {
214214
ethers.ZeroAddress, // paymentReceiver
215215
]);
216216

217+
<<<<<<< HEAD
217218
// Generate deterministic salt from stable multisig parameters
218219
const saltNonce = BigInt(ethers.keccak256(
219220
ethers.solidityPacked(
220221
['address', 'address', 'address', 'uint256'],
221222
[owners[0], owners[1], owners[2], threshold],
223+
=======
224+
// Generate deterministic salt from participants
225+
const saltNonce = BigInt(ethers.keccak256(
226+
ethers.solidityPacked(
227+
['address', 'address', 'address', 'uint256'],
228+
[owners[0], owners[1], owners[2], Date.now()],
229+
>>>>>>> feat/multisig-escrow
222230
),
223231
));
224232

@@ -399,12 +407,19 @@ export class EvmSafeAdapter implements ChainAdapter {
399407
packedSignatures,
400408
]);
401409

410+
<<<<<<< HEAD
402411
// Return a deterministic id for the prepared payload.
403412
// NOTE: this does NOT broadcast to chain yet.
404413
return {
405414
tx_hash: ethers.keccak256(execData),
406415
success: true,
407416
broadcasted: false,
417+
=======
418+
// Return the prepared transaction for relay/execution
419+
return {
420+
tx_hash: ethers.keccak256(execData),
421+
success: true,
422+
>>>>>>> feat/multisig-escrow
408423
};
409424
}
410425
}

0 commit comments

Comments
 (0)