Skip to content
Merged
31 changes: 2 additions & 29 deletions src/routes/treasury-proposals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import { Errors } from "../errors";
import { requireUser } from "../plugins/auth";
import { requireMembership, requireAdmin } from "../services/access";
import { stellar } from "../services/stellar";
import { audit } from "../services/audit";
import { isPositive } from "../services/money";
import {
serializeGroup,
Expand Down Expand Up @@ -91,20 +90,6 @@ export default async function treasuryProposalRoutes(app: FastifyInstance) {
threshold
);

await audit({
userId: auth.id,
action: "treasury.proposal.created",
entityType: "treasury_proposal",
entityId: proposal.id,
metadata: {
groupId,
destination: body.destination,
amount: body.amount,
assetCode: body.assetCode,
threshold,
},
});

return {
proposal: serializeTreasuryProposal(proposal),
xdr,
Expand Down Expand Up @@ -172,20 +157,8 @@ export default async function treasuryProposalRoutes(app: FastifyInstance) {
userId: auth.id,
});

await audit({
userId: auth.id,
action:
result.status === "confirmed"
? "treasury.proposal.submitted"
: "treasury.proposal.signed",
entityType: "treasury_proposal",
entityId: proposalId,
metadata: {
signatureCount: result.signatureCount,
threshold: result.threshold,
stellarTxHash: result.stellarTxHash,
},
});
// The service writes signature/submission audit records inside its
// transaction; do not add a second best-effort record after commit.

const proposal = await prisma.treasuryProposal.findUnique({
where: { id: proposalId },
Expand Down
47 changes: 6 additions & 41 deletions src/routes/treasury-signatures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ import { rateLimited } from "../lib/rate-limit";
import { requireAdmin, requireMembership } from "../services/access";
import { treasurySignaturesService } from "../services/treasury-signatures";
import { serializeTreasuryTxProposal } from "../serializers";
import { audit } from "../services/audit";
import { AuditAction } from "../services/audit-actions";

const createBodySchema = z.object({
groupId: z.string().min(1),
Expand Down Expand Up @@ -95,51 +93,18 @@ export default async function treasurySignatureRoutes(app: FastifyInstance) {
if (!existing) throw Errors.notFound("Treasury proposal not found");
await requireAdmin(existing.groupId, auth.id);

let result;
try {
result = await treasurySignaturesService.submitSignature({
proposalId: id,
groupId: existing.groupId,
userId: auth.id,
signedXdr: body.signedXdr,
});
} catch (e: any) {
await audit({
userId: auth.id,
groupId: existing.groupId,
action: AuditAction.TREASURY_TX_PROPOSAL_FAILED,
entityType: "treasury_tx_proposal",
entityId: id,
outcome: "failure",
metadata: {
reason: e instanceof Error ? e.message : String(e),
},
});
throw e;
}
const result = await treasurySignaturesService.submitSignature({
proposalId: id,
groupId: existing.groupId,
userId: auth.id,
signedXdr: body.signedXdr,
});

const proposal = await prisma.treasuryTxProposal.findUnique({
where: { id },
include: { signatures: true },
});

await audit({
userId: auth.id,
groupId: existing.groupId,
action:
result.status === "SUBMITTED"
? AuditAction.TREASURY_TX_PROPOSAL_SUBMITTED
: AuditAction.TREASURY_TX_PROPOSAL_SIGNATURE_ADDED,
entityType: "treasury_tx_proposal",
entityId: id,
metadata: {
status: result.status,
totalWeight: result.totalWeight,
requiredWeight: result.requiredWeight,
stellarTxHash: result.stellarTxHash,
},
});

return {
proposal: serializeTreasuryTxProposal(proposal),
status: result.status,
Expand Down
1 change: 1 addition & 0 deletions src/routes/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ async function webhookManagementRoutes(app: FastifyInstance) {
groupId,
}).catch(() => undefined);


return { queued: true };
});

Expand Down
4 changes: 4 additions & 0 deletions src/services/audit-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const AuditAction = {
GROUP_JOIN: "group.join",
GROUP_LEAVE: "group.leave",
GROUP_MEMBER_REMOVE: "group.member_remove",
GROUP_MEMBER_ROLE_CHANGE: "group.member_role_change",

// ── Treasury ────────────────────────────────────────────────────────
TREASURY_ENABLE: "treasury.enable",
Expand All @@ -44,6 +45,9 @@ export const AuditAction = {

// ── Settlements ─────────────────────────────────────────────────────
SETTLEMENT_CREATED: "settlement.created",
EXPENSE_CREATE: "expense.create",
EXPENSE_UPDATE: "expense.update",
EXPENSE_DELETE: "expense.delete",
SETTLEMENT_CONFIRM_RETRY: "settlement.confirm.retry",
SETTLEMENT_CONFIRM_SUBMITTED: "settlement.confirm.submitted",
SETTLEMENT_CONFIRM_VALIDATION_FAILED: "settlement.confirm.validation_failed",
Expand Down
25 changes: 24 additions & 1 deletion src/services/audit.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,29 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "../db";

const SENSITIVE_KEYS = new Set([
"privatekey",
"secretkey",
"signedxdr",
"transactionxdr",
"xdr",
"token",
"jwt",
"authorization",
"password",
"secret",
]);

function sanitize(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sanitize);
if (!value || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.filter(([key]) => !SENSITIVE_KEYS.has(key.toLowerCase()))
.map(([key, item]) => [key, sanitize(item)])
);
}

/** Whether the audited action succeeded, for operator-facing filtering. */
export type AuditOutcome = "success" | "failure";

Expand Down Expand Up @@ -33,7 +56,7 @@ export function auditData(params: AuditParams) {
entityType: params.entityType,
entityId: params.entityId,
metadata: {
...(params.metadata ?? {}),
...(sanitize(params.metadata ?? {}) as Record<string, unknown>),
...(params.outcome ? { outcome: params.outcome } : {}),
...(params.actorType ? { actorType: params.actorType } : {}),
} as any,
Expand Down
8 changes: 4 additions & 4 deletions src/services/treasury-proposals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ import { config } from "../config";
import { Errors } from "../errors";
import { prisma } from "../db";
import { stellar } from "./stellar";
import { audit, auditTx } from "./audit";
import { auditTx } from "./audit";
import { AuditAction } from "./audit-actions";

export interface CreateProposalParams {
Expand Down Expand Up @@ -426,10 +426,10 @@ export const treasuryProposalsService = {
};
}

// Audit each new signature (best-effort: the proposal update above
// succeeded, and if an audit write fails the signature is still stored).
// Signature persistence, proposal status, and audit records must
// commit together. A failed audit write must roll back the mutation.
for (const pk of verified.slice(stored.length).map((s) => s.publicKey)) {
await audit({
await auditTx(tx, {
groupId: proposal.groupId,
action: AuditAction.TREASURY_PROPOSAL_SIGNED,
entityType: "treasury_proposal",
Expand Down
Loading