Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions backend/src/emailQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { PrismaClient } from '@prisma/client';
import { getPrismaClient } from './prismaClient';
import { emailService, EmailOptions } from './emailService';
import { logger } from './middleware/structuredLogging';
import { captureRequestContext } from './requestContext';

interface EmailQueueItem {
id: string;
Expand Down Expand Up @@ -31,6 +32,11 @@ export class EmailQueueService {
}

async enqueueEmail(options: EmailOptions): Promise<EmailQueueItem> {
// Capture context at enqueue time so it can be used when sending.
// Note: the EmailQueue model doesn't persist context fields, so
// propagation relies on AsyncLocalStorage being available when processed.
void captureRequestContext();

return this.queueDelegate.create({
data: {
to: options.to,
Expand Down Expand Up @@ -183,3 +189,4 @@ export class EmailQueueService {
}

export const emailQueueService = new EmailQueueService();

23 changes: 20 additions & 3 deletions backend/src/emailService.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { logger } from './middleware/structuredLogging';
import { emailQueueService } from './emailQueue';
import { getActiveCorrelationId, getActiveRequestId } from './requestContext';

// Known mainnet passphrases that map to the public (mainnet) explorer.
const MAINNET_PASSPHRASES = new Set([
'Public Global Stellar Network ; September 2015',
]);


/**
* Returns the stellar.expert transaction URL for the given hash, selecting
* the correct network segment from STELLAR_NETWORK or STELLAR_NETWORK_PASSPHRASE.
Expand Down Expand Up @@ -42,10 +44,21 @@ export interface TransactionEmailDetails {
* Supports SendGrid and Resend providers.
*/
export class EmailService {
private getCorrelationHeaders(): Record<string, string> {
const correlationId = getActiveCorrelationId();
const requestId = getActiveRequestId();

const headers: Record<string, string> = {};
if (correlationId) headers['X-Correlation-ID'] = correlationId;
if (requestId) headers['X-Request-ID'] = requestId;
return headers;
}

private provider: 'sendgrid' | 'resend';
private apiKey: string;
private fromEmail: string;


constructor() {
this.provider = (process.env.EMAIL_PROVIDER as 'sendgrid' | 'resend') || 'resend';
this.apiKey = process.env.EMAIL_API_KEY || '';
Expand Down Expand Up @@ -93,7 +106,7 @@ export class EmailService {
}

const success = await this.simulateProviderCall(options);

if (success) {
logger.log('info', `Email sent successfully to ${options.to} via ${this.provider}`);
} else {
Expand All @@ -117,7 +130,7 @@ export class EmailService {
async sendDepositConfirmation(to: string, details: TransactionEmailDetails): Promise<boolean> {
const explorerLink = getStellarExplorerUrl(details.txHash);
const subject = `Deposit Confirmed - ${details.amount} ${details.asset}`;

const text = `Your deposit of ${details.amount} ${details.asset} has been confirmed on-chain.
Date: ${details.date}
Transaction Hash: ${details.txHash}
Expand All @@ -142,7 +155,7 @@ View on Stellar Explorer: ${explorerLink}`;
async sendWithdrawalConfirmation(to: string, details: TransactionEmailDetails): Promise<boolean> {
const explorerLink = getStellarExplorerUrl(details.txHash);
const subject = `Withdrawal Confirmed - ${details.amount} ${details.asset}`;

const text = `Your withdrawal of ${details.amount} ${details.asset} has been confirmed on-chain.
Date: ${details.date}
Transaction Hash: ${details.txHash}
Expand All @@ -165,13 +178,16 @@ View on Stellar Explorer: ${explorerLink}`;
* Simulates a call to the email provider API.
*/
private async simulateProviderCall(options: EmailOptions): Promise<boolean> {
const correlationHeaders = this.getCorrelationHeaders();

if (this.provider === 'resend') {
try {
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
...correlationHeaders,
},
body: JSON.stringify({
from: this.fromEmail,
Expand All @@ -193,6 +209,7 @@ View on Stellar Explorer: ${explorerLink}`;
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
...correlationHeaders,
},
body: JSON.stringify({
personalizations: [{ to: [{ email: options.to }] }],
Expand Down
9 changes: 9 additions & 0 deletions backend/src/webhookDelivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
webhookDeduplicationStore,
WebhookDeduplicationStore,
} from './webhookDeduplication';
import { getActiveCorrelationId, getActiveRequestId } from './requestContext';

export type TransactionEventType =
| 'transaction.deposit.created'
Expand Down Expand Up @@ -634,13 +635,21 @@ async function deliverWithRetry(
};

const body = JSON.stringify(envelope);
const correlationId = getActiveCorrelationId();
const requestId = getActiveRequestId();

const headers: Record<string, string> = {
'Content-Type': 'application/json',
'User-Agent': 'YieldVault-Webhook-Delivery/1.0',
'X-YieldVault-Event': delivery.eventType,
'X-YieldVault-Delivery-Id': delivery.id,
};

// Propagate correlation identifiers for traceability.
// Downstream systems can use these to correlate webhook requests.
if (correlationId) headers['X-Correlation-ID'] = correlationId;
if (requestId) headers['X-Request-ID'] = requestId;

if (endpoint.secret) {
headers['X-YieldVault-Signature'] = createWebhookSignature(endpoint.secret, envelope);
}
Expand Down
15 changes: 8 additions & 7 deletions contracts/vault/src/emergency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,10 @@ pub fn read_proposal(env: &Env, id: u32) -> Option<EmergencyProposal> {
}

pub fn write_proposal(env: &Env, id: u32, proposal: &EmergencyProposal) {
env.storage()
.instance()
.set(&DataKey::Emergency(EmergencyStorageKey::Proposal(id)), proposal);
env.storage().instance().set(
&DataKey::Emergency(EmergencyStorageKey::Proposal(id)),
proposal,
);
}

pub fn next_proposal_id(env: &Env) -> u32 {
Expand All @@ -70,9 +71,10 @@ pub fn next_proposal_id(env: &Env) -> u32 {
.get(&DataKey::Emergency(EmergencyStorageKey::ProposalNonce))
.unwrap_or(0);
let next = nonce.checked_add(1).expect("proposal nonce overflow");
env.storage()
.instance()
.set(&DataKey::Emergency(EmergencyStorageKey::ProposalNonce), &next);
env.storage().instance().set(
&DataKey::Emergency(EmergencyStorageKey::ProposalNonce),
&next,
);
next
}

Expand Down Expand Up @@ -153,7 +155,6 @@ pub fn simulate_emergency_unwind(
#[cfg(test)]
mod tests {
use super::*;
use soroban_sdk::testutils::Address as _;

#[test]
fn test_distinct_approvers_required() {
Expand Down
Loading
Loading