Skip to content

Commit 11d49d4

Browse files
Merge pull request #131 from frankosakwe/feature/mainnet-deployment-readiness
feat: Add mainnet deployment readiness security features
2 parents 2ee73ac + 84c0477 commit 11d49d4

10 files changed

Lines changed: 3499 additions & 94 deletions

docs/DEPLOYMENT_GUIDE.md

Lines changed: 462 additions & 0 deletions
Large diffs are not rendered by default.

docs/SECURITY_AUDIT_CHECKLIST.md

Lines changed: 340 additions & 0 deletions
Large diffs are not rendered by default.

docs/THREAT_MODELING.md

Lines changed: 600 additions & 0 deletions
Large diffs are not rendered by default.

lib/escrow/service.ts

Lines changed: 197 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -47,17 +47,34 @@ import {
4747

4848
import { sorobanEscrowAdapter } from './blockchain'
4949
import { escrowRepository } from './repository'
50+
import {
51+
executeCriticalOperation,
52+
type CriticalOperationType,
53+
initializeFailSafe,
54+
} from '@/lib/security/failSafe'
5055

5156
// ---------------------------------------------------------------------------
5257
// Service class
5358
// ---------------------------------------------------------------------------
5459

5560
export class EscrowService {
61+
private failSafeInitialized = false
62+
5663
constructor(
5764
private readonly blockchain: IEscrowBlockchainAdapter = sorobanEscrowAdapter,
5865
private readonly repo: IEscrowRepository = escrowRepository
5966
) {}
6067

68+
/**
69+
* Initialize fail-safe system (call this on application startup)
70+
*/
71+
async initialize(): Promise<void> {
72+
if (!this.failSafeInitialized) {
73+
await initializeFailSafe()
74+
this.failSafeInitialized = true
75+
}
76+
}
77+
6178
// =========================================================================
6279
// createEscrow
6380
// =========================================================================
@@ -169,33 +186,63 @@ export class EscrowService {
169186
throw new EscrowInvalidStateError('Contract has no escrow address — deploy first')
170187
}
171188

172-
// --- Verify on-chain ---
173-
const verification = await this.blockchain.verifyFunding({
174-
contractAddress: contract.escrowAddress,
175-
txHash: input.fundingTxHash,
176-
expectedAmount: input.amount,
177-
currency: contract.currency,
178-
})
179-
180-
if (!verification.verified) {
181-
throw new EscrowFundingVerificationError(
182-
`Funding verification failed: on-chain amount ${verification.onChainAmount} does not match expected ${input.amount}`
183-
)
184-
}
185-
186-
const now = new Date().toISOString()
187-
const updated = await this.repo.updateContractEscrowStatus(
188-
contract.id,
189-
'funded',
189+
// --- Execute with fail-safe ---
190+
const { operation, result } = await executeCriticalOperation(
190191
{
191-
fundedAt: now,
192-
fundingTxHash: input.fundingTxHash,
193-
status: 'active',
194-
startedAt: now,
192+
type: 'escrow_fund' as CriticalOperationType,
193+
userId: parseInt(contract.clientId, 10),
194+
walletAddress: input.callerWalletAddress,
195+
resourceId: contract.id,
196+
data: {
197+
contractId: input.contractId,
198+
fundingTxHash: input.fundingTxHash,
199+
amount: input.amount,
200+
},
201+
amount: Number(input.amount),
202+
},
203+
async () => {
204+
// --- Verify on-chain ---
205+
if (!contract.escrowAddress) {
206+
throw new EscrowInvalidStateError('Contract has no escrow address')
207+
}
208+
209+
const verification = await this.blockchain.verifyFunding({
210+
contractAddress: contract.escrowAddress,
211+
txHash: input.fundingTxHash,
212+
expectedAmount: input.amount,
213+
currency: contract.currency,
214+
})
215+
216+
if (!verification.verified) {
217+
throw new EscrowFundingVerificationError(
218+
`Funding verification failed: on-chain amount ${verification.onChainAmount} does not match expected ${input.amount}`
219+
)
220+
}
221+
222+
const now = new Date().toISOString()
223+
const updated = await this.repo.updateContractEscrowStatus(
224+
contract.id,
225+
'funded',
226+
{
227+
fundedAt: now,
228+
fundingTxHash: input.fundingTxHash,
229+
status: 'active',
230+
startedAt: now,
231+
}
232+
)
233+
234+
return { contract: updated, fundedAt: now }
195235
}
196236
)
197237

198-
return { contract: updated, fundedAt: now }
238+
// If operation requires approval, return pending status
239+
if (operation.requiresApproval && operation.status === 'pending') {
240+
throw new EscrowInvalidStateError(
241+
'Operation requires admin approval. Please wait for approval before proceeding.'
242+
)
243+
}
244+
245+
return result
199246
}
200247

201248
// =========================================================================
@@ -246,55 +293,85 @@ export class EscrowService {
246293
throw new EscrowInvalidStateError('Contract has no escrow address')
247294
}
248295

249-
// --- Resolve freelancer wallet ---
250-
const freelancerWallet = await this.repo.getUserWalletAddress(contract.freelancerId)
251-
if (!freelancerWallet) {
252-
throw new EscrowValidationError('Freelancer wallet address not found')
253-
}
254-
255-
// --- Trigger on-chain release ---
256-
let release: { txHash: string }
257-
try {
258-
release = await this.blockchain.releaseMilestoneFunds({
259-
contractAddress: contract.escrowAddress,
260-
milestoneId: milestone.id,
261-
recipientAddress: freelancerWallet,
262-
amount: milestone.amount,
263-
currency: milestone.currency,
264-
})
265-
} catch (err) {
266-
if (err instanceof EscrowBlockchainError) throw err
267-
throw new EscrowBlockchainError('Failed to release milestone funds on-chain', err)
268-
}
269-
270-
const now = new Date().toISOString()
296+
// --- Execute with fail-safe ---
297+
const { operation, result } = await executeCriticalOperation(
298+
{
299+
type: 'escrow_release' as CriticalOperationType,
300+
userId: parseInt(contract.clientId, 10),
301+
walletAddress: input.callerWalletAddress,
302+
resourceId: milestone.id,
303+
data: {
304+
contractId: input.contractId,
305+
milestoneId: input.milestoneId,
306+
amount: milestone.amount,
307+
},
308+
amount: Number(milestone.amount),
309+
},
310+
async () => {
311+
// --- Resolve freelancer wallet ---
312+
const freelancerWallet = await this.repo.getUserWalletAddress(contract.freelancerId)
313+
if (!freelancerWallet) {
314+
throw new EscrowValidationError('Freelancer wallet address not found')
315+
}
316+
317+
// --- Trigger on-chain release ---
318+
if (!contract.escrowAddress) {
319+
throw new EscrowInvalidStateError('Contract has no escrow address')
320+
}
321+
322+
let release: { txHash: string }
323+
try {
324+
release = await this.blockchain.releaseMilestoneFunds({
325+
contractAddress: contract.escrowAddress,
326+
milestoneId: milestone.id,
327+
recipientAddress: freelancerWallet,
328+
amount: milestone.amount,
329+
currency: milestone.currency,
330+
})
331+
} catch (err) {
332+
if (err instanceof EscrowBlockchainError) throw err
333+
throw new EscrowBlockchainError('Failed to release milestone funds on-chain', err)
334+
}
335+
336+
const now = new Date().toISOString()
337+
338+
// --- Update milestone ---
339+
const updatedMilestone = await this.repo.updateMilestoneStatus(
340+
milestone.id,
341+
'paid',
342+
{ releaseTxHash: release.txHash, paidAt: now }
343+
)
271344

272-
// --- Update milestone ---
273-
const updatedMilestone = await this.repo.updateMilestoneStatus(
274-
milestone.id,
275-
'paid',
276-
{ releaseTxHash: release.txHash, paidAt: now }
277-
)
345+
// --- Determine new escrow / contract status ---
346+
const allMilestones = await this.repo.getMilestonesByContractId(contract.id)
347+
const allPaid = allMilestones.every(
348+
(m) => m.id === milestone.id ? true : m.status === 'paid'
349+
)
278350

279-
// --- Determine new escrow / contract status ---
280-
const allMilestones = await this.repo.getMilestonesByContractId(contract.id)
281-
const allPaid = allMilestones.every(
282-
(m) => m.id === milestone.id ? true : m.status === 'paid'
283-
)
351+
const newEscrowStatus = allPaid ? 'fully_released' : 'partially_released'
352+
const updatedContract = await this.repo.updateContractEscrowStatus(
353+
contract.id,
354+
newEscrowStatus,
355+
allPaid ? { status: 'completed', completedAt: now } : undefined
356+
)
284357

285-
const newEscrowStatus = allPaid ? 'fully_released' : 'partially_released'
286-
const updatedContract = await this.repo.updateContractEscrowStatus(
287-
contract.id,
288-
newEscrowStatus,
289-
allPaid ? { status: 'completed', completedAt: now } : undefined
358+
return {
359+
milestone: updatedMilestone,
360+
contract: updatedContract,
361+
releaseTxHash: release.txHash,
362+
allMilestonesPaid: allPaid,
363+
}
364+
}
290365
)
291366

292-
return {
293-
milestone: updatedMilestone,
294-
contract: updatedContract,
295-
releaseTxHash: release.txHash,
296-
allMilestonesPaid: allPaid,
367+
// If operation requires approval, return pending status
368+
if (operation.requiresApproval && operation.status === 'pending') {
369+
throw new EscrowInvalidStateError(
370+
'Operation requires admin approval. Please wait for approval before proceeding.'
371+
)
297372
}
373+
374+
return result
298375
}
299376

300377
// =========================================================================
@@ -337,38 +414,64 @@ export class EscrowService {
337414
throw new EscrowInvalidStateError('Contract has no escrow address')
338415
}
339416

340-
// --- Resolve client wallet ---
341-
const clientWallet = await this.repo.getUserWalletAddress(contract.clientId)
342-
if (!clientWallet) {
343-
throw new EscrowValidationError('Client wallet address not found')
344-
}
345-
346-
// --- Trigger on-chain refund ---
347-
let refund: { txHash: string }
348-
try {
349-
refund = await this.blockchain.refundEscrow({
350-
contractAddress: contract.escrowAddress,
351-
clientAddress: clientWallet,
352-
amount: contract.totalAmount,
353-
currency: contract.currency,
354-
})
355-
} catch (err) {
356-
if (err instanceof EscrowBlockchainError) throw err
357-
throw new EscrowBlockchainError('Failed to refund escrow on-chain', err)
358-
}
359-
360-
const now = new Date().toISOString()
361-
const updatedContract = await this.repo.updateContractEscrowStatus(
362-
contract.id,
363-
'refunded',
417+
// --- Execute with fail-safe ---
418+
const { operation, result } = await executeCriticalOperation(
364419
{
365-
status: 'cancelled',
366-
cancelledAt: now,
367-
cancellationReason: input.reason,
420+
type: 'escrow_refund' as CriticalOperationType,
421+
userId: parseInt(contract.clientId, 10),
422+
walletAddress: input.callerWalletAddress,
423+
resourceId: contract.id,
424+
data: {
425+
contractId: input.contractId,
426+
reason: input.reason,
427+
amount: contract.totalAmount,
428+
},
429+
amount: Number(contract.totalAmount),
430+
},
431+
async () => {
432+
// --- Resolve client wallet ---
433+
const clientWallet = await this.repo.getUserWalletAddress(contract.clientId)
434+
if (!clientWallet) {
435+
throw new EscrowValidationError('Client wallet address not found')
436+
}
437+
438+
// --- Trigger on-chain refund ---
439+
let refund: { txHash: string }
440+
try {
441+
refund = await this.blockchain.refundEscrow({
442+
contractAddress: contract.escrowAddress,
443+
clientAddress: clientWallet,
444+
amount: contract.totalAmount,
445+
currency: contract.currency,
446+
})
447+
} catch (err) {
448+
if (err instanceof EscrowBlockchainError) throw err
449+
throw new EscrowBlockchainError('Failed to refund escrow on-chain', err)
450+
}
451+
452+
const now = new Date().toISOString()
453+
const updatedContract = await this.repo.updateContractEscrowStatus(
454+
contract.id,
455+
'refunded',
456+
{
457+
status: 'cancelled',
458+
cancelledAt: now,
459+
cancellationReason: input.reason,
460+
}
461+
)
462+
463+
return { contract: updatedContract, refundTxHash: refund.txHash }
368464
}
369465
)
370466

371-
return { contract: updatedContract, refundTxHash: refund.txHash }
467+
// If operation requires approval, return pending status
468+
if (operation.requiresApproval && operation.status === 'pending') {
469+
throw new EscrowInvalidStateError(
470+
'Operation requires admin approval. Please wait for approval before proceeding.'
471+
)
472+
}
473+
474+
return result
372475
}
373476

374477
// =========================================================================

0 commit comments

Comments
 (0)