Skip to content

Commit fdced4d

Browse files
ralyodioclaude
andcommitted
feat(multisig): add API routes for multisig escrow operations
POST /api/escrow/multisig - Create 2-of-3 multisig escrow GET /api/escrow/multisig?id= - Get multisig escrow POST /api/escrow/multisig/:id/propose - Propose release/refund POST /api/escrow/multisig/:id/sign - Add signature to proposal POST /api/escrow/multisig/:id/broadcast - Broadcast approved tx POST /api/escrow/multisig/:id/dispute - Open dispute All routes follow existing escrow API patterns with auth middleware. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 46ed30b commit fdced4d

File tree

5 files changed

+374
-0
lines changed

5 files changed

+374
-0
lines changed
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* POST /api/escrow/multisig/:id/broadcast
3+
*
4+
* Broadcast an approved multisig proposal on-chain.
5+
* Requires the proposal to have reached threshold (2 signatures).
6+
*/
7+
8+
import { NextRequest, NextResponse } from 'next/server';
9+
import { createClient } from '@supabase/supabase-js';
10+
import {
11+
broadcastTransaction,
12+
broadcastTransactionSchema,
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 = broadcastTransactionSchema.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 broadcastTransaction(
41+
supabase,
42+
escrowId,
43+
parsed.data.proposal_id,
44+
);
45+
46+
if (!result.success) {
47+
return NextResponse.json({ error: result.error }, { status: 400 });
48+
}
49+
50+
return NextResponse.json({
51+
tx_hash: result.tx_hash,
52+
proposal: result.proposal,
53+
});
54+
} catch (error) {
55+
console.error('Failed to broadcast transaction:', error);
56+
return NextResponse.json(
57+
{ error: 'Internal server error' },
58+
{ status: 500 },
59+
);
60+
}
61+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* POST /api/escrow/multisig/:id/dispute
3+
*
4+
* Open a dispute on a funded multisig escrow.
5+
* Either depositor or beneficiary can open a dispute.
6+
* Once disputed, the arbiter can propose resolution.
7+
*/
8+
9+
import { NextRequest, NextResponse } from 'next/server';
10+
import { createClient } from '@supabase/supabase-js';
11+
import {
12+
disputeMultisigEscrow,
13+
disputeSchema,
14+
} from '@/lib/multisig';
15+
16+
function getSupabase() {
17+
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
18+
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
19+
if (!url || !key) throw new Error('Supabase not configured');
20+
return createClient(url, key);
21+
}
22+
23+
export async function POST(
24+
request: NextRequest,
25+
{ params }: { params: Promise<{ id: string }> },
26+
) {
27+
try {
28+
const { id: escrowId } = await params;
29+
const supabase = getSupabase();
30+
const body = await request.json();
31+
32+
// Validate input
33+
const parsed = disputeSchema.safeParse(body);
34+
if (!parsed.success) {
35+
return NextResponse.json(
36+
{ error: parsed.error.errors[0].message },
37+
{ status: 400 },
38+
);
39+
}
40+
41+
const result = await disputeMultisigEscrow(
42+
supabase,
43+
escrowId,
44+
parsed.data.signer_pubkey,
45+
parsed.data.reason,
46+
);
47+
48+
if (!result.success) {
49+
return NextResponse.json({ error: result.error }, { status: 400 });
50+
}
51+
52+
return NextResponse.json(result.escrow);
53+
} catch (error) {
54+
console.error('Failed to open dispute:', error);
55+
return NextResponse.json(
56+
{ error: 'Internal server error' },
57+
{ status: 500 },
58+
);
59+
}
60+
}
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+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* POST /api/escrow/multisig/:id/sign
3+
*
4+
* Add a signature to a multisig proposal.
5+
* Each participant can sign once. When threshold (2) is reached,
6+
* the proposal is marked as approved and ready for broadcast.
7+
*/
8+
9+
import { NextRequest, NextResponse } from 'next/server';
10+
import { createClient } from '@supabase/supabase-js';
11+
import {
12+
signProposal,
13+
signProposalSchema,
14+
} from '@/lib/multisig';
15+
16+
function getSupabase() {
17+
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
18+
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
19+
if (!url || !key) throw new Error('Supabase not configured');
20+
return createClient(url, key);
21+
}
22+
23+
export async function POST(
24+
request: NextRequest,
25+
{ params }: { params: Promise<{ id: string }> },
26+
) {
27+
try {
28+
const { id: escrowId } = await params;
29+
const supabase = getSupabase();
30+
const body = await request.json();
31+
32+
// Validate input
33+
const parsed = signProposalSchema.safeParse(body);
34+
if (!parsed.success) {
35+
return NextResponse.json(
36+
{ error: parsed.error.errors[0].message },
37+
{ status: 400 },
38+
);
39+
}
40+
41+
const result = await signProposal(
42+
supabase,
43+
escrowId,
44+
parsed.data.proposal_id,
45+
parsed.data.signer_pubkey,
46+
parsed.data.signature,
47+
);
48+
49+
if (!result.success) {
50+
return NextResponse.json({ error: result.error }, { status: 400 });
51+
}
52+
53+
return NextResponse.json({
54+
signature: result.signature,
55+
signatures_collected: result.signatures_collected,
56+
threshold_met: result.threshold_met,
57+
});
58+
} catch (error) {
59+
console.error('Failed to sign proposal:', error);
60+
return NextResponse.json(
61+
{ error: 'Internal server error' },
62+
{ status: 500 },
63+
);
64+
}
65+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* POST /api/escrow/multisig — Create a new multisig escrow
3+
* GET /api/escrow/multisig — Get a multisig escrow by ID (query param)
4+
*
5+
* Non-custodial 2-of-3 multisig escrow.
6+
* CoinPay is a dispute mediator and co-signer — never a custodian.
7+
*/
8+
9+
import { NextRequest, NextResponse } from 'next/server';
10+
import { createClient } from '@supabase/supabase-js';
11+
import { authenticateRequest } from '@/lib/auth/middleware';
12+
import {
13+
createMultisigEscrow,
14+
getMultisigEscrow,
15+
createMultisigEscrowSchema,
16+
isMultisigEnabled,
17+
} from '@/lib/multisig';
18+
19+
function getSupabase() {
20+
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
21+
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
22+
if (!url || !key) throw new Error('Supabase not configured');
23+
return createClient(url, key);
24+
}
25+
26+
/**
27+
* POST /api/escrow/multisig
28+
* Create a new 2-of-3 multisig escrow
29+
*/
30+
export async function POST(request: NextRequest) {
31+
try {
32+
if (!isMultisigEnabled()) {
33+
return NextResponse.json(
34+
{ error: 'Multisig escrow is not enabled' },
35+
{ status: 503 },
36+
);
37+
}
38+
39+
const supabase = getSupabase();
40+
41+
// Authentication required
42+
const authHeader = request.headers.get('authorization');
43+
const apiKeyHeader = request.headers.get('x-api-key');
44+
45+
if (!authHeader && !apiKeyHeader) {
46+
return NextResponse.json(
47+
{ error: 'Authentication required. Provide Authorization header or X-API-Key.' },
48+
{ status: 401 },
49+
);
50+
}
51+
52+
try {
53+
const authResult = await authenticateRequest(supabase, authHeader || apiKeyHeader);
54+
if (!authResult.success) {
55+
return NextResponse.json(
56+
{ error: 'Invalid or expired authentication' },
57+
{ status: 401 },
58+
);
59+
}
60+
} catch {
61+
return NextResponse.json(
62+
{ error: 'Authentication failed' },
63+
{ status: 401 },
64+
);
65+
}
66+
67+
const body = await request.json();
68+
69+
// Validate input
70+
const parsed = createMultisigEscrowSchema.safeParse(body);
71+
if (!parsed.success) {
72+
return NextResponse.json(
73+
{ error: parsed.error.errors[0].message },
74+
{ status: 400 },
75+
);
76+
}
77+
78+
const result = await createMultisigEscrow(supabase, parsed.data);
79+
80+
if (!result.success) {
81+
return NextResponse.json({ error: result.error }, { status: 400 });
82+
}
83+
84+
return NextResponse.json(result.escrow, { status: 201 });
85+
} catch (error) {
86+
console.error('Failed to create multisig escrow:', error);
87+
return NextResponse.json(
88+
{ error: 'Internal server error' },
89+
{ status: 500 },
90+
);
91+
}
92+
}
93+
94+
/**
95+
* GET /api/escrow/multisig?id=<escrow_id>
96+
* Get a multisig escrow by ID
97+
*/
98+
export async function GET(request: NextRequest) {
99+
try {
100+
const supabase = getSupabase();
101+
const { searchParams } = new URL(request.url);
102+
const escrowId = searchParams.get('id');
103+
104+
if (!escrowId) {
105+
return NextResponse.json(
106+
{ error: 'id query parameter is required' },
107+
{ status: 400 },
108+
);
109+
}
110+
111+
const result = await getMultisigEscrow(supabase, escrowId);
112+
113+
if (!result.success) {
114+
return NextResponse.json({ error: result.error }, { status: 404 });
115+
}
116+
117+
return NextResponse.json(result.escrow);
118+
} catch (error) {
119+
console.error('Failed to get multisig escrow:', error);
120+
return NextResponse.json(
121+
{ error: 'Internal server error' },
122+
{ status: 500 },
123+
);
124+
}
125+
}

0 commit comments

Comments
 (0)