EXTENSION for TreeOS
transactions
Nodes can trade value with each other. A transaction is a two-sided exchange: node A sends values to node B, and node B sends values back to node A. Or one side is OUTSIDE, representing an external source like a Solana wallet. The extension handles the full lifecycle: proposal, approval, execution, and rejection. Four approval policies govern who can authorize transactions on a tree. OWNER_ONLY: only the root owner can approve. ANYONE: any contributor can approve with a single vote. MAJORITY: more than half of all members (owner plus contributors) must approve. ALL: unanimous consent required. The policy is set per tree root. The proposer auto-approves their own side if they are an eligible approver. If that single approval satisfies the policy, the transaction executes immediately without waiting. Execution is atomic. Both sides are debited and credited in a single operation. If either side has insufficient balance for what it is sending, the transaction fails and is marked rejected. Values are read from and written to node metadata through the values extension's storage format. Denial works symmetrically: an eligible approver can deny instead of approve. Under OWNER_ONLY, ANYONE, or ALL policies, a single denial kills the transaction. Under MAJORITY, denials must reach the same threshold as approvals. Every state change is logged as a contribution on both participating nodes with full metadata: which side, what role (proposer, approver, denier, system), which event (created, approved, denied, execution_started, succeeded, failed, accepted_by_policy, rejected_by_policy), and the values sent and received. The transaction detail endpoint returns the full contribution timeline for audit.
v1.0.1 by TreeOS Site 0 downloads 7 files 3,150 lines 78.2 KB published 38d ago
treeos ext install transactions
View changelog

Manifest

Provides

  • 1 models
  • routes
  • 1 CLI commands
  • 1 energy actions

Requires

  • services: contributions, auth
  • models: Node, Contribution

Optional

  • services: energy
  • extensions: html-rendering, treeos-base
SHA256: 9ca23628a275eee1deb069d761ebaa532b0808d11d65c42d5740c169cb6b20a9

CLI Commands

CommandMethodDescription
transactionsGETList transactions for current node

Source Code

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

Versions

Version Published Downloads
1.0.1 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star transactions

Comments

Loading comments...

Post comments from the CLI: treeos ext comment transactions "your comment"
Max 3 comments per extension. One star and one flag per user.