1import Transaction from "./model.js";
2import { resolveTreeAccess } from "../../seed/tree/treeAccess.js";
3
4// Services wired from init() via setServices()
5let Node = null;
6let Contribution = null;
7let logContribution = async () => {};
8let useEnergy = async () => ({ energyUsed: 0 });
9let _metadata = null;
10
11export function setServices({ models, contributions, metadata }) {
12 Node = models.Node;
13 Contribution = models.Contribution;
14 logContribution = contributions.logContribution;
15 if (metadata) _metadata = metadata;
16}
17export function setEnergyService(energy) { useEnergy = energy.useEnergy; }
18
19function getPolicy(node) {
20 // Read from metadata first, fall back to schema field for migration period
21 const meta = _metadata.getExtMeta(node, "transactions");
22 return meta.policy || node.transactionPolicy || "OWNER_ONLY";
23}
24
25function getPolicyFromLean(node) {
26 // For .lean() results where metadata is a plain object
27 const meta = node.metadata?.transactions || (node.metadata instanceof Map ? node.metadata.get("transactions") : null) || {};
28 return meta.policy || node.transactionPolicy || "OWNER_ONLY";
29}
30
31function assertPositiveMap(map, label) {
32 for (const [key, value] of map) {
33 if (typeof value !== "number" || !Number.isFinite(value)) {
34 throw new Error(`Invalid ${label} value for ${key}`);
35 }
36 if (value <= 0) {
37 throw new Error(`${label} value for ${key} must be > 0`);
38 }
39 }
40}
41function assertNonNegativeTradeMap(input, label) {
42 if (!input) return;
43
44 // Case 1: Mongoose Map
45 if (input instanceof Map) {
46 for (const [key, value] of input.entries()) {
47 validateValue(key, value, label);
48 }
49 return;
50 }
51
52 // Case 2: Plain object (from request body)
53 if (typeof input === "object") {
54 for (const [key, value] of Object.entries(input)) {
55 validateValue(key, value, label);
56 }
57 return;
58 }
59
60 throw new Error(`${label} values must be an object or Map`);
61}
62
63// Read/write node values via the values extension API (not direct namespace access)
64let _valuesExt = null;
65async function getValuesExt() {
66 if (!_valuesExt) {
67 try {
68 const { getExtension } = await import("../loader.js");
69 _valuesExt = getExtension("values");
70 } catch {}
71 }
72 return _valuesExt;
73}
74
75function getNodeValues(node) {
76 // Sync read: use values export if cached, fallback to direct read
77 if (_valuesExt?.exports?.getNodeValues) return _valuesExt.exports.getNodeValues(node);
78 return { ..._metadata.getExtMeta(node, "values") };
79}
80
81async function setNodeValues(node, values) {
82 const ext = await getValuesExt();
83 if (ext?.exports?.setNodeValues) return ext.exports.setNodeValues(node, values);
84 await _metadata.setExtMeta(node, "values", values);
85}
86
87function hasTradeValues(input) {
88 if (!input) return false;
89
90 if (input instanceof Map) {
91 return input.size > 0;
92 }
93
94 if (typeof input === "object") {
95 return Object.keys(input).length > 0;
96 }
97
98 return false;
99}
100
101function validateValue(key, value, label) {
102 if (typeof value !== "number" || !Number.isFinite(value)) {
103 throw new Error(`Invalid ${label} value for "${key}"`);
104 }
105
106 if (value < 0) {
107 throw new Error(`Negative value not allowed for "${key}" on ${label}`);
108 }
109
110 if (value === 0) {
111 throw new Error(`Zero value is not allowed for "${key}" on ${label}`);
112 }
113}
114
115const ALLOWED_POLICIES = ["OWNER_ONLY", "ANYONE", "MAJORITY", "ALL"];
116
117export async function setTransactionPolicy({ rootNodeId, policy, userId }) {
118 if (!ALLOWED_POLICIES.includes(policy)) {
119 throw new Error("Invalid transaction policy");
120 }
121
122 const root = await Node.findById(rootNodeId).select(
123 "rootOwner parent metadata",
124 );
125 if (!root) {
126 throw new Error("Root not found");
127 }
128
129 if (!root.rootOwner) {
130 throw new Error("Not a root node");
131 }
132
133 if (root.rootOwner.toString() !== userId.toString()) {
134 throw new Error("Only root owner can change transaction policy");
135 }
136 if (getPolicy(root) === policy) {
137 throw new Error("This transaction policy is already set");
138 }
139
140 await _metadata.setExtMeta(root, "transactions", { ..._metadata.getExtMeta(root, "transactions"), policy });
141
142 return {
143 rootId: rootNodeId,
144 policy,
145 };
146}
147
148const validateTransactionSides = ({ sideA, sideB }) => {
149 const outsideCount =
150 (sideA.kind === "OUTSIDE" ? 1 : 0) + (sideB.kind === "OUTSIDE" ? 1 : 0);
151
152 if (outsideCount > 1) {
153 throw new Error("Only one transaction side may be OUTSIDE.");
154 }
155};
156
157export const createTransaction = async ({
158 sideA,
159 sideB,
160 valuesA,
161 valuesB,
162 userId,
163}) => {
164 validateTransactionSides({ sideA, sideB });
165 assertNonNegativeTradeMap(valuesA, "sideA");
166 assertNonNegativeTradeMap(valuesB, "sideB");
167
168 let nodeA = null;
169 let nodeB = null;
170
171 const hasA = hasTradeValues(valuesA);
172 const hasB = hasTradeValues(valuesB);
173
174 if (!hasA && !hasB) {
175 throw new Error("Transaction must trade at least one value");
176 }
177
178 if (sideA.kind === "NODE") {
179 nodeA = await Node.findById(sideA.nodeId);
180 if (!nodeA) throw new Error("Node A not found");
181 }
182
183 if (sideB.kind === "NODE") {
184 nodeB = await Node.findById(sideB.nodeId);
185 if (!nodeB) throw new Error("Node B not found");
186 }
187
188 if (
189 sideA.kind === "NODE" &&
190 sideB.kind === "NODE" &&
191 String(sideA.nodeId) === String(sideB.nodeId)
192 ) {
193 throw new Error("Transactions between the same node are not allowed");
194 }
195
196 if (sideA.kind === "NODE") {
197 await resolveTreeAccess(sideA.nodeId, userId);
198 }
199
200 if (sideB.kind === "NODE") {
201 await resolveTreeAccess(sideB.nodeId, userId);
202 }
203
204 const approvalGroups = await buildApprovalGroups({ sideA, sideB }, userId);
205
206 const allResolved = approvalGroups.every((g) => g.resolved);
207
208 const { energyUsed } = await useEnergy({
209 userId,
210 action: "transaction",
211 });
212
213 const transaction = await Transaction.create({
214 sideA,
215 sideB,
216 versionAIndex: 0,
217 versionBIndex: 0,
218 valuesTraded: {
219 sideA: valuesA,
220 sideB: valuesB,
221 },
222 approvalGroups,
223 status: allResolved ? "accepted" : "pending",
224 });
225 if (nodeA) {
226 await logContribution({
227 userId,
228 nodeId: nodeA._id,
229 action: "transaction",
230 tradeId: transaction._id,
231 nodeVersion: "0",
232 transactionMeta: {
233 event: "created",
234 side: "A",
235 role: "proposer",
236 counterpartyNodeId: nodeB?._id ?? null,
237 versionSelf: "0",
238 versionCounterparty: "0",
239 actorUserId: userId,
240 },
241 energyUsed,
242 });
243 }
244
245 if (nodeB) {
246 await logContribution({
247 userId,
248 nodeId: nodeB._id,
249 action: "transaction",
250 tradeId: transaction._id,
251 nodeVersion: "0",
252 transactionMeta: {
253 event: "created",
254 side: "B",
255 role: "counterparty",
256 counterpartyNodeId: nodeA?._id ?? null,
257 versionSelf: "0",
258 versionCounterparty: "0",
259 actorUserId: userId,
260 },
261 energyUsed,
262 });
263 }
264
265 if (transaction.status === "accepted") {
266 await executeTransaction(transaction, userId);
267 }
268
269 return transaction;
270};
271
272export async function getTransactions({
273 nodeId,
274 version,
275 includePending = false,
276 userId,
277}) {
278 if (!nodeId) throw new Error("nodeId is required");
279
280 const statusFilter = includePending
281 ? { $in: ["pending", "accepted", "rejected"] }
282 : "accepted";
283
284 const txs = await Transaction.find({
285 $or: [{ "sideA.nodeId": nodeId }, { "sideB.nodeId": nodeId }],
286 status: statusFilter,
287 })
288 .sort({ createdAt: -1 })
289 .lean()
290 .exec();
291
292 const formatted = [];
293
294 for (const tx of txs) {
295 const viewerApprovals = tx.approvalGroups.filter((g) =>
296 g.eligibleApprovers.includes(String(userId)),
297 );
298
299 const viewerAlreadyApproved = viewerApprovals.some((g) =>
300 g.approvals.some((a) => a.userId === String(userId)),
301 );
302
303 const canApprove =
304 tx.status === "pending" &&
305 viewerApprovals.length > 0 &&
306 !viewerAlreadyApproved;
307
308 const isA = tx.sideA?.nodeId === nodeId;
309 const isB = tx.sideB?.nodeId === nodeId;
310
311 if (!isA && !isB) continue;
312
313 const selfSide = isA ? "A" : "B";
314 const otherSide = isA ? "B" : "A";
315
316 const selfVersion = 0;
317
318 const counterpartyNodeId = tx[`side${otherSide}`]?.nodeId ?? null;
319
320 const counterparty = counterpartyNodeId
321 ? await Node.findById(counterpartyNodeId).select("name").lean()
322 : null;
323
324 const approvalSummary = tx.approvalGroups
325 .map((g) => ({
326 policy: g.policy,
327 required: g.requiredApprovals,
328 approved: g.approvals.length,
329 resolved: g.resolved,
330 isViewerGroup: g.eligibleApprovers.includes(String(userId)),
331 }))
332 .sort((a, b) => (b.isViewerGroup ? 1 : 0) - (a.isViewerGroup ? 1 : 0));
333 formatted.push({
334 _id: tx._id,
335 createdAt: tx.createdAt,
336 perspective: isA ? "nodeA" : "nodeB",
337 canApprove,
338 canDeny: canApprove,
339 versionSelf: 0,
340 versionCounterparty: 0,
341 valuesSent: tx.valuesTraded[`side${selfSide}`] ?? {},
342 valuesReceived: tx.valuesTraded[`side${otherSide}`] ?? {},
343 counterparty,
344 status: tx.status,
345 approvalSummary,
346 });
347 }
348
349 return {
350 transactions: formatted,
351 };
352}
353
354export async function executeTransaction(transaction, userId) {
355 // 0) Basic guards
356 if (!transaction) throw new Error("Transaction is required");
357
358 if (transaction.executedAt) {
359 throw new Error("Transaction already executed");
360 }
361
362 if (transaction.status !== "accepted") {
363 throw new Error("Transaction is not accepted");
364 }
365
366 const { sideA, sideB, valuesTraded } =
367 transaction;
368
369 // 1) Normalize traded values
370 const sideAObj =
371 valuesTraded?.sideA instanceof Map
372 ? Object.fromEntries(valuesTraded.sideA.entries())
373 : (valuesTraded?.sideA ?? {});
374
375 const sideBObj =
376 valuesTraded?.sideB instanceof Map
377 ? Object.fromEntries(valuesTraded.sideB.entries())
378 : (valuesTraded?.sideB ?? {});
379
380 assertPositiveMap(Object.entries(sideAObj), "sideA");
381 assertPositiveMap(Object.entries(sideBObj), "sideB");
382
383 // 2) Load nodes
384 let nodeA = null;
385 let nodeB = null;
386
387 if (sideA?.kind === "NODE") {
388 nodeA = await Node.findById(sideA.nodeId);
389 if (!nodeA) throw new Error("Node A not found");
390 }
391
392 if (sideB?.kind === "NODE") {
393 nodeB = await Node.findById(sideB.nodeId);
394 if (!nodeB) throw new Error("Node B not found");
395 }
396
397 // 3) Validate versions
398 // 4) Pre-check balances
399 if (nodeA && nodeB) {
400 const vA = getNodeValues(nodeA);
401 const vB = getNodeValues(nodeB);
402
403 for (const [k, v] of Object.entries(sideAObj)) {
404 if ((vA[k] || 0) < v) {
405 throw new Error(`Insufficient ${k} for node A`);
406 }
407 }
408
409 for (const [k, v] of Object.entries(sideBObj)) {
410 if ((vB[k] || 0) < v) {
411 throw new Error(`Insufficient ${k} for node B`);
412 }
413 }
414 }
415
416 // 5) execution_started contributions
417 if (nodeA) {
418 await logContribution({
419 userId,
420 nodeId: nodeA._id,
421 action: "transaction",
422 tradeId: transaction._id,
423 nodeVersion: "0",
424 transactionMeta: {
425 event: "execution_started",
426 side: "A",
427 role: "sender",
428 counterpartyNodeId: nodeB?._id ?? null,
429 versionSelf: "0",
430 versionCounterparty: "0",
431 actorUserId: userId,
432 },
433 });
434 }
435
436 if (nodeB) {
437 await logContribution({
438 userId,
439 nodeId: nodeB._id,
440 action: "transaction",
441 tradeId: transaction._id,
442 nodeVersion: "0",
443 transactionMeta: {
444 event: "execution_started",
445 side: "B",
446 role: "receiver",
447 counterpartyNodeId: nodeA?._id ?? null,
448 versionSelf: "0",
449 versionCounterparty: "0",
450 actorUserId: userId,
451 },
452 });
453 }
454
455 // 6) MUTATION + PERSISTENCE (ATOMIC)
456 try {
457 // NODE ↔ NODE
458 if (nodeA && nodeB) {
459 const vA = getNodeValues(nodeA);
460 const vB = getNodeValues(nodeB);
461
462 for (const [k, v] of Object.entries(sideAObj)) {
463 vA[k] = (vA[k] || 0) - v;
464 vB[k] = (vB[k] || 0) + v;
465 }
466
467 for (const [k, v] of Object.entries(sideBObj)) {
468 vB[k] = (vB[k] || 0) - v;
469 vA[k] = (vA[k] || 0) + v;
470 }
471
472 await setNodeValues(nodeA, vA);
473 await setNodeValues(nodeB, vB);
474 }
475
476 // NODE ↔ OUTSIDE (A sends)
477 if (nodeA && !nodeB) {
478 const vA = getNodeValues(nodeA);
479 for (const [k, v] of Object.entries(sideAObj)) {
480 vA[k] = (vA[k] || 0) - v;
481 }
482 await setNodeValues(nodeA, vA);
483 }
484
485 // NODE ↔ OUTSIDE (B receives)
486 if (!nodeA && nodeB) {
487 const vB = getNodeValues(nodeB);
488 for (const [k, v] of Object.entries(sideBObj)) {
489 vB[k] = (vB[k] || 0) + v;
490 }
491 await setNodeValues(nodeB, vB);
492 }
493
494 transaction.executedAt = new Date();
495 await transaction.save();
496 } catch (err) {
497 transaction.status = "rejected";
498 await transaction.save();
499
500 if (nodeA) {
501 await logContribution({
502 userId,
503 nodeId: nodeA._id,
504 action: "transaction",
505 tradeId: transaction._id,
506 nodeVersion: "0",
507 transactionMeta: {
508 event: "failed",
509 side: "A",
510 role: "sender",
511 counterpartyNodeId: nodeB?._id ?? null,
512 versionSelf: "0",
513 versionCounterparty: "0",
514 valuesSent: sideAObj,
515 valuesReceived: sideBObj ?? {},
516 failureReason: err.message,
517 actorUserId: userId,
518 },
519 });
520 }
521
522 if (nodeB) {
523 await logContribution({
524 userId,
525 nodeId: nodeB._id,
526 action: "transaction",
527 tradeId: transaction._id,
528 nodeVersion: "0",
529 transactionMeta: {
530 event: "failed",
531 side: "B",
532 role: "receiver",
533 counterpartyNodeId: nodeA?._id ?? null,
534 versionSelf: "0",
535 versionCounterparty: "0",
536 valuesSent: sideBObj ?? {},
537 valuesReceived: sideAObj ?? {},
538 failureReason: err.message,
539 actorUserId: userId,
540 },
541 });
542 }
543
544 throw err;
545 }
546
547 // 7) SUCCESS contributions
548 if (nodeA) {
549 await logContribution({
550 userId,
551 nodeId: nodeA._id,
552 action: "transaction",
553 tradeId: transaction._id,
554 nodeVersion: "0",
555 transactionMeta: {
556 event: "succeeded",
557 side: "A",
558 role: "sender",
559 counterpartyNodeId: nodeB?._id ?? null,
560 versionSelf: "0",
561 versionCounterparty: "0",
562 valuesSent: sideAObj,
563 valuesReceived: sideBObj ?? {},
564 actorUserId: userId,
565 },
566 });
567 }
568
569 if (nodeB) {
570 await logContribution({
571 userId,
572 nodeId: nodeB._id,
573 action: "transaction",
574 tradeId: transaction._id,
575 nodeVersion: "0",
576 transactionMeta: {
577 event: "succeeded",
578 side: "B",
579 role: "receiver",
580 counterpartyNodeId: nodeA?._id ?? null,
581 versionSelf: "0",
582 versionCounterparty: "0",
583 valuesSent: sideBObj ?? {},
584 valuesReceived: sideAObj ?? {},
585 actorUserId: userId,
586 },
587 });
588 }
589
590 return transaction;
591}
592
593/**
594 * Build approval groups for each NODE side
595 */
596export async function buildApprovalGroups({ sideA, sideB }, userId) {
597 const approvalGroups = [];
598
599 for (const side of [sideA, sideB]) {
600 if (side.kind !== "NODE") continue;
601
602 const access = await resolveTreeAccess(side.nodeId, userId);
603
604 const rootNode = await Node.findById(access.rootId)
605 .select("rootOwner contributors metadata")
606 .lean()
607 .exec();
608
609 if (!rootNode) {
610 throw new Error("Root node not found");
611 }
612
613 const owner = rootNode.rootOwner;
614 const contributors = rootNode.contributors ?? [];
615 const policy = getPolicyFromLean(rootNode);
616
617 const members = [owner, ...contributors].map(String);
618
619 let eligibleApprovers;
620 let requiredApprovals;
621
622 switch (policy) {
623 case "OWNER_ONLY":
624 eligibleApprovers = [String(owner)];
625 requiredApprovals = 1;
626 break;
627
628 case "ANYONE":
629 eligibleApprovers = members;
630 requiredApprovals = 1;
631 break;
632
633 case "MAJORITY":
634 eligibleApprovers = members;
635 requiredApprovals = Math.ceil(members.length / 2);
636 break;
637
638 case "ALL":
639 eligibleApprovers = members;
640 requiredApprovals = members.length;
641 break;
642
643 default:
644 throw new Error("Invalid transaction policy");
645 }
646
647 const approvals = [];
648
649 // Proposer auto-approves if eligible
650 if (eligibleApprovers.includes(String(userId))) {
651 approvals.push({ userId: String(userId), approvedAt: new Date() });
652 }
653
654 approvalGroups.push({
655 rootId: access.rootId,
656 policy,
657 eligibleApprovers,
658 requiredApprovals,
659 approvals,
660 resolved: approvals.length >= requiredApprovals,
661 side: side === sideA ? "A" : "B",
662 });
663 }
664
665 return approvalGroups;
666}
667
668export async function applyApproval(transactionId, userId) {
669 const transaction = await Transaction.findById(transactionId);
670 const { sideA, sideB, valuesTraded } =
671 transaction;
672
673 if (!transaction) {
674 throw new Error("Transaction not found");
675 }
676
677 if (transaction.status !== "pending") {
678 throw new Error("Transaction is not pending");
679 }
680
681 let approvedSomething = false;
682
683 for (const group of transaction.approvalGroups) {
684 if (group.resolved) continue;
685
686 if (!group.eligibleApprovers.includes(String(userId))) {
687 continue;
688 }
689
690 const alreadyApproved = group.approvals.some(
691 (a) => a.userId === String(userId),
692 );
693
694 if (alreadyApproved) continue;
695
696 group.approvals.push({
697 userId: String(userId),
698 approvedAt: new Date(),
699 });
700
701 await logContribution({
702 userId,
703 nodeId: group.side === "A" ? sideA.nodeId : sideB.nodeId,
704 action: "transaction",
705 tradeId: transaction._id,
706 nodeVersion: "0",
707 transactionMeta: {
708 event: "approved",
709 side: group.side,
710 role: "approver",
711 counterpartyNodeId:
712 group.side === "A"
713 ? (sideB?.nodeId ?? null)
714 : (sideA?.nodeId ?? null),
715 versionSelf: "0",
716 versionCounterparty: "0",
717 actorUserId: userId,
718 },
719 });
720
721 if (group.approvals.length >= group.requiredApprovals) {
722 group.resolved = true;
723 }
724
725 approvedSomething = true;
726 }
727
728 if (!approvedSomething) {
729 throw new Error("User is not eligible to approve this transaction");
730 }
731
732 await transaction.save();
733
734 if (checkAllGroupsResolved(transaction)) {
735 transaction.status = "accepted";
736 await transaction.save();
737
738 await logResolution(transaction, "accepted", userId);
739
740 try {
741 await executeTransaction(transaction, userId);
742 } catch (err) {
743 throw err;
744 }
745 }
746
747 return transaction;
748}
749
750export async function denyTransaction(transactionId, userId) {
751 const tx = await Transaction.findById(transactionId);
752 if (!tx) throw new Error("Transaction not found");
753 if (tx.status !== "pending") {
754 throw new Error("Transaction is not pending");
755 }
756
757 let deniedSomething = false;
758
759 for (const group of tx.approvalGroups) {
760 if (group.resolved) continue;
761 if (!group.eligibleApprovers.includes(String(userId))) continue;
762
763 const alreadyApproved = group.approvals.some(
764 (a) => a.userId === String(userId),
765 );
766 if (alreadyApproved) {
767 throw new Error("User has already approved and cannot deny");
768 }
769
770 const alreadyDenied = group.denials?.some(
771 (d) => d.userId === String(userId),
772 );
773 if (alreadyDenied) continue;
774
775 // ✅ Store denial
776 group.denials = group.denials || [];
777 group.denials.push({
778 userId: String(userId),
779 deniedAt: new Date(),
780 });
781
782 deniedSomething = true;
783
784 // ✅ Recompute group resolution
785 if (checkGroupFailure(group)) {
786 group.resolved = true;
787 }
788
789 // ✅ Log denial for BOTH sides (if NODE)
790 if (tx.sideA.kind === "NODE") {
791 await logContribution({
792 userId,
793 nodeId: tx.sideA.nodeId,
794 action: "transaction",
795 tradeId: tx._id,
796 nodeVersion: "0",
797 transactionMeta: {
798 event: "denied",
799 side: "A",
800 role: "denier",
801 counterpartyNodeId: tx.sideB?.nodeId ?? null,
802 versionSelf: "0",
803 versionCounterparty: "0",
804 actorUserId: userId,
805 },
806 });
807 }
808
809 if (tx.sideB.kind === "NODE") {
810 await logContribution({
811 userId,
812 nodeId: tx.sideB.nodeId,
813 action: "transaction",
814 tradeId: tx._id,
815 nodeVersion: "0",
816 transactionMeta: {
817 event: "denied",
818 side: "B",
819 role: "denier",
820 counterpartyNodeId: tx.sideA?.nodeId ?? null,
821 versionSelf: "0",
822 versionCounterparty: "0",
823 actorUserId: userId,
824 },
825 });
826 }
827 }
828
829 if (!deniedSomething) {
830 throw new Error("User is not eligible to deny this transaction");
831 }
832
833 // ✅ Derive transaction status (NOT forced)
834 const anyGroupFailed = tx.approvalGroups.some(checkGroupFailure);
835
836 if (anyGroupFailed) {
837 tx.status = "rejected";
838 await logResolution(tx, "rejected", userId);
839 }
840
841 await tx.save();
842 return tx;
843}
844
845async function logResolution(transaction, outcome, actorUserId) {
846 const { sideA, sideB } = transaction;
847 if (transaction.executedAt) return;
848
849 const event =
850 outcome === "accepted" ? "accepted_by_policy" : "rejected_by_policy";
851
852 if (sideA.kind === "NODE") {
853 await logContribution({
854 userId: actorUserId,
855 nodeId: sideA.nodeId,
856 action: "transaction",
857 tradeId: transaction._id,
858 nodeVersion: "0",
859 transactionMeta: {
860 event,
861 side: "A",
862 role: "system",
863 counterpartyNodeId: sideB?.nodeId ?? null,
864 versionSelf: "0",
865 versionCounterparty: "0",
866 actorUserId,
867 },
868 });
869 }
870
871 if (sideB.kind === "NODE") {
872 await logContribution({
873 userId: actorUserId,
874 nodeId: sideB.nodeId,
875 action: "transaction",
876 tradeId: transaction._id,
877 nodeVersion: "0",
878 transactionMeta: {
879 event,
880 side: "B",
881 role: "system",
882 counterpartyNodeId: sideA?.nodeId ?? null,
883 versionSelf: "0",
884 versionCounterparty: "0",
885 actorUserId,
886 },
887 });
888 }
889}
890
891function checkGroupFailure(group) {
892 const denyCount = group.denials?.length ?? 0;
893 const memberCount = group.eligibleApprovers.length;
894
895 switch (group.policy) {
896 case "OWNER_ONLY":
897 case "ANYONE":
898 case "ALL":
899 return denyCount > 0;
900
901 case "MAJORITY":
902 return denyCount >= Math.ceil(memberCount / 2);
903
904 default:
905 return false;
906 }
907}
908
909export function checkAllGroupsResolved(transaction) {
910 if (!transaction.approvalGroups.length) return true;
911
912 return transaction.approvalGroups.every((g) => g.resolved === true);
913}
914
915export async function getTransactionWithContributions(transactionId) {
916 if (!transactionId) {
917 throw new Error("transactionId is required");
918 }
919
920 const transaction = await Transaction.findById(transactionId).lean();
921 if (!transaction) {
922 throw new Error("Transaction not found");
923 }
924
925 const contributions = await Contribution.find({
926 tradeId: transactionId, // rename later if you migrate
927 })
928 .sort({ date: 1 })
929 .populate({
930 path: "userId",
931 select: "_id username", // adjust fields as needed
932 })
933 .lean();
934
935 return {
936 transaction,
937 contributions,
938 };
939}
940
1/* ─────────────────────────────────────────────── */
2/* HTML renderer for transactions pages */
3/* ─────────────────────────────────────────────── */
4
5import { page } from "../html-rendering/html/layout.js";
6
7function normalizeValues(values) {
8 if (!values) return {};
9 if (values instanceof Map) {
10 return Object.fromEntries(values.entries());
11 }
12 if (typeof values === "object") {
13 return values;
14 }
15 return {};
16}
17
18function renderApprovalSummary(summary) {
19 if (!summary || summary.length === 0) return "";
20
21 return `
22 <div class="approval-summary">
23 ${summary
24 .map(
25 (approval) => `
26 <div class="approval-item">
27 <span class="approval-label">
28 ${approval.isViewerGroup ? "Your Side" : "Counterparty"}:
29 </span>
30 <span class="approval-badge ${
31 approval.resolved ? "resolved" : "pending"
32 }">
33 ${approval.approved}/${approval.required} approved
34 </span>
35 <span class="approval-policy">(${approval.policy})</span>
36 </div>
37 `
38 )
39 .join("")}
40 </div>
41 `;
42}
43
44/**
45 * Render the transactions list page.
46 */
47export function renderTransactionsList({
48 nodeId,
49 version,
50 nodeName,
51 transactions,
52 queryString,
53}) {
54 const parsedVersion = Number(version);
55
56 // Sort transactions: pending first, then by date
57 const sortedTransactions = [...transactions].sort((a, b) => {
58 if (a.status === "pending" && b.status !== "pending") return -1;
59 if (a.status !== "pending" && b.status === "pending") return 1;
60 return new Date(b.createdAt) - new Date(a.createdAt);
61 });
62
63 const transactionsHtml =
64 sortedTransactions.length > 0
65 ? `
66<ul class="transactions-list">
67${sortedTransactions
68 .map((tx) => {
69 const txHref = `/api/v1/node/${nodeId}/${parsedVersion}/transactions/${tx._id}${queryString}`;
70
71 return `
72<li>
73 <div
74 class="transaction-card ${tx.status}"
75 onclick="window.location.href='${txHref}'"
76 style="cursor:pointer;"
77 >
78 <div class="transaction-header">
79 <div class="transaction-date">
80 ${new Date(tx.createdAt).toLocaleString()}
81 </div>
82 <div class="transaction-status status-${tx.status}">
83 ${
84 tx.canApprove
85 ? "⏳ Pending"
86 : tx.status === "rejected"
87 ? "❌ Rejected"
88 : "✅ Accepted"
89 }
90 </div>
91 </div>
92
93 <div class="transaction-body">
94 <div class="transaction-parties">
95 <div class="party">
96 <div class="party-label">You</div>
97 <a
98 href="/api/v1/node/${nodeId}/${tx.versionSelf}${queryString}"
99 class="party-link"
100 onclick="event.stopPropagation()"
101 >
102 <code>${nodeName} v${tx.versionSelf}</code>
103 </a>
104 </div>
105
106 <div class="party-arrow">⇄</div>
107
108 <div class="party">
109 <div class="party-label">
110 ${tx.counterparty ? "Counterparty" : "Outside Source"}
111 </div>
112 ${
113 tx.counterparty
114 ? `
115 <a
116 href="/api/v1/node/${tx.counterparty._id}/${
117 tx.versionCounterparty
118 }${queryString}"
119 class="party-link"
120 onclick="event.stopPropagation()"
121 >
122 <code>${tx.counterparty.name ?? tx.counterparty._id} v${
123 tx.versionCounterparty
124 }</code>
125 </a>
126 `
127 : `<em class="outside-source">External</em>`
128 }
129 </div>
130 </div>
131
132 <div class="transaction-values">
133 <div class="value-box sent">
134 <div class="value-label">Sent</div>
135 <code>${JSON.stringify(tx.valuesSent)}</code>
136 </div>
137 <div class="value-box received">
138 <div class="value-label">Received</div>
139 <code>${JSON.stringify(tx.valuesReceived)}</code>
140 </div>
141 </div>
142
143 ${tx.approvalSummary ? renderApprovalSummary(tx.approvalSummary) : ""}
144
145 ${
146 tx.canApprove
147 ? `
148 <div class="transaction-actions" onclick="event.stopPropagation()">
149 <form
150 method="POST"
151 action="/api/v1/node/${nodeId}/${parsedVersion}/transactions/${tx._id}/approve${queryString}"
152 style="display:inline;"
153 >
154 <button type="submit" class="btn-approve">✓ Approve</button>
155 </form>
156
157 <form
158 method="POST"
159 action="/api/v1/node/${nodeId}/${parsedVersion}/transactions/${tx._id}/deny${queryString}"
160 style="display:inline;"
161 >
162 <button type="submit" class="btn-deny">✗ Deny</button>
163 </form>
164 </div>
165 `
166 : ""
167 }
168 </div>
169 </div>
170</li>
171`;
172 })
173 .join("")}
174</ul>`
175 : `
176<div class="empty-state">
177 <div class="empty-state-icon">📊</div>
178 <div class="empty-state-text">No transactions yet</div>
179 <div class="empty-state-subtext">Transactions will appear here</div>
180</div>`;
181
182 const css = `
183 /* Header */
184 .header {
185 background: rgba(255, 255, 255, 0.95);
186 backdrop-filter: blur(10px);
187 border-radius: 16px;
188 padding: 28px;
189 margin-bottom: 24px;
190 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
191 color: #1a1a1a;
192 animation: none;
193 border: none;
194 }
195
196 .header::before {
197 display: none;
198 }
199
200 .header h1 {
201 font-size: 28px;
202 font-weight: 700;
203 color: #1a1a1a;
204 margin-bottom: 8px;
205 text-shadow: none;
206 }
207
208 .header h1::before {
209 content: '💱 ';
210 font-size: 26px;
211 }
212
213 .header h1 a {
214 color: #667eea;
215 text-decoration: none;
216 transition: color 0.2s;
217 border-bottom: none;
218 }
219
220 .header h1 a:hover {
221 color: #764ba2;
222 text-decoration: underline;
223 text-shadow: none;
224 }
225
226 .header-subtitle {
227 font-size: 14px;
228 color: #888;
229 margin-top: 4px;
230 }
231
232 /* Section Titles */
233 .section-title {
234 font-size: 20px;
235 font-weight: 600;
236 color: #1a1a1a;
237 margin-bottom: 16px;
238 padding-left: 4px;
239 }
240
241 /* Transactions List */
242 .transactions-list {
243 list-style: none;
244 margin-bottom: 32px;
245 }
246
247 .transaction-card {
248 background: rgba(255, 255, 255, 0.95);
249 backdrop-filter: blur(10px);
250 border-radius: 12px;
251 padding: 20px;
252 margin-bottom: 16px;
253 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
254 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
255 border-left: 4px solid #667eea;
256 position: relative;
257 overflow: hidden;
258 }
259
260 .transaction-card.pending {
261 border-left-color: #ffa500;
262 background: linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(255, 250, 240, 0.98) 100%);
263 }
264
265 .transaction-card.accepted {
266 border-left-color: #10b981;
267 }
268
269 .transaction-card.rejected {
270 border-left-color: #ef4444;
271 }
272
273 .transaction-card::before {
274 content: '';
275 position: absolute;
276 top: 0;
277 left: 0;
278 width: 100%;
279 height: 100%;
280 background: linear-gradient(135deg, rgba(102, 126, 234, 0.03) 0%, rgba(118, 75, 162, 0.03) 100%);
281 opacity: 0;
282 transition: opacity 0.3s;
283 pointer-events: none;
284 }
285
286 .transaction-card:hover {
287 transform: translateX(8px) translateY(-4px);
288 box-shadow: 0 12px 32px rgba(102, 126, 234, 0.2);
289 }
290
291 .transaction-card:hover::before {
292 opacity: 1;
293 }
294
295 .transaction-header {
296 display: flex;
297 justify-content: space-between;
298 align-items: center;
299 margin-bottom: 16px;
300 padding-bottom: 12px;
301 border-bottom: 1px solid #e9ecef;
302 }
303
304 .transaction-date {
305 font-size: 13px;
306 color: #888;
307 }
308
309 .transaction-status {
310 padding: 4px 12px;
311 border-radius: 12px;
312 font-size: 13px;
313 font-weight: 600;
314 }
315
316 .status-pending {
317 background: linear-gradient(135deg, #ffa500 0%, #ff8c00 100%);
318 color: white;
319 }
320
321 .status-accepted {
322 background: linear-gradient(135deg, #10b981 0%, #059669 100%);
323 color: white;
324 }
325
326 .status-rejected {
327 background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
328 color: white;
329 }
330
331 .transaction-body {
332 position: relative;
333 z-index: 1;
334 }
335
336 .transaction-parties {
337 display: flex;
338 align-items: center;
339 gap: 16px;
340 margin-bottom: 16px;
341 flex-wrap: wrap;
342 }
343
344 .party {
345 flex: 1;
346 min-width: 200px;
347 }
348
349 .party-label {
350 font-size: 12px;
351 color: #888;
352 margin-bottom: 6px;
353 font-weight: 600;
354 text-transform: uppercase;
355 letter-spacing: 0.5px;
356 }
357
358 .party-link {
359 color: #667eea;
360 text-decoration: none;
361 transition: color 0.2s;
362 }
363
364 .party-link:hover {
365 color: #764ba2;
366 text-decoration: underline;
367 }
368
369 .party-arrow {
370 font-size: 24px;
371 color: #667eea;
372 flex-shrink: 0;
373 }
374
375 .outside-source {
376 color: #888;
377 font-size: 14px;
378 }
379
380 .transaction-values {
381 display: grid;
382 grid-template-columns: 1fr 1fr;
383 gap: 12px;
384 margin-bottom: 16px;
385 }
386
387 .value-box {
388 background: #f8f9fa;
389 padding: 12px;
390 border-radius: 8px;
391 border: 1px solid #e9ecef;
392 }
393
394 .value-box.sent {
395 border-left: 3px solid #ef4444;
396 }
397
398 .value-box.received {
399 border-left: 3px solid #10b981;
400 }
401
402 .value-label {
403 font-size: 12px;
404 color: #888;
405 margin-bottom: 6px;
406 font-weight: 600;
407 text-transform: uppercase;
408 letter-spacing: 0.5px;
409 }
410
411 .value-box code {
412 background: white;
413 padding: 4px 8px;
414 border-radius: 4px;
415 font-size: 13px;
416 color: #667eea;
417 font-weight: 600;
418 word-break: break-all;
419 }
420
421 /* Approval Summary */
422 .approval-summary {
423 background: linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%);
424 padding: 12px;
425 border-radius: 8px;
426 margin-bottom: 16px;
427 border: 1px solid rgba(102, 126, 234, 0.1);
428 }
429
430 .approval-item {
431 display: flex;
432 align-items: center;
433 gap: 8px;
434 margin-bottom: 8px;
435 flex-wrap: wrap;
436 }
437
438 .approval-item:last-child {
439 margin-bottom: 0;
440 }
441
442 .approval-label {
443 font-size: 13px;
444 font-weight: 600;
445 color: #667eea;
446 min-width: 100px;
447 }
448
449 .approval-badge {
450 padding: 4px 10px;
451 border-radius: 12px;
452 font-size: 12px;
453 font-weight: 600;
454 }
455
456 .approval-badge.resolved {
457 background: linear-gradient(135deg, #10b981 0%, #059669 100%);
458 color: white;
459 }
460
461 .approval-badge.pending {
462 background: linear-gradient(135deg, #ffa500 0%, #ff8c00 100%);
463 color: white;
464 }
465
466 .approval-policy {
467 font-size: 12px;
468 color: #888;
469 font-style: italic;
470 }
471
472 /* Transaction Actions */
473 .transaction-actions {
474 display: flex;
475 gap: 12px;
476 margin-top: 16px;
477 padding-top: 16px;
478 border-top: 1px solid #e9ecef;
479 }
480
481 .btn-approve,
482 .btn-deny {
483 padding: 10px 20px;
484 border: none;
485 border-radius: 8px;
486 font-weight: 600;
487 font-size: 14px;
488 cursor: pointer;
489 transition: all 0.2s;
490 display: inline-flex;
491 align-items: center;
492 gap: 6px;
493 }
494
495 .btn-approve {
496 background: linear-gradient(135deg, #10b981 0%, #059669 100%);
497 color: white;
498 box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
499 }
500
501 .btn-approve:hover {
502 transform: translateY(-2px);
503 box-shadow: 0 6px 20px rgba(16, 185, 129, 0.4);
504 }
505
506 .btn-deny {
507 background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
508 color: white;
509 box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
510 }
511
512 .btn-deny:hover {
513 transform: translateY(-2px);
514 box-shadow: 0 6px 20px rgba(239, 68, 68, 0.4);
515 }
516
517 /* Create Transaction Form */
518 .create-form-container {
519 background: rgba(255, 255, 255, 0.95);
520 backdrop-filter: blur(10px);
521 border-radius: 16px;
522 padding: 28px;
523 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
524 position: relative;
525 overflow: hidden;
526 }
527
528 .create-form-container::before {
529 content: '';
530 position: absolute;
531 top: 0;
532 left: 0;
533 width: 100%;
534 height: 4px;
535 background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
536 }
537
538 .form-section {
539 margin-bottom: 24px;
540 }
541
542 .form-section h3 {
543 font-size: 16px;
544 font-weight: 600;
545 color: #667eea;
546 margin-bottom: 12px;
547 }
548
549 .form-group {
550 margin-bottom: 12px;
551 }
552
553 input,
554 select,
555 textarea {
556 width: 100%;
557 padding: 12px;
558 border: 1px solid #e9ecef;
559 border-radius: 8px;
560 font-size: 14px;
561 font-family: inherit;
562 transition: border-color 0.2s;
563 }
564
565 input:focus,
566 select:focus,
567 textarea:focus {
568 outline: none;
569 border-color: #667eea;
570 }
571
572 textarea {
573 resize: vertical;
574 font-family: 'SF Mono', Monaco, monospace;
575 }
576
577 .form-divider {
578 height: 1px;
579 background: #e9ecef;
580 margin: 24px 0;
581 }
582
583 .btn-submit {
584 width: 100%;
585 padding: 14px 24px;
586 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
587 color: white;
588 border: none;
589 border-radius: 10px;
590 font-weight: 600;
591 font-size: 15px;
592 cursor: pointer;
593 transition: all 0.2s;
594 box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
595 }
596
597 .btn-submit:hover {
598 transform: translateY(-2px);
599 box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
600 }
601
602 /* Empty State Override */
603 .empty-state {
604 background: rgba(255, 255, 255, 0.95);
605 backdrop-filter: blur(10px);
606 border-radius: 16px;
607 padding: 60px 40px;
608 text-align: center;
609 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
610 margin-bottom: 32px;
611 color: #1a1a1a;
612 border: none;
613 animation: none;
614 }
615
616 .empty-state::before {
617 display: none;
618 }
619
620 .empty-state-text {
621 font-size: 18px;
622 color: #666;
623 margin-bottom: 8px;
624 text-shadow: none;
625 }
626
627 .empty-state-subtext {
628 font-size: 14px;
629 color: #999;
630 }
631
632 /* Code */
633 code {
634 background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
635 padding: 4px 8px;
636 border-radius: 6px;
637 font-size: 13px;
638 font-family: 'SF Mono', Monaco, monospace;
639 color: #667eea;
640 font-weight: 600;
641 }
642
643 /* Back Link Override */
644 .back-link {
645 background: rgba(255, 255, 255, 0.95);
646 backdrop-filter: blur(10px);
647 color: #667eea;
648 border-radius: 10px;
649 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
650 border: none;
651 }
652
653 .back-link::before {
654 display: none;
655 }
656
657 .back-link:hover {
658 background: white;
659 transform: translateY(-2px);
660 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
661 }
662
663 /* Responsive */
664 @media (max-width: 640px) {
665 .header,
666 .create-form-container {
667 padding: 20px;
668 }
669
670 .transaction-card {
671 padding: 16px;
672 }
673
674 .transaction-parties {
675 flex-direction: column;
676 align-items: flex-start;
677 }
678
679 .party {
680 width: 100%;
681 }
682
683 .party-arrow {
684 transform: rotate(90deg);
685 align-self: center;
686 }
687
688 .transaction-values {
689 grid-template-columns: 1fr;
690 }
691
692 .transaction-actions {
693 flex-direction: column;
694 }
695
696 .btn-approve,
697 .btn-deny {
698 width: 100%;
699 justify-content: center;
700 }
701 }
702`;
703
704 const body = `
705 <div class="container">
706 <!-- Header -->
707 <div class="back-nav">
708 <a href="/api/v1/root/${nodeId}${queryString}" class="back-link">
709 ← Back to Tree
710 </a>
711 <a href="/api/v1/node/${nodeId}/${parsedVersion}${queryString}" class="back-link">
712 Back to Version
713 </a>
714</div>
715
716 <div class="header">
717 <h1>
718 <a href="/api/v1/node/${nodeId}${queryString}">
719 ${nodeName} v${parsedVersion}
720 </a>
721 </h1>
722 <div class="header-subtitle">Transaction history and management</div>
723</div>
724
725 <!-- Create Transaction Form -->
726 <div class="create-form-container">
727 <div class="section-title">Create Transaction</div>
728
729 <form method="POST" action="/api/v1/node/${nodeId}/${parsedVersion}/transactions${queryString}">
730 <input type="hidden" name="sideA.kind" value="NODE" />
731 <input type="hidden" name="sideA.nodeId" value="${nodeId}" />
732 <input type="hidden" name="versionAIndex" value="${parsedVersion}" />
733
734 <div class="form-section">
735 <h3>Your Side</h3>
736 <textarea name="valuesA" rows="3" placeholder='{"gold": 10, "silver": 5}'></textarea>
737 </div>
738
739 <div class="form-divider"></div>
740
741 <div class="form-section">
742 <h3>Counterparty</h3>
743 <div class="form-group">
744 <select name="sideB.kind" id="sideBKind">
745 <option value="NODE">Node</option>
746 <option value="OUTSIDE">Outside</option>
747 </select>
748 </div>
749
750 <div id="nodeFields">
751 <div class="form-group">
752 <input name="sideB.nodeId" placeholder="Node ID" />
753 </div>
754 <div class="form-group">
755 <input type="number" name="versionBIndex" placeholder="Version index (defaults to other node's latest verion if blank)" />
756 </div>
757 </div>
758
759 <div id="outsideFields" style="display:none;">
760 <div class="form-group">
761 <select name="sideB.sourceType">
762 <option value="SOLANA">Solana</option>
763 </select>
764 </div>
765 <div class="form-group">
766 <input name="sideB.sourceId" placeholder="Wallet / reference" />
767 </div>
768 </div>
769
770 <textarea name="valuesB" rows="3" placeholder='{"wood": 5, "stone": 3}'></textarea>
771 </div>
772
773 <button type="submit" class="btn-submit">Send Transaction</button>
774 </form>
775 </div>
776 <!-- Transactions Section -->
777 <div class="section-title">Transactions</div>
778 ${transactionsHtml}
779
780
781 </div>
782`;
783
784 const js = `
785 const kind = document.getElementById("sideBKind");
786 const node = document.getElementById("nodeFields");
787 const out = document.getElementById("outsideFields");
788
789 kind.addEventListener("change", () => {
790 const isNode = kind.value === "NODE";
791 node.style.display = isNode ? "block" : "none";
792 out.style.display = isNode ? "none" : "block";
793 });
794
795 const form = document.querySelector("form");
796 const valuesA = form.querySelector('[name="valuesA"]');
797 const valuesB = form.querySelector('[name="valuesB"]');
798
799 form.addEventListener("submit", (e) => {
800 const a = valuesA.value.trim();
801 const b = valuesB.value.trim();
802
803 if (!a && !b) {
804 e.preventDefault();
805 alert("You must enter values on at least one side.");
806 return;
807 }
808 });
809`;
810
811 return page({ title: `Transactions - ${nodeName}`, css, body, js });
812}
813
814/**
815 * Render the transaction detail page.
816 */
817export function renderTransactionDetail({
818 nodeId,
819 version,
820 transactionId,
821 tx,
822 contributions,
823 sideANodeName,
824 sideBNodeName,
825 queryString,
826}) {
827 // Render approval groups
828 const renderApprovalGroup = (group) => {
829 const approvedUserIds = new Set(group.approvals.map((a) => a.userId));
830 const deniedUserIds = new Set(
831 (group.denials || []).map((d) => d.userId)
832 );
833
834 return `
835 <div class="approval-group ${
836 group.resolved ? "resolved" : "pending"
837 }">
838 <div class="approval-group-header">
839 <div class="approval-group-title">
840 <span class="side-badge side-${group.side.toLowerCase()}">${
841 group.side === "A" ? sideANodeName : sideBNodeName
842 }</span>
843 <span class="policy-badge">${group.policy}</span>
844 </div>
845 <div class="approval-status ${
846 group.resolved ? "resolved" : "pending"
847 }">
848 ${group.approvals.length}/${group.requiredApprovals}
849 ${group.resolved ? "✓ Complete" : "⏳ Pending"}
850 </div>
851 </div>
852
853 <div class="approvers-list">
854 ${group.eligibleApprovers
855 .map((userId) => {
856 const hasApproved = approvedUserIds.has(userId);
857 const hasDenied = deniedUserIds.has(userId);
858 const approval = group.approvals.find(
859 (a) => a.userId === userId
860 );
861 const user = contributions.find(
862 (c) => c.userId._id === userId
863 )?.userId;
864 const username = user?.username || userId.substring(0, 8);
865
866 return `
867 <div class="approver-item ${
868 hasApproved ? "approved" : hasDenied ? "denied" : "pending"
869 }">
870 <div class="approver-info">
871 <span class="approver-icon">
872 ${hasApproved ? "✓" : hasDenied ? "✗" : "○"}
873 </span>
874 <a href="/api/v1/user/${userId}${queryString}" class="approver-name">
875 @${username}
876 </a>
877 </div>
878
879 ${
880 hasApproved
881 ? `<span class="approval-time">${new Date(
882 approval.approvedAt
883 ).toLocaleString()}</span>`
884 : hasDenied
885 ? `<span class="approval-time denied">${new Date(
886 group.denials.find((d) => d.userId === userId).deniedAt
887 ).toLocaleString()}</span>`
888 : `<span class="approval-time pending">Not voted</span>`
889 }
890 </div>
891`;
892 })
893 .join("")}
894 </div>
895 </div>
896 `;
897 };
898
899 // Render transaction timeline
900 const renderTimeline = () => {
901 const events = contributions
902 .filter((c) => c.nodeId?.toString() === nodeId)
903 .sort((a, b) => new Date(a.date) - new Date(b.date))
904 .map((contrib) => {
905 const username = contrib.userId?.username || "Unknown";
906 const event = contrib.transactionMeta?.event;
907
908 let icon = "📝";
909 let label = event;
910 let cls = "event-neutral";
911
912 if (event === "created") {
913 icon = "🚀";
914 label = "Proposed transaction";
915 cls = "event-created";
916 } else if (event === "approved") {
917 icon = "✅";
918 label = "Approved";
919 cls = "event-approved";
920 } else if (event === "denied") {
921 icon = "❌";
922 label = "Denied";
923 cls = "event-denied";
924 } else if (event === "execution_started") {
925 icon = "⏳";
926 label = "Execution started";
927 cls = "event-executing";
928 } else if (event === "succeeded") {
929 icon = "⚡";
930 label = "Transaction executed";
931 cls = "event-executed";
932 } else if (event === "failed") {
933 icon = "💥";
934 label = "Execution failed";
935 cls = "event-failed";
936 } else if (event === "accepted_by_policy") {
937 icon = "📜";
938 label = "Accepted by policy";
939 cls = "event-policy-accepted";
940 } else if (event === "rejected_by_policy") {
941 icon = "📜";
942 label = "Rejected by policy";
943 cls = "event-policy-rejected";
944 }
945 return `
946 <div class="timeline-item ${cls}">
947 <div class="timeline-icon">${icon}</div>
948 <div class="timeline-content">
949 <div class="timeline-header">
950 <a href="/api/v1/user/${
951 contrib.userId._id
952 }${queryString}" class="timeline-user">
953 @${username}
954 </a>
955 <span class="timeline-event">${label}</span>
956 </div>
957 <div class="timeline-time">
958 ${new Date(contrib.date).toLocaleString()}
959 </div>
960 </div>
961 </div>
962 `;
963 })
964 .join("");
965
966 return events || '<div class="empty-timeline">No events yet</div>';
967 };
968
969 const css = `
970 .container {
971 max-width: 1000px;
972 }
973
974 .approver-item.denied {
975 border-left: 3px solid #ef4444;
976 background: rgba(239, 68, 68, 0.05);
977 }
978
979 /* Back Link Override */
980 .back-link {
981 background: rgba(255, 255, 255, 0.95);
982 backdrop-filter: blur(10px);
983 color: #667eea;
984 border-radius: 10px;
985 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
986 border: none;
987 }
988
989 .back-link::before {
990 display: none;
991 }
992
993 .back-link:hover {
994 background: white;
995 transform: translateY(-2px);
996 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
997 }
998
999 /* Header */
1000 .header {
1001 background: rgba(255, 255, 255, 0.95);
1002 backdrop-filter: blur(10px);
1003 border-radius: 16px;
1004 padding: 28px;
1005 margin-bottom: 24px;
1006 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
1007 position: relative;
1008 overflow: hidden;
1009 color: #1a1a1a;
1010 animation: none;
1011 border: none;
1012 }
1013
1014 .header::before {
1015 content: '';
1016 position: absolute;
1017 top: 0;
1018 left: 0;
1019 width: 100%;
1020 height: 4px;
1021 background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
1022 inset: unset;
1023 border-radius: 0;
1024 opacity: 1;
1025 }
1026
1027 .header-top {
1028 display: flex;
1029 justify-content: space-between;
1030 align-items: flex-start;
1031 margin-bottom: 20px;
1032 flex-wrap: wrap;
1033 gap: 12px;
1034 }
1035
1036 .header h1 {
1037 font-size: 28px;
1038 font-weight: 700;
1039 color: #1a1a1a;
1040 text-shadow: none;
1041 }
1042
1043 .status-badge {
1044 padding: 8px 16px;
1045 border-radius: 12px;
1046 font-size: 14px;
1047 font-weight: 600;
1048 display: inline-flex;
1049 align-items: center;
1050 gap: 6px;
1051 }
1052
1053 .status-badge.pending {
1054 background: linear-gradient(135deg, #ffa500 0%, #ff8c00 100%);
1055 color: white;
1056 }
1057
1058 .status-badge.accepted {
1059 background: linear-gradient(135deg, #10b981 0%, #059669 100%);
1060 color: white;
1061 }
1062
1063 .status-badge.rejected {
1064 background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
1065 color: white;
1066 }
1067
1068 .transaction-id {
1069 font-size: 13px;
1070 color: #888;
1071 font-family: 'SF Mono', Monaco, monospace;
1072 margin-top: 8px;
1073 }
1074
1075 /* Trade Overview */
1076 .trade-overview {
1077 display: grid;
1078 grid-template-columns: 1fr auto 1fr;
1079 gap: 24px;
1080 align-items: center;
1081 padding: 24px;
1082 background: #f8f9fa;
1083 border-radius: 12px;
1084 margin-top: 20px;
1085 }
1086
1087 .trade-side {
1088 text-align: center;
1089 }
1090
1091 .side-label {
1092 font-size: 12px;
1093 color: #888;
1094 font-weight: 600;
1095 text-transform: uppercase;
1096 letter-spacing: 0.5px;
1097 margin-bottom: 12px;
1098 }
1099
1100 .side-node {
1101 font-size: 18px;
1102 font-weight: 700;
1103 color: #667eea;
1104 margin-bottom: 8px;
1105 }
1106
1107 .side-version {
1108 font-size: 13px;
1109 color: #888;
1110 margin-bottom: 12px;
1111 }
1112
1113 .side-values {
1114 background: white;
1115 padding: 12px;
1116 border-radius: 8px;
1117 border: 1px solid #e9ecef;
1118 margin-top: 12px;
1119 }
1120
1121 .values-label {
1122 font-size: 11px;
1123 color: #888;
1124 font-weight: 600;
1125 text-transform: uppercase;
1126 letter-spacing: 0.5px;
1127 margin-bottom: 8px;
1128 }
1129
1130 .values-content {
1131 font-family: 'SF Mono', Monaco, monospace;
1132 font-size: 13px;
1133 color: #1a1a1a;
1134 word-break: break-all;
1135 }
1136
1137 .trade-arrow {
1138 font-size: 32px;
1139 color: #667eea;
1140 }
1141
1142 /* Section */
1143 .section {
1144 background: rgba(255, 255, 255, 0.95);
1145 backdrop-filter: blur(10px);
1146 border-radius: 16px;
1147 padding: 28px;
1148 margin-bottom: 24px;
1149 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
1150 position: relative;
1151 overflow: hidden;
1152 }
1153
1154 .section::before {
1155 content: '';
1156 position: absolute;
1157 top: 0;
1158 left: 0;
1159 width: 100%;
1160 height: 4px;
1161 background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
1162 }
1163
1164 .section-title {
1165 font-size: 20px;
1166 font-weight: 600;
1167 color: #1a1a1a;
1168 margin-bottom: 20px;
1169 }
1170
1171 /* Approval Groups */
1172 .approval-groups {
1173 display: grid;
1174 gap: 20px;
1175 }
1176
1177 .approval-group {
1178 background: #f8f9fa;
1179 border-radius: 12px;
1180 padding: 20px;
1181 border-left: 4px solid #667eea;
1182 transition: all 0.2s;
1183 }
1184
1185 .approval-group.resolved {
1186 border-left-color: #10b981;
1187 background: linear-gradient(135deg, rgba(16, 185, 129, 0.05) 0%, rgba(5, 150, 105, 0.05) 100%);
1188 }
1189
1190 .approval-group.pending {
1191 border-left-color: #ffa500;
1192 background: linear-gradient(135deg, rgba(255, 165, 0, 0.05) 0%, rgba(255, 140, 0, 0.05) 100%);
1193 }
1194
1195 .approval-group-header {
1196 display: flex;
1197 justify-content: space-between;
1198 align-items: center;
1199 margin-bottom: 16px;
1200 padding-bottom: 12px;
1201 border-bottom: 1px solid #e9ecef;
1202 flex-wrap: wrap;
1203 gap: 12px;
1204 }
1205
1206 .approval-group-title {
1207 display: flex;
1208 align-items: center;
1209 gap: 12px;
1210 flex-wrap: wrap;
1211 }
1212
1213 .side-badge {
1214 padding: 6px 12px;
1215 border-radius: 8px;
1216 font-size: 13px;
1217 font-weight: 600;
1218 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1219 color: white;
1220 }
1221
1222 .side-badge.side-a {
1223 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1224 }
1225
1226 .side-badge.side-b {
1227 background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
1228 }
1229
1230 .policy-badge {
1231 padding: 4px 10px;
1232 border-radius: 12px;
1233 font-size: 12px;
1234 font-weight: 600;
1235 background: white;
1236 color: #667eea;
1237 border: 1px solid #e9ecef;
1238 }
1239
1240 .approval-status {
1241 padding: 6px 14px;
1242 border-radius: 12px;
1243 font-size: 13px;
1244 font-weight: 600;
1245 }
1246
1247 .approval-status.resolved {
1248 background: linear-gradient(135deg, #10b981 0%, #059669 100%);
1249 color: white;
1250 }
1251
1252 .approval-status.pending {
1253 background: linear-gradient(135deg, #ffa500 0%, #ff8c00 100%);
1254 color: white;
1255 }
1256
1257 /* Approvers List */
1258 .approvers-list {
1259 display: grid;
1260 gap: 12px;
1261 }
1262
1263 .approver-item {
1264 display: flex;
1265 justify-content: space-between;
1266 align-items: center;
1267 padding: 12px;
1268 background: white;
1269 border-radius: 8px;
1270 border: 1px solid #e9ecef;
1271 transition: all 0.2s;
1272 }
1273
1274 .approver-item:hover {
1275 transform: translateX(4px);
1276 box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1);
1277 }
1278
1279 .approver-item.approved {
1280 border-left: 3px solid #10b981;
1281 }
1282
1283 .approver-item.pending {
1284 border-left: 3px solid #e9ecef;
1285 opacity: 0.7;
1286 }
1287
1288 .approver-info {
1289 display: flex;
1290 align-items: center;
1291 gap: 12px;
1292 }
1293
1294 .approver-icon {
1295 font-size: 18px;
1296 width: 24px;
1297 height: 24px;
1298 display: flex;
1299 align-items: center;
1300 justify-content: center;
1301 }
1302
1303 .approver-item.approved .approver-icon {
1304 color: #10b981;
1305 }
1306
1307 .approver-item.pending .approver-icon {
1308 color: #d1d5db;
1309 }
1310
1311 .approver-name {
1312 color: #667eea;
1313 text-decoration: none;
1314 font-weight: 600;
1315 font-size: 14px;
1316 transition: color 0.2s;
1317 }
1318
1319 .approver-name:hover {
1320 color: #764ba2;
1321 text-decoration: underline;
1322 }
1323
1324 .approval-time {
1325 font-size: 12px;
1326 color: #888;
1327 }
1328
1329 .approval-time.pending {
1330 font-style: italic;
1331 color: #aaa;
1332 }
1333
1334 /* Timeline */
1335 .timeline {
1336 position: relative;
1337 padding-left: 40px;
1338 }
1339
1340 .timeline::before {
1341 content: '';
1342 position: absolute;
1343 left: 11px;
1344 top: 0;
1345 bottom: 0;
1346 width: 2px;
1347 background: linear-gradient(180deg, #667eea 0%, #764ba2 100%);
1348 }
1349
1350 .timeline-item {
1351 position: relative;
1352 margin-bottom: 24px;
1353 }
1354
1355 .timeline-icon {
1356 position: absolute;
1357 left: -40px;
1358 width: 24px;
1359 height: 24px;
1360 background: white;
1361 border-radius: 50%;
1362 display: flex;
1363 align-items: center;
1364 justify-content: center;
1365 font-size: 14px;
1366 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
1367 }
1368
1369 .timeline-content {
1370 background: #f8f9fa;
1371 padding: 12px 16px;
1372 border-radius: 8px;
1373 border: 1px solid #e9ecef;
1374 }
1375
1376 .timeline-header {
1377 display: flex;
1378 align-items: center;
1379 gap: 8px;
1380 margin-bottom: 4px;
1381 flex-wrap: wrap;
1382 }
1383
1384 .timeline-user {
1385 color: #667eea;
1386 text-decoration: none;
1387 font-weight: 600;
1388 font-size: 14px;
1389 transition: color 0.2s;
1390 }
1391
1392 .timeline-user:hover {
1393 color: #764ba2;
1394 text-decoration: underline;
1395 }
1396
1397 .timeline-event {
1398 font-size: 14px;
1399 color: #1a1a1a;
1400 }
1401
1402 .timeline-time {
1403 font-size: 12px;
1404 color: #888;
1405 }
1406
1407 .event-created .timeline-icon {
1408 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1409 }
1410
1411 .event-approved .timeline-icon {
1412 background: linear-gradient(135deg, #10b981 0%, #059669 100%);
1413 }
1414
1415 .event-denied .timeline-icon {
1416 background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
1417 }
1418
1419 .event-executed .timeline-icon {
1420 background: linear-gradient(135deg, #ffa500 0%, #ff8c00 100%);
1421 }
1422
1423 .empty-timeline {
1424 text-align: center;
1425 color: #888;
1426 padding: 40px;
1427 font-style: italic;
1428 }
1429
1430 /* Responsive */
1431 @media (max-width: 768px) {
1432 .header,
1433 .section {
1434 padding: 20px;
1435 }
1436
1437 .header h1 {
1438 font-size: 24px;
1439 }
1440
1441 .trade-overview {
1442 grid-template-columns: 1fr;
1443 text-align: center;
1444 }
1445
1446 .trade-arrow {
1447 transform: rotate(90deg);
1448 }
1449
1450 .approval-group-header {
1451 flex-direction: column;
1452 align-items: flex-start;
1453 }
1454
1455 .approver-item {
1456 flex-direction: column;
1457 align-items: flex-start;
1458 gap: 8px;
1459 }
1460 }
1461
1462 @media (min-width: 769px) and (max-width: 1024px) {
1463 .container {
1464 max-width: 800px;
1465 }
1466 }
1467`;
1468
1469 const body = `
1470 <div class="container">
1471 <!-- Back Navigation -->
1472 <div class="back-nav">
1473 <a href="/api/v1/node/${nodeId}/${version}/transactions${queryString}" class="back-link">
1474 ← Back to Transactions
1475 </a>
1476 </div>
1477
1478 <!-- Header -->
1479 <div class="header">
1480 <div class="header-top">
1481 <div>
1482 <h1>Transaction Details</h1>
1483 <div class="transaction-id">ID: ${transactionId}</div>
1484 </div>
1485 <div class="status-badge ${tx.status}">
1486 ${
1487 tx.status === "pending"
1488 ? "⏳ Pending"
1489 : tx.status === "rejected"
1490 ? "❌ Rejected"
1491 : "✅ Accepted"
1492 }
1493 </div>
1494 </div>
1495
1496 <!-- Trade Overview -->
1497 <div class="trade-overview">
1498 <div class="trade-side">
1499 <div class="side-label">Side A</div>
1500 <div class="side-node">${sideANodeName}</div>
1501 <div class="side-version">Version ${tx.versionAIndex}</div>
1502 <div class="side-values">
1503 <div class="values-label">Sending</div>
1504 <div class="values-content">${JSON.stringify(
1505 normalizeValues(tx.valuesTraded.sideA)
1506 )}</div>
1507 </div>
1508 </div>
1509
1510 <div class="trade-arrow">⇄</div>
1511
1512 <div class="trade-side">
1513 <div class="side-label">Side B</div>
1514 <div class="side-node">${sideBNodeName}</div>
1515 <div class="side-version">Version ${tx.versionBIndex}</div>
1516 <div class="side-values">
1517 <div class="values-label">Sending</div>
1518 <div class="values-content">${JSON.stringify(
1519 normalizeValues(tx.valuesTraded.sideB)
1520 )}</div>
1521 </div>
1522 </div>
1523 </div>
1524 </div>
1525
1526 <!-- Approval Groups -->
1527 <div class="section">
1528 <div class="section-title">Approval Status</div>
1529 <div class="approval-groups">
1530 ${tx.approvalGroups.map((group) => renderApprovalGroup(group)).join("")}
1531 </div>
1532 </div>
1533
1534 <!-- Timeline -->
1535 <div class="section">
1536 <div class="section-title">Transaction History</div>
1537 <div class="timeline">
1538 ${renderTimeline()}
1539 </div>
1540 </div>
1541 </div>
1542`;
1543
1544 return page({ title: `Transaction ${transactionId.substring(0, 8)}`, css, body });
1545}
1546
1import { setServices, setEnergyService } from "./core.js";
2import { getExtension } from "../loader.js";
3
4export async function init(core) {
5 setServices({ models: core.models, contributions: core.contributions, metadata: core.metadata });
6 if (core.energy) setEnergyService(core.energy);
7 const { default: router, resolveHtmlAuth } = await import("./routes.js");
8 resolveHtmlAuth();
9
10 // Register version quick link
11 try {
12 const treeos = getExtension("treeos-base");
13 treeos?.exports?.registerSlot?.("version-quick-links", "transactions", ({ nodeId, version, qs }) =>
14 `<a href="/api/v1/node/${nodeId}/${version}/transactions${qs}">Transactions</a>`,
15 { priority: 20 }
16 );
17
18 treeos?.exports?.registerSlot?.("tree-transaction-policy", "transactions", ({ policyHtml }) => {
19 if (!policyHtml) return "";
20 return `<div class="content-card">
21 <div class="section-header"><h2>Transaction Policy</h2></div>
22 ${policyHtml}
23</div>`;
24 }, { priority: 30 });
25 } catch {}
26
27 return { router };
28}
29
1export default {
2 name: "transactions",
3 version: "1.0.1",
4 builtFor: "TreeOS",
5 description:
6 "Nodes can trade value with each other. A transaction is a two-sided exchange: node A sends " +
7 "values to node B, and node B sends values back to node A. Or one side is OUTSIDE, " +
8 "representing an external source like a Solana wallet. The extension handles the full " +
9 "lifecycle: proposal, approval, execution, and rejection." +
10 "\n\n" +
11 "Four approval policies govern who can authorize transactions on a tree. OWNER_ONLY: only " +
12 "the root owner can approve. ANYONE: any contributor can approve with a single vote. " +
13 "MAJORITY: more than half of all members (owner plus contributors) must approve. ALL: " +
14 "unanimous consent required. The policy is set per tree root. The proposer auto-approves " +
15 "their own side if they are an eligible approver. If that single approval satisfies the " +
16 "policy, the transaction executes immediately without waiting." +
17 "\n\n" +
18 "Execution is atomic. Both sides are debited and credited in a single operation. If " +
19 "either side has insufficient balance for what it is sending, the transaction fails and " +
20 "is marked rejected. Values are read from and written to node metadata through the values " +
21 "extension's storage format. Denial works symmetrically: an eligible approver can deny " +
22 "instead of approve. Under OWNER_ONLY, ANYONE, or ALL policies, a single denial kills " +
23 "the transaction. Under MAJORITY, denials must reach the same threshold as approvals." +
24 "\n\n" +
25 "Every state change is logged as a contribution on both participating nodes with full " +
26 "metadata: which side, what role (proposer, approver, denier, system), which event " +
27 "(created, approved, denied, execution_started, succeeded, failed, accepted_by_policy, " +
28 "rejected_by_policy), and the values sent and received. The transaction detail endpoint " +
29 "returns the full contribution timeline for audit.",
30
31 needs: {
32 services: ["contributions", "auth"],
33 models: ["Node", "Contribution"],
34 },
35
36 optional: {
37 services: ["energy"],
38 extensions: ["html-rendering", "treeos-base"],
39 },
40
41 provides: {
42 models: {
43 Transaction: "./model.js",
44 },
45 routes: "./routes.js",
46 tools: false,
47 jobs: false,
48 orchestrator: false,
49 energyActions: {
50 transaction: { cost: 1 },
51 },
52 sessionTypes: {},
53 schemaVersion: 1,
54 migrations: "./migrations.js",
55 cli: [
56 { command: "transactions", scope: ["tree"], description: "List transactions for current node", method: "GET", endpoint: "/node/:nodeId/:version/transactions" },
57 ],
58 },
59};
60
1// Migration 1 already ran. No further migrations needed.
2export default [];
3
1import mongoose from "mongoose";
2import { v4 as uuidv4 } from "uuid";
3
4const ApprovalGroupSchema = new mongoose.Schema(
5 {
6 rootId: {
7 type: String,
8 ref: "Node",
9 required: true,
10 },
11
12 side: {
13 type: String,
14 enum: ["A", "B"],
15 required: true,
16 },
17 policy: {
18 type: String,
19 enum: ["OWNER_ONLY", "ANYONE", "MAJORITY", "ALL"],
20 required: true,
21 },
22
23 eligibleApprovers: [
24 {
25 type: String,
26 ref: "User",
27 required: true,
28 },
29 ],
30
31 requiredApprovals: {
32 type: Number,
33 required: true,
34 },
35
36 approvals: [
37 {
38 userId: { type: String, ref: "User", required: true },
39 approvedAt: { type: Date, default: Date.now },
40 },
41 ],
42 denials: [
43 {
44 userId: { type: String, ref: "User", required: true },
45 deniedAt: { type: Date, default: Date.now },
46 },
47 ],
48
49 resolved: {
50 type: Boolean,
51 default: false,
52 },
53 },
54 { _id: false }
55);
56
57const TransactionSchema = new mongoose.Schema(
58 {
59 _id: {
60 type: String,
61 default: uuidv4,
62 },
63 sideA: {
64 kind: { type: String, enum: ["NODE", "OUTSIDE"], required: true },
65 nodeId: {
66 type: String,
67 ref: "Node",
68 required: function () {
69 return this.sideA.kind === "NODE";
70 },
71 },
72 sourceType: {
73 type: String,
74 enum: ["SOLANA"], // extend later
75 },
76 sourceId: String,
77 },
78
79 sideB: {
80 kind: { type: String, enum: ["NODE", "OUTSIDE"], required: true },
81 nodeId: {
82 type: String,
83 ref: "Node",
84 required: function () {
85 return this.sideB.kind === "NODE";
86 },
87 },
88 sourceType: String,
89 sourceId: String,
90 },
91
92 versionAIndex: Number,
93 versionBIndex: Number,
94
95 valuesTraded: {
96 sideA: { type: Map, of: Number },
97 sideB: { type: Map, of: Number },
98 },
99
100 approvalGroups: {
101 type: [ApprovalGroupSchema],
102 default: [],
103 },
104
105 status: {
106 type: String,
107 enum: ["pending", "accepted", "rejected"],
108 default: "pending",
109 index: true,
110 },
111
112 executedAt: {
113 type: Date,
114 default: null,
115 },
116 },
117 { timestamps: true }
118);
119
120const Transaction = mongoose.model("Transaction", TransactionSchema);
121export default Transaction;
122
1import log from "../../seed/log.js";
2import express from "express";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import authenticate from "../../seed/middleware/authenticate.js";
5
6import {
7 setTransactionPolicy,
8 getTransactions,
9 createTransaction,
10 applyApproval,
11 denyTransaction,
12 getTransactionWithContributions,
13} from "./core.js";
14import getNodeName from "../../routes/api/helpers/getNameById.js";
15import {
16 renderTransactionsList,
17 renderTransactionDetail,
18} from "./html.js";
19
20import Node from "../../seed/models/node.js";
21import { getExtension } from "../loader.js";
22
23let htmlAuth = authenticate;
24export function resolveHtmlAuth() {
25 const htmlExt = getExtension("html-rendering");
26 if (htmlExt?.exports?.urlAuth) htmlAuth = htmlExt.exports.urlAuth;
27}
28
29async function resolveVersion(nodeId, version) {
30 if (version === "latest" || version === undefined) {
31 const node = await Node.findById(nodeId).select("metadata").lean();
32 const meta = node?.metadata instanceof Map ? Object.fromEntries(node.metadata) : (node?.metadata || {});
33 return meta.prestige?.current || 0;
34 }
35 return Number(version);
36}
37
38const router = express.Router();
39const allowedParams = ["token", "html"];
40
41// Resolve "latest" to actual prestige number
42router.param("version", async (req, res, next, val) => {
43 try {
44 req.params.version = String(await resolveVersion(req.params.nodeId, val));
45 next();
46 } catch (err) {
47 return sendError(res, 404, ERR.NODE_NOT_FOUND, err.message);
48 }
49});
50
51/**
52 * Parse JSON safely for values payloads.
53 * Always returns a plain object. Throws a 400-friendly Error on invalid JSON.
54 */
55function safeParseValues(input) {
56 if (input == null || input === "") return {};
57 if (typeof input !== "string") {
58 // In case you ever send JSON already parsed (e.g. fetch with JSON body)
59 if (typeof input === "object") return input ?? {};
60 return {};
61 }
62
63 try {
64 const parsed = JSON.parse(input);
65 return typeof parsed === "object" &&
66 parsed !== null &&
67 !Array.isArray(parsed)
68 ? parsed
69 : {};
70 } catch {
71 throw new Error("Invalid JSON in values");
72 }
73}
74
75/**
76 * Sanitize values to:
77 * - block mongo operators / dot paths
78 * - allow only finite numbers
79 * - normalize -0 to 0
80 */
81function sanitizeValuesObject(obj) {
82 const clean = {};
83
84 for (const [key, value] of Object.entries(obj || {})) {
85 if (typeof key !== "string") continue;
86 if (key.startsWith("$") || key.includes(".")) continue;
87 if (key === "__proto__" || key === "constructor" || key === "prototype")
88 continue;
89
90 if (typeof value !== "number" || !Number.isFinite(value)) continue;
91
92 clean[key] = Object.is(value, -0) ? 0 : value;
93 }
94
95 return clean;
96}
97
98/**
99 * Basic semantic checks to keep behavior consistent with your backend:
100 * - A NODE can send values (valuesA / valuesB depending on which side is NODE)
101 * - OUTSIDE is only a counterparty reference and cannot mint/receive values directly via this route
102 * (If you *want* minting, remove this rule.)
103 */
104function validateTransactionSemantics(normalized) {
105 const hasA = Object.keys(normalized.valuesA || {}).length > 0;
106 const hasB = Object.keys(normalized.valuesB || {}).length > 0;
107
108 if (!hasA && !hasB) {
109 throw new Error("Transaction must trade at least one value");
110 }
111
112 // If a side is OUTSIDE, it must not carry a values payload on that side.
113 // (Prevents accidental minting or confusing directionality.)
114 if (normalized.sideA.kind === "OUTSIDE" && hasA) {
115 throw new Error("Outside side cannot send values");
116 }
117 if (normalized.sideB.kind === "OUTSIDE" && hasB) {
118 throw new Error("Outside side cannot send values");
119 }
120
121 // If one side is OUTSIDE, the other side must be NODE (your core logic enforces this too,
122 // but we keep input errors friendly here).
123 const outsideCount =
124 (normalized.sideA.kind === "OUTSIDE" ? 1 : 0) +
125 (normalized.sideB.kind === "OUTSIDE" ? 1 : 0);
126
127 if (outsideCount > 1) {
128 throw new Error("Only one transaction side may be OUTSIDE.");
129 }
130
131 // If NODE ↔ OUTSIDE, require the NODE side's version index to be present.
132 if (normalized.sideA.kind === "NODE") {
133 if (
134 typeof normalized.versionAIndex !== "number" ||
135 isNaN(normalized.versionAIndex)
136 ) {
137 throw new Error("versionAIndex is required for NODE sideA");
138 }
139 }
140}
141
142/**
143 * LIST TRANSACTIONS FOR NODE + VERSION
144 */
145router.get("/node/:nodeId/:version/transactions", htmlAuth, async (req, res) => {
146 try {
147 const { nodeId, version } = req.params;
148 const parsedVersion = Number(version);
149
150 if (isNaN(parsedVersion)) {
151 return sendError(res, 400, ERR.INVALID_INPUT, "Invalid version");
152 }
153
154 const filtered = Object.entries(req.query)
155 .filter(([key]) => allowedParams.includes(key))
156 .map(([key, val]) => (val === "" ? key : `${key}=${val}`))
157 .join("&");
158
159 const queryString = filtered ? `?${filtered}` : "";
160
161 const result = await getTransactions({
162 nodeId,
163 version: parsedVersion,
164 includePending: true,
165 userId: req.userId,
166 });
167
168 const wantHtml = "html" in req.query;
169
170 // JSON MODE
171 if (!wantHtml || !getExtension("html-rendering")) {
172 return sendOk(res, {
173 nodeId,
174 version: parsedVersion,
175 ...result,
176 });
177 }
178
179 const nodeName = await getNodeName(nodeId);
180 const transactions = result.transactions || [];
181
182 return res.send(
183 renderTransactionsList({
184 nodeId,
185 version: parsedVersion,
186 nodeName,
187 transactions,
188 queryString,
189 }),
190 );
191 } catch (err) {
192 log.error("Transactions", "transactions list error:", err);
193 sendError(res, 500, ERR.INTERNAL, err.message);
194 }
195});
196
197/**
198 * CREATE TRANSACTION
199 */
200router.post(
201 "/node/:nodeId/:version/transactions",
202 authenticate,
203
204 async (req, res) => {
205 try {
206 const normalized = normalizeTransactionBody(req.body);
207
208 // Friendly input-level checks (prevents confusing backend behavior)
209 validateTransactionSemantics(normalized);
210
211 const transaction = await createTransaction({
212 ...normalized,
213 userId: req.userId,
214 });
215
216 if ("html" in req.query) {
217 return res.redirect(
218 `/api/v1/node/${req.params.nodeId}/${req.params.version}/transactions${
219 req.query.token ? `?token=${req.query.token}&html` : "?html"
220 }`,
221 );
222 }
223
224 return sendOk(res, { transaction });
225 } catch (err) {
226 sendError(res, 400, ERR.INVALID_INPUT, err.message);
227 }
228 },
229);
230
231/**
232 * APPROVE TRANSACTION
233 */
234router.post(
235 "/node/:nodeId/:version/transactions/:transactionId/approve",
236 authenticate,
237
238 async (req, res) => {
239 try {
240 const tx = await applyApproval(req.params.transactionId, req.userId);
241
242 if ("html" in req.query) {
243 const qs = [];
244 if (req.query.token) qs.push(`token=${req.query.token}`);
245 qs.push("html");
246 return res.redirect(
247 `/api/v1/node/${req.params.nodeId}/${
248 req.params.version
249 }/transactions?${qs.join("&")}`,
250 );
251 }
252
253 return sendOk(res, { transaction: tx });
254 } catch (err) {
255 sendError(res, 400, ERR.INVALID_INPUT, err.message);
256 }
257 },
258);
259router.post(
260 "/node/:nodeId/:version/transactions/:transactionId/deny",
261 authenticate,
262
263 async (req, res) => {
264 try {
265 await denyTransaction(req.params.transactionId, req.userId);
266
267 if ("html" in req.query) {
268 const qs = [];
269 if (req.query.token) qs.push(`token=${req.query.token}`);
270 qs.push("html");
271
272 return res.redirect(
273 `/api/v1/node/${req.params.nodeId}/${
274 req.params.version
275 }/transactions?${qs.join("&")}`,
276 );
277 }
278
279 sendOk(res);
280 } catch (err) {
281 sendError(res, 400, ERR.INVALID_INPUT, err.message);
282 }
283 },
284);
285
286/**
287 * Normalize & sanitize request body to match the core transaction backend.
288 * - valuesA/valuesB become clean plain objects of finite numbers only
289 * - indices become Numbers when present
290 */
291function normalizeTransactionBody(body) {
292 const sideA = {
293 kind: body["sideA.kind"],
294 nodeId: body["sideA.nodeId"],
295 sourceType: body["sideA.sourceType"],
296 sourceId: body["sideA.sourceId"],
297 };
298
299 const sideB = {
300 kind: body["sideB.kind"],
301 nodeId: body["sideB.nodeId"],
302 sourceType: body["sideB.sourceType"],
303 sourceId: body["sideB.sourceId"],
304 };
305
306 const rawA = safeParseValues(body.valuesA);
307 const rawB = safeParseValues(body.valuesB);
308
309 const valuesA = sanitizeValuesObject(rawA);
310 const valuesB = sanitizeValuesObject(rawB);
311
312 return {
313 sideA,
314 sideB,
315 versionAIndex:
316 body.versionAIndex !== undefined && body.versionAIndex !== ""
317 ? Number(body.versionAIndex)
318 : undefined,
319 versionBIndex:
320 body.versionBIndex !== undefined && body.versionBIndex !== ""
321 ? Number(body.versionBIndex)
322 : undefined,
323 valuesA,
324 valuesB,
325 };
326}
327
328/**
329 * GET TRANSACTION + ALL CONTRIBUTIONS
330 * GET /transactions/:transactionId
331 */
332router.get(
333 "/node/:nodeId/:version/transactions/:transactionId",
334 htmlAuth,
335 async (req, res) => {
336 try {
337 const { nodeId, version, transactionId } = req.params;
338
339 const filtered = Object.entries(req.query)
340 .filter(([key]) => allowedParams.includes(key))
341 .map(([key, val]) => (val === "" ? key : `${key}=${val}`))
342 .join("&");
343
344 const queryString = filtered ? `?${filtered}` : "";
345
346 const result = await getTransactionWithContributions(transactionId);
347
348 const wantHtml = "html" in req.query;
349
350 // JSON MODE
351 if (!wantHtml || !getExtension("html-rendering")) {
352 return sendOk(res, result);
353 }
354
355 const tx = result.transaction;
356 const contributions = result.contributions || [];
357
358 // Get node names
359 const sideANodeName =
360 tx.sideA.kind === "NODE"
361 ? await getNodeName(tx.sideA.nodeId)
362 : "External Source";
363
364 const sideBNodeName =
365 tx.sideB.kind === "NODE"
366 ? await getNodeName(tx.sideB.nodeId)
367 : "External Source";
368
369 return res.send(
370 renderTransactionDetail({
371 nodeId,
372 version,
373 transactionId,
374 tx,
375 contributions,
376 sideANodeName,
377 sideBNodeName,
378 queryString,
379 }),
380 );
381 } catch (err) {
382 const status =
383 err.message === "Transaction not found" ||
384 err.message === "transactionId is required"
385 ? 400
386 : 500;
387
388 const code = status === 400 ? ERR.INVALID_INPUT : ERR.INTERNAL;
389 sendError(res, status, code, err.message);
390 }
391 },
392);
393
394// Versionless aliases (protocol-compliant)
395async function useLatest(req, res, next) {
396 try {
397 req.params.version = String(await resolveVersion(req.params.nodeId, "latest"));
398 next();
399 } catch (err) {
400 return sendError(res, 404, ERR.NODE_NOT_FOUND, err.message);
401 }
402}
403
404router.get("/node/:nodeId/transactions", htmlAuth, useLatest, (req, res, next) => {
405 req.url = `/node/${req.params.nodeId}/${req.params.version}/transactions`;
406 router.handle(req, res, next);
407});
408
409router.post("/node/:nodeId/transactions", authenticate, useLatest, (req, res, next) => {
410 req.url = `/node/${req.params.nodeId}/${req.params.version}/transactions`;
411 router.handle(req, res, next);
412});
413
414router.post("/node/:nodeId/transactions/:transactionId/approve", authenticate, useLatest, (req, res, next) => {
415 req.url = `/node/${req.params.nodeId}/${req.params.version}/transactions/${req.params.transactionId}/approve`;
416 router.handle(req, res, next);
417});
418
419router.post("/node/:nodeId/transactions/:transactionId/deny", authenticate, useLatest, (req, res, next) => {
420 req.url = `/node/${req.params.nodeId}/${req.params.version}/transactions/${req.params.transactionId}/deny`;
421 router.handle(req, res, next);
422});
423
424// Transaction policy (moved from routes/api/root.js)
425router.post("/root/:nodeId/transaction-policy", authenticate, async (req, res) => {
426 try {
427 const { nodeId } = req.params;
428 const { policy } = req.body;
429
430 const result = await setTransactionPolicy({
431 rootNodeId: nodeId,
432 policy,
433 userId: req.userId,
434 });
435
436 if ("html" in req.query) {
437 return res.redirect(
438 `/api/v1/root/${nodeId}?token=${req.query.token ?? ""}&html`,
439 );
440 }
441
442 return sendOk(res, result);
443 } catch (err) {
444 log.error("Transactions", "Change policy error:", err);
445 sendError(res, 400, ERR.INVALID_INPUT, err.message);
446 }
447});
448
449export default router;
450
Loading comments...