From 9eac62332f7c752cd025f3dba7d106fedd8d65d6 Mon Sep 17 00:00:00 2001
From: 0xPatrick <patrick@0xpatrick.dev>
Date: Wed, 6 Nov 2024 20:45:00 -0500
Subject: [PATCH] feat: `StatusManager` tracks `seenTxs`

- use a composite key of `txHash+chainId` to track unique `EventFeed` submissions
---
 packages/fast-usdc/src/exos/status-manager.js | 34 +++++++++++++++++--
 packages/fast-usdc/src/types.ts               |  3 ++
 .../test/exos/status-manager.test.ts          | 23 +++++++++++++
 3 files changed, 58 insertions(+), 2 deletions(-)

diff --git a/packages/fast-usdc/src/exos/status-manager.js b/packages/fast-usdc/src/exos/status-manager.js
index 4b18fc75ee36..89920ff05b70 100644
--- a/packages/fast-usdc/src/exos/status-manager.js
+++ b/packages/fast-usdc/src/exos/status-manager.js
@@ -6,9 +6,9 @@ import { CctpTxEvidenceShape, PendingTxShape } from '../typeGuards.js';
 import { PendingTxStatus } from '../constants.js';
 
 /**
- * @import {MapStore} from '@agoric/store';
+ * @import {MapStore, SetStore} from '@agoric/store';
  * @import {Zone} from '@agoric/zone';
- * @import {CctpTxEvidence, NobleAddress, PendingTxKey, PendingTx} from '../types.js';
+ * @import {CctpTxEvidence, NobleAddress, SeenTxKey, PendingTxKey, PendingTx} from '../types.js';
  */
 
 /**
@@ -35,6 +35,20 @@ const pendingTxKeyOf = evidence => {
   return makePendingTxKey(forwardingAddress, amount);
 };
 
+/**
+ * Get the key for the seenTxs SetStore.
+ *
+ * The key is a composite of `NobleAddress` and transaction `amount` and not
+ * meant to be parsable.
+ *
+ * @param {CctpTxEvidence} evidence
+ * @returns {SeenTxKey}
+ */
+const seenTxKeyOf = evidence => {
+  const { txHash, chainId } = evidence;
+  return `seenTx:${JSON.stringify([txHash, chainId])}`;
+};
+
 /**
  * The `StatusManager` keeps track of Pending and Seen Transactions
  * via {@link PendingTxStatus} states, aiding in coordination between the `Advancer`
@@ -51,11 +65,27 @@ export const prepareStatusManager = zone => {
     valueShape: M.arrayOf(PendingTxShape),
   });
 
+  /** @type {SetStore<SeenTxKey>} */
+  const seenTxs = zone.setStore('SeenTxs', {
+    keyShape: M.string(),
+  });
+
   /**
+   * Ensures that `txHash+chainId` has not been processed
+   * and adds entry to `seenTxs` set.
+   *
+   * Also records the CctpTxEvidence and status in `pendingTxs`.
+   *
    * @param {CctpTxEvidence} evidence
    * @param {PendingTxStatus} status
    */
   const recordPendingTx = (evidence, status) => {
+    const seenKey = seenTxKeyOf(evidence);
+    if (seenTxs.has(seenKey)) {
+      throw makeError(`Transaction already seen: ${q(seenKey)}`);
+    }
+    seenTxs.add(seenKey);
+
     appendToStoredArray(
       pendingTxs,
       pendingTxKeyOf(evidence),
diff --git a/packages/fast-usdc/src/types.ts b/packages/fast-usdc/src/types.ts
index efcdeaa7f4f4..ed6249f2ff4b 100644
--- a/packages/fast-usdc/src/types.ts
+++ b/packages/fast-usdc/src/types.ts
@@ -32,4 +32,7 @@ export interface PendingTx extends CctpTxEvidence {
 /** internal key for `StatusManager` exo */
 export type PendingTxKey = `pendingTx:${string}`;
 
+/** internal key for `StatusManager` exo */
+export type SeenTxKey = `seenTx:${string}`;
+
 export type * from './constants.js';
diff --git a/packages/fast-usdc/test/exos/status-manager.test.ts b/packages/fast-usdc/test/exos/status-manager.test.ts
index 45638f9d771d..95b1e9825348 100644
--- a/packages/fast-usdc/test/exos/status-manager.test.ts
+++ b/packages/fast-usdc/test/exos/status-manager.test.ts
@@ -35,6 +35,29 @@ test('observe creates new entry with OBSERVED status', t => {
   t.is(entries[0]?.status, PendingTxStatus.Observed);
 });
 
+test('cannot process same tx twice', t => {
+  const zone = provideDurableZone('status-test');
+  const statusManager = prepareStatusManager(zone.subZone('status-manager'));
+
+  const evidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO();
+  statusManager.advance(evidence);
+
+  t.throws(() => statusManager.advance(evidence), {
+    message:
+      'Transaction already seen: "seenTx:[\\"0xc81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761702\\",1]"',
+  });
+
+  t.throws(() => statusManager.observe(evidence), {
+    message:
+      'Transaction already seen: "seenTx:[\\"0xc81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761702\\",1]"',
+  });
+
+  // new txHash should not throw
+  t.notThrows(() => statusManager.advance({ ...evidence, txHash: '0xtest2' }));
+  // new chainId with existing txHash should not throw
+  t.notThrows(() => statusManager.advance({ ...evidence, chainId: 9999 }));
+});
+
 test('settle removes entries from PendingTxs', t => {
   const zone = provideDurableZone('status-test');
   const statusManager = prepareStatusManager(zone.subZone('status-manager'));