From 0cd3170e14ca6d4a36ca4b3ffa2b77a3f3f833b0 Mon Sep 17 00:00:00 2001 From: ECWireless Date: Wed, 3 Jun 2026 16:25:24 -0600 Subject: [PATCH 1/2] feat: add treasury transaction ingestion --- .env.example | 2 + drizzle/0006_shocking_franklin_storm.sql | 56 + drizzle/meta/0006_snapshot.json | 2032 +++++++++++++++++ drizzle/meta/_journal.json | 7 + src/app/api/treasury/transactions/route.ts | 80 + .../treasury/treasury-dashboard.tsx | 28 +- src/db/schema.ts | 95 + src/lib/treasury/accounts.ts | 25 + src/lib/treasury/transactions.ts | 566 +++++ 9 files changed, 2876 insertions(+), 15 deletions(-) create mode 100644 drizzle/0006_shocking_franklin_storm.sql create mode 100644 drizzle/meta/0006_snapshot.json create mode 100644 src/app/api/treasury/transactions/route.ts create mode 100644 src/lib/treasury/transactions.ts diff --git a/.env.example b/.env.example index 31162fb..cda8ce2 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,5 @@ ANGRY_DWARF_HAT_ID= # External APIs # Optional; public CoinGecko price endpoint is used when unset. COINGECKO_API_KEY= +# Optional; defaults to Safe's Gnosis Transaction Service endpoint. +SAFE_TRANSACTION_SERVICE_URL= diff --git a/drizzle/0006_shocking_franklin_storm.sql b/drizzle/0006_shocking_franklin_storm.sql new file mode 100644 index 0000000..13817a7 --- /dev/null +++ b/drizzle/0006_shocking_franklin_storm.sql @@ -0,0 +1,56 @@ +CREATE TYPE "public"."treasury_transfer_direction" AS ENUM('inflow', 'outflow', 'internal');--> statement-breakpoint +CREATE TABLE "treasury_transaction_transfers" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "treasury_transaction_id" uuid NOT NULL, + "treasury_account_id" uuid, + "transfer_id" text NOT NULL, + "direction" "treasury_transfer_direction" NOT NULL, + "transfer_type" text NOT NULL, + "account_address" text NOT NULL, + "chain_id" integer NOT NULL, + "tx_hash" text NOT NULL, + "executed_at" timestamp with time zone NOT NULL, + "from_address" text NOT NULL, + "to_address" text NOT NULL, + "token_address" text, + "asset_symbol" text NOT NULL, + "asset_name" text NOT NULL, + "decimals" integer NOT NULL, + "raw_amount" numeric(78, 0) NOT NULL, + "amount" numeric(36, 18) NOT NULL, + "usd_price" numeric(18, 8), + "usd_amount" numeric(18, 2), + "raw_metadata" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "treasury_transactions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "treasury_account_id" uuid, + "source" "ledger_source" NOT NULL, + "account_address" text NOT NULL, + "chain_id" integer NOT NULL, + "tx_hash" text NOT NULL, + "safe_transaction_hash" text, + "transaction_type" text NOT NULL, + "executed_at" timestamp with time zone NOT NULL, + "block_number" integer, + "raw_metadata" jsonb, + "imported_at" timestamp with time zone DEFAULT now() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "treasury_transaction_transfers" ADD CONSTRAINT "treasury_transaction_transfers_treasury_transaction_id_treasury_transactions_id_fk" FOREIGN KEY ("treasury_transaction_id") REFERENCES "public"."treasury_transactions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "treasury_transaction_transfers" ADD CONSTRAINT "treasury_transaction_transfers_treasury_account_id_treasury_accounts_id_fk" FOREIGN KEY ("treasury_account_id") REFERENCES "public"."treasury_accounts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "treasury_transactions" ADD CONSTRAINT "treasury_transactions_treasury_account_id_treasury_accounts_id_fk" FOREIGN KEY ("treasury_account_id") REFERENCES "public"."treasury_accounts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "treasury_transaction_transfers_chain_transfer_unique" ON "treasury_transaction_transfers" USING btree ("chain_id",lower("account_address"),"transfer_id");--> statement-breakpoint +CREATE INDEX "treasury_transaction_transfers_transaction_id_idx" ON "treasury_transaction_transfers" USING btree ("treasury_transaction_id");--> statement-breakpoint +CREATE INDEX "treasury_transaction_transfers_treasury_account_id_idx" ON "treasury_transaction_transfers" USING btree ("treasury_account_id");--> statement-breakpoint +CREATE INDEX "treasury_transaction_transfers_tx_hash_idx" ON "treasury_transaction_transfers" USING btree ("tx_hash");--> statement-breakpoint +CREATE INDEX "treasury_transaction_transfers_executed_at_idx" ON "treasury_transaction_transfers" USING btree ("executed_at");--> statement-breakpoint +CREATE UNIQUE INDEX "treasury_transactions_chain_account_tx_unique" ON "treasury_transactions" USING btree ("chain_id",lower("account_address"),lower("tx_hash"));--> statement-breakpoint +CREATE INDEX "treasury_transactions_tx_hash_idx" ON "treasury_transactions" USING btree ("tx_hash");--> statement-breakpoint +CREATE INDEX "treasury_transactions_account_executed_idx" ON "treasury_transactions" USING btree ("chain_id","account_address","executed_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "treasury_transactions_treasury_account_id_idx" ON "treasury_transactions" USING btree ("treasury_account_id"); diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..3c6d7b8 --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,2032 @@ +{ + "id": "579ab073-3a14-490d-adf3-ce9c785df55b", + "prevId": "8eddacdc-134b-4d19-b9ce-71d3f8bc0041", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app_users": { + "name": "app_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "wallet_address": { + "name": "wallet_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_encrypted": { + "name": "display_name_encrypted", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "app_users_wallet_address_unique": { + "name": "app_users_wallet_address_unique", + "columns": [ + { + "expression": "lower(\"wallet_address\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_wallet_address": { + "name": "actor_wallet_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "audit_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "subject_table": { + "name": "subject_table", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "quarter_id": { + "name": "quarter_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_subject_idx": { + "name": "audit_events_subject_idx", + "columns": [ + { + "expression": "subject_table", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_user_id_idx": { + "name": "audit_events_actor_user_id_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_quarter_id_idx": { + "name": "audit_events_quarter_id_idx", + "columns": [ + { + "expression": "quarter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_events_actor_user_id_app_users_id_fk": { + "name": "audit_events_actor_user_id_app_users_id_fk", + "tableFrom": "audit_events", + "tableTo": "app_users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_events_quarter_id_quarters_id_fk": { + "name": "audit_events_quarter_id_quarters_id_fk", + "tableFrom": "audit_events", + "tableTo": "quarters", + "columnsFrom": [ + "quarter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cleric_roles": { + "name": "cleric_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "wallet_address": { + "name": "wallet_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by_user_id": { + "name": "granted_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_user_id": { + "name": "revoked_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cleric_roles_wallet_address_idx": { + "name": "cleric_roles_wallet_address_idx", + "columns": [ + { + "expression": "lower(\"wallet_address\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cleric_roles_active_idx": { + "name": "cleric_roles_active_idx", + "columns": [ + { + "expression": "lower(\"wallet_address\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cleric_roles_granted_by_user_id_app_users_id_fk": { + "name": "cleric_roles_granted_by_user_id_app_users_id_fk", + "tableFrom": "cleric_roles", + "tableTo": "app_users", + "columnsFrom": [ + "granted_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cleric_roles_revoked_by_user_id_app_users_id_fk": { + "name": "cleric_roles_revoked_by_user_id_app_users_id_fk", + "tableFrom": "cleric_roles", + "tableTo": "app_users", + "columnsFrom": [ + "revoked_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entities": { + "name": "entities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name_encrypted": { + "name": "name_encrypted", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "website_encrypted": { + "name": "website_encrypted", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notes_encrypted": { + "name": "notes_encrypted", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_member": { + "name": "is_member", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "entities_type_idx": { + "name": "entities_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "entities_archived_at_idx": { + "name": "entities_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entity_addresses": { + "name": "entity_addresses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chain_id": { + "name": "chain_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "label_encrypted": { + "name": "label_encrypted", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "entity_addresses_entity_id_idx": { + "name": "entity_addresses_entity_id_idx", + "columns": [ + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "entity_addresses_address_idx": { + "name": "entity_addresses_address_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "entity_addresses_chain_address_unique": { + "name": "entity_addresses_chain_address_unique", + "columns": [ + { + "expression": "coalesce(\"chain_id\", -1)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "lower(\"address\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entity_addresses_entity_id_entities_id_fk": { + "name": "entity_addresses_entity_id_entities_id_fk", + "tableFrom": "entity_addresses", + "tableTo": "entities", + "columnsFrom": [ + "entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ledger_entries": { + "name": "ledger_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "quarter_id": { + "name": "quarter_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "ledger_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "ledger_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'uncategorized'" + }, + "verification_status": { + "name": "verification_status", + "type": "verification_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'verified'" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "chain_id": { + "name": "chain_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "treasury_account_id": { + "name": "treasury_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "asset_symbol": { + "name": "asset_symbol", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asset_amount": { + "name": "asset_amount", + "type": "numeric(36, 18)", + "primaryKey": false, + "notNull": true + }, + "usd_amount": { + "name": "usd_amount", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": true + }, + "counterparty_entity_id": { + "name": "counterparty_entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "raid_id": { + "name": "raid_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "notes_encrypted": { + "name": "notes_encrypted", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_metadata": { + "name": "source_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ledger_entries_quarter_id_idx": { + "name": "ledger_entries_quarter_id_idx", + "columns": [ + { + "expression": "quarter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ledger_entries_category_idx": { + "name": "ledger_entries_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ledger_entries_tx_hash_idx": { + "name": "ledger_entries_tx_hash_idx", + "columns": [ + { + "expression": "tx_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ledger_entries_raid_id_idx": { + "name": "ledger_entries_raid_id_idx", + "columns": [ + { + "expression": "raid_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ledger_entries_occurred_at_idx": { + "name": "ledger_entries_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ledger_entries_quarter_id_quarters_id_fk": { + "name": "ledger_entries_quarter_id_quarters_id_fk", + "tableFrom": "ledger_entries", + "tableTo": "quarters", + "columnsFrom": [ + "quarter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ledger_entries_treasury_account_id_treasury_accounts_id_fk": { + "name": "ledger_entries_treasury_account_id_treasury_accounts_id_fk", + "tableFrom": "ledger_entries", + "tableTo": "treasury_accounts", + "columnsFrom": [ + "treasury_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ledger_entries_counterparty_entity_id_entities_id_fk": { + "name": "ledger_entries_counterparty_entity_id_entities_id_fk", + "tableFrom": "ledger_entries", + "tableTo": "entities", + "columnsFrom": [ + "counterparty_entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ledger_entries_raid_id_raids_id_fk": { + "name": "ledger_entries_raid_id_raids_id_fk", + "tableFrom": "ledger_entries", + "tableTo": "raids", + "columnsFrom": [ + "raid_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quarters": { + "name": "quarters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "quarter": { + "name": "quarter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "starts_on": { + "name": "starts_on", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "ends_on": { + "name": "ends_on", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "quarter_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reopened_at": { + "name": "reopened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quarters_year_quarter_unique": { + "name": "quarters_year_quarter_unique", + "columns": [ + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "quarter", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quarters_status_idx": { + "name": "quarters_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.raids": { + "name": "raids", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_entity_id": { + "name": "client_entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name_encrypted": { + "name": "name_encrypted", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "notes_encrypted": { + "name": "notes_encrypted", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "raids_client_entity_id_idx": { + "name": "raids_client_entity_id_idx", + "columns": [ + { + "expression": "client_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "raids_archived_at_idx": { + "name": "raids_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "raids_client_entity_id_entities_id_fk": { + "name": "raids_client_entity_id_entities_id_fk", + "tableFrom": "raids", + "tableTo": "entities", + "columnsFrom": [ + "client_entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.treasury_accounts": { + "name": "treasury_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name_encrypted": { + "name": "name_encrypted", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chain_id": { + "name": "chain_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "treasury_account_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "is_dao_controlled": { + "name": "is_dao_controlled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notes_encrypted": { + "name": "notes_encrypted", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "treasury_accounts_chain_address_unique": { + "name": "treasury_accounts_chain_address_unique", + "columns": [ + { + "expression": "chain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "treasury_accounts_type_idx": { + "name": "treasury_accounts_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "treasury_accounts_archived_at_idx": { + "name": "treasury_accounts_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.treasury_balance_assets": { + "name": "treasury_balance_assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "symbol": { + "name": "symbol", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decimals": { + "name": "decimals", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "raw_amount": { + "name": "raw_amount", + "type": "numeric(78, 0)", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "numeric(36, 18)", + "primaryKey": false, + "notNull": true + }, + "usd_price": { + "name": "usd_price", + "type": "numeric(18, 8)", + "primaryKey": false, + "notNull": true + }, + "usd_value": { + "name": "usd_value", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "treasury_balance_assets_snapshot_id_idx": { + "name": "treasury_balance_assets_snapshot_id_idx", + "columns": [ + { + "expression": "snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "treasury_balance_assets_snapshot_symbol_unique": { + "name": "treasury_balance_assets_snapshot_symbol_unique", + "columns": [ + { + "expression": "snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "symbol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "treasury_balance_assets_snapshot_id_treasury_balance_snapshots_id_fk": { + "name": "treasury_balance_assets_snapshot_id_treasury_balance_snapshots_id_fk", + "tableFrom": "treasury_balance_assets", + "tableTo": "treasury_balance_snapshots", + "columnsFrom": [ + "snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.treasury_balance_snapshots": { + "name": "treasury_balance_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_address": { + "name": "account_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chain_id": { + "name": "chain_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "treasury_snapshot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "total_usd": { + "name": "total_usd", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": true + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "treasury_balance_snapshots_chain_account_synced_idx": { + "name": "treasury_balance_snapshots_chain_account_synced_idx", + "columns": [ + { + "expression": "chain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_address", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "synced_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "treasury_balance_snapshots_synced_at_idx": { + "name": "treasury_balance_snapshots_synced_at_idx", + "columns": [ + { + "expression": "synced_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.treasury_transaction_transfers": { + "name": "treasury_transaction_transfers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "treasury_transaction_id": { + "name": "treasury_transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "treasury_account_id": { + "name": "treasury_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "transfer_id": { + "name": "transfer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "treasury_transfer_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "transfer_type": { + "name": "transfer_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_address": { + "name": "account_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chain_id": { + "name": "chain_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "from_address": { + "name": "from_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_address": { + "name": "to_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_address": { + "name": "token_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "asset_symbol": { + "name": "asset_symbol", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asset_name": { + "name": "asset_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decimals": { + "name": "decimals", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "raw_amount": { + "name": "raw_amount", + "type": "numeric(78, 0)", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(36, 18)", + "primaryKey": false, + "notNull": true + }, + "usd_price": { + "name": "usd_price", + "type": "numeric(18, 8)", + "primaryKey": false, + "notNull": false + }, + "usd_amount": { + "name": "usd_amount", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": false + }, + "raw_metadata": { + "name": "raw_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "treasury_transaction_transfers_chain_transfer_unique": { + "name": "treasury_transaction_transfers_chain_transfer_unique", + "columns": [ + { + "expression": "chain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"account_address\")", + "isExpression": true, + "asc": true, + "nulls": "last" + }, + { + "expression": "transfer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "treasury_transaction_transfers_transaction_id_idx": { + "name": "treasury_transaction_transfers_transaction_id_idx", + "columns": [ + { + "expression": "treasury_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "treasury_transaction_transfers_treasury_account_id_idx": { + "name": "treasury_transaction_transfers_treasury_account_id_idx", + "columns": [ + { + "expression": "treasury_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "treasury_transaction_transfers_tx_hash_idx": { + "name": "treasury_transaction_transfers_tx_hash_idx", + "columns": [ + { + "expression": "tx_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "treasury_transaction_transfers_executed_at_idx": { + "name": "treasury_transaction_transfers_executed_at_idx", + "columns": [ + { + "expression": "executed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "treasury_transaction_transfers_treasury_transaction_id_treasury_transactions_id_fk": { + "name": "treasury_transaction_transfers_treasury_transaction_id_treasury_transactions_id_fk", + "tableFrom": "treasury_transaction_transfers", + "tableTo": "treasury_transactions", + "columnsFrom": [ + "treasury_transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "treasury_transaction_transfers_treasury_account_id_treasury_accounts_id_fk": { + "name": "treasury_transaction_transfers_treasury_account_id_treasury_accounts_id_fk", + "tableFrom": "treasury_transaction_transfers", + "tableTo": "treasury_accounts", + "columnsFrom": [ + "treasury_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.treasury_transactions": { + "name": "treasury_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "treasury_account_id": { + "name": "treasury_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "ledger_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "account_address": { + "name": "account_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chain_id": { + "name": "chain_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "safe_transaction_hash": { + "name": "safe_transaction_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transaction_type": { + "name": "transaction_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "block_number": { + "name": "block_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "raw_metadata": { + "name": "raw_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "imported_at": { + "name": "imported_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "treasury_transactions_chain_account_tx_unique": { + "name": "treasury_transactions_chain_account_tx_unique", + "columns": [ + { + "expression": "chain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"account_address\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "lower(\"tx_hash\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "treasury_transactions_tx_hash_idx": { + "name": "treasury_transactions_tx_hash_idx", + "columns": [ + { + "expression": "tx_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "treasury_transactions_account_executed_idx": { + "name": "treasury_transactions_account_executed_idx", + "columns": [ + { + "expression": "chain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_address", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "executed_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "treasury_transactions_treasury_account_id_idx": { + "name": "treasury_transactions_treasury_account_id_idx", + "columns": [ + { + "expression": "treasury_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "treasury_transactions_treasury_account_id_treasury_accounts_id_fk": { + "name": "treasury_transactions_treasury_account_id_treasury_accounts_id_fk", + "tableFrom": "treasury_transactions", + "tableTo": "treasury_accounts", + "columnsFrom": [ + "treasury_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.audit_action": { + "name": "audit_action", + "schema": "public", + "values": [ + "create", + "update", + "delete", + "import", + "classify", + "publish", + "reopen", + "grant_role", + "revoke_role" + ] + }, + "public.entity_type": { + "name": "entity_type", + "schema": "public", + "values": [ + "client", + "provider", + "subcontractor" + ] + }, + "public.ledger_category": { + "name": "ledger_category", + "schema": "public", + "values": [ + "raid_revenue", + "subcontractor_payout", + "provider_expense", + "member_dues", + "ragequit", + "treasury_transfer", + "uncategorized" + ] + }, + "public.ledger_source": { + "name": "ledger_source", + "schema": "public", + "values": [ + "main_safe", + "side_vault", + "manual", + "bank_csv", + "dao_proposal" + ] + }, + "public.quarter_status": { + "name": "quarter_status", + "schema": "public", + "values": [ + "draft", + "ready_for_review", + "published", + "reopened" + ] + }, + "public.treasury_account_type": { + "name": "treasury_account_type", + "schema": "public", + "values": [ + "main_safe", + "side_vault", + "operator" + ] + }, + "public.treasury_snapshot_status": { + "name": "treasury_snapshot_status", + "schema": "public", + "values": [ + "pending_live_sync", + "synced", + "stale_syncing", + "partial", + "failed" + ] + }, + "public.treasury_transfer_direction": { + "name": "treasury_transfer_direction", + "schema": "public", + "values": [ + "inflow", + "outflow", + "internal" + ] + }, + "public.verification_status": { + "name": "verification_status", + "schema": "public", + "values": [ + "verified", + "unverified" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d05316c..43b416d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1780518492589, "tag": "0005_public_diamondback", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1780524457784, + "tag": "0006_shocking_franklin_storm", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/api/treasury/transactions/route.ts b/src/app/api/treasury/transactions/route.ts new file mode 100644 index 0000000..aed0e51 --- /dev/null +++ b/src/app/api/treasury/transactions/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from "next/server"; + +import { writeAuditEvent } from "@/lib/audit"; +import { getAuthSession } from "@/lib/auth/session"; +import { + getRecentTreasuryTransactions, + syncTreasuryTransactions, +} from "@/lib/treasury/transactions"; + +function getPositiveQueryInteger(value: string | null) { + if (!value) { + return undefined; + } + + const parsed = Number(value); + + return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; +} + +async function requireAdminSession() { + const session = await getAuthSession(); + + if (!session.address || !session.permissions?.canAdmin) { + return null; + } + + return session; +} + +export async function GET(request: Request) { + const session = await requireAdminSession(); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const url = new URL(request.url); + const limit = getPositiveQueryInteger(url.searchParams.get("limit")); + const transfers = await getRecentTreasuryTransactions(limit); + + return NextResponse.json({ transfers }); +} + +export async function POST(request: Request) { + const session = await requireAdminSession(); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const url = new URL(request.url); + const result = await syncTreasuryTransactions({ + limit: getPositiveQueryInteger(url.searchParams.get("limit")), + maxPages: getPositiveQueryInteger(url.searchParams.get("maxPages")), + }); + + await writeAuditEvent({ + action: "import", + actorWalletAddress: session.address, + metadata: { + accountCount: result.accounts.length, + importedTransactions: result.importedTransactions, + importedTransfers: result.importedTransfers, + scannedTransfers: result.scannedTransfers, + }, + subjectTable: "treasury_transactions", + summary: "Synced treasury transactions", + }); + + return NextResponse.json(result); + } catch (error) { + console.error("Treasury transaction sync failed", error); + + return NextResponse.json( + { error: "Treasury transaction sync failed" }, + { status: 502 }, + ); + } +} diff --git a/src/components/treasury/treasury-dashboard.tsx b/src/components/treasury/treasury-dashboard.tsx index f3530fc..63dda24 100644 --- a/src/components/treasury/treasury-dashboard.tsx +++ b/src/components/treasury/treasury-dashboard.tsx @@ -268,9 +268,12 @@ export function TreasuryDashboard({ {account.name}

{account.address ? ( - - {formatAddress(account.address)} - +
+ + {formatAddress(account.address)} + + +
) : (

Address not configured @@ -278,18 +281,13 @@ export function TreasuryDashboard({ )} -

-

- {formatCurrency(account.totalUsd)} -

- {account.address ? ( - - ) : null} -
+

+ {formatCurrency(account.totalUsd)} +

))} diff --git a/src/db/schema.ts b/src/db/schema.ts index 0859990..7db0bea 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -64,6 +64,11 @@ export const treasurySnapshotStatusEnum = pgEnum("treasury_snapshot_status", [ "failed", ]); +export const treasuryTransferDirectionEnum = pgEnum( + "treasury_transfer_direction", + ["inflow", "outflow", "internal"], +); + export const auditActionEnum = pgEnum("audit_action", [ "create", "update", @@ -217,6 +222,96 @@ export const treasuryBalanceAssets = pgTable( ], ); +export const treasuryTransactions = pgTable( + "treasury_transactions", + { + id: uuid("id").defaultRandom().primaryKey(), + treasuryAccountId: uuid("treasury_account_id").references( + () => treasuryAccounts.id, + { onDelete: "set null" }, + ), + source: ledgerSourceEnum("source").notNull(), + accountAddress: text("account_address").notNull(), + chainId: integer("chain_id").notNull(), + txHash: text("tx_hash").notNull(), + safeTransactionHash: text("safe_transaction_hash"), + transactionType: text("transaction_type").notNull(), + executedAt: timestamp("executed_at", { withTimezone: true }).notNull(), + blockNumber: integer("block_number"), + rawMetadata: jsonb("raw_metadata"), + importedAt: timestamp("imported_at", { withTimezone: true }) + .defaultNow() + .notNull(), + ...timestamps, + }, + (table) => [ + uniqueIndex("treasury_transactions_chain_account_tx_unique").on( + table.chainId, + sql`lower(${table.accountAddress})`, + sql`lower(${table.txHash})`, + ), + index("treasury_transactions_tx_hash_idx").on(table.txHash), + index("treasury_transactions_account_executed_idx").on( + table.chainId, + table.accountAddress, + table.executedAt.desc(), + ), + index("treasury_transactions_treasury_account_id_idx").on( + table.treasuryAccountId, + ), + ], +); + +export const treasuryTransactionTransfers = pgTable( + "treasury_transaction_transfers", + { + id: uuid("id").defaultRandom().primaryKey(), + treasuryTransactionId: uuid("treasury_transaction_id") + .notNull() + .references(() => treasuryTransactions.id, { onDelete: "cascade" }), + treasuryAccountId: uuid("treasury_account_id").references( + () => treasuryAccounts.id, + { onDelete: "set null" }, + ), + transferId: text("transfer_id").notNull(), + direction: treasuryTransferDirectionEnum("direction").notNull(), + transferType: text("transfer_type").notNull(), + accountAddress: text("account_address").notNull(), + chainId: integer("chain_id").notNull(), + txHash: text("tx_hash").notNull(), + executedAt: timestamp("executed_at", { withTimezone: true }).notNull(), + fromAddress: text("from_address").notNull(), + toAddress: text("to_address").notNull(), + tokenAddress: text("token_address"), + assetSymbol: text("asset_symbol").notNull(), + assetName: text("asset_name").notNull(), + decimals: integer("decimals").notNull(), + rawAmount: numeric("raw_amount", { precision: 78, scale: 0 }).notNull(), + amount: numeric("amount", { precision: 36, scale: 18 }).notNull(), + usdPrice: numeric("usd_price", { precision: 18, scale: 8 }), + usdAmount: numeric("usd_amount", { precision: 18, scale: 2 }), + rawMetadata: jsonb("raw_metadata"), + ...timestamps, + }, + (table) => [ + uniqueIndex("treasury_transaction_transfers_chain_transfer_unique").on( + table.chainId, + sql`lower(${table.accountAddress})`, + table.transferId, + ), + index("treasury_transaction_transfers_transaction_id_idx").on( + table.treasuryTransactionId, + ), + index("treasury_transaction_transfers_treasury_account_id_idx").on( + table.treasuryAccountId, + ), + index("treasury_transaction_transfers_tx_hash_idx").on(table.txHash), + index("treasury_transaction_transfers_executed_at_idx").on( + table.executedAt, + ), + ], +); + export const entities = pgTable( "entities", { diff --git a/src/lib/treasury/accounts.ts b/src/lib/treasury/accounts.ts index 3984cd7..df25cb2 100644 --- a/src/lib/treasury/accounts.ts +++ b/src/lib/treasury/accounts.ts @@ -155,3 +155,28 @@ export async function listActiveGnosisBalanceAccounts(): Promise< type: account.type as EditableTreasuryAccountType, })); } + +export async function listActiveGnosisSideVaultAccounts(): Promise< + TreasuryBalanceAccountSource[] +> { + const db = getDb(); + const accounts = await db + .select() + .from(treasuryAccounts) + .where( + and( + isNull(treasuryAccounts.archivedAt), + eq(treasuryAccounts.chainId, gnosis.id), + eq(treasuryAccounts.type, "side_vault"), + ), + ) + .orderBy(asc(treasuryAccounts.createdAt)); + + return accounts.map((account) => ({ + id: account.id, + name: decryptField(account.nameEncrypted as EncryptedField), + address: getAddress(account.address), + chainId: account.chainId, + type: "side_vault", + })); +} diff --git a/src/lib/treasury/transactions.ts b/src/lib/treasury/transactions.ts new file mode 100644 index 0000000..2fb46e6 --- /dev/null +++ b/src/lib/treasury/transactions.ts @@ -0,0 +1,566 @@ +import "server-only"; + +import { and, eq, sql } from "drizzle-orm"; +import { formatUnits, getAddress, isAddress, type Address } from "viem"; +import { gnosis } from "viem/chains"; + +import { getDb } from "@/db"; +import { + treasuryTransactionTransfers, + treasuryTransactions, +} from "@/db/schema"; +import { listActiveGnosisSideVaultAccounts } from "@/lib/treasury/accounts"; + +const DEFAULT_SAFE_TRANSACTION_SERVICE_URL = + "https://api.safe.global/tx-service/gnosis/api/v1"; +const DEFAULT_PAGE_LIMIT = 100; +const DEFAULT_MAX_PAGES = 3; + +type TreasuryTransactionSource = { + id: string; + name: string; + address: Address; + chainId: typeof gnosis.id; + source: "main_safe" | "side_vault"; + treasuryAccountId: string | null; +}; + +type SafeTransferTokenInfo = { + decimals?: number | null; + name?: string | null; + symbol?: string | null; +}; + +type SafeTransfer = { + blockNumber?: number | null; + executionDate?: string | null; + from?: string | null; + logIndex?: number | null; + safeTxHash?: string | null; + to?: string | null; + tokenAddress?: string | null; + tokenInfo?: SafeTransferTokenInfo | null; + transactionHash?: string | null; + transferId?: string | null; + type?: string | null; + value?: string | null; +}; + +type SafeTransfersResponse = { + next?: string | null; + results?: SafeTransfer[]; +}; + +type NormalizedTransfer = { + amount: string; + assetName: string; + assetSymbol: string; + blockNumber: number | null; + decimals: number; + direction: "inflow" | "outflow" | "internal"; + executedAt: Date; + fromAddress: Address; + rawAmount: string; + rawMetadata: SafeTransfer; + safeTransactionHash: string | null; + toAddress: Address; + tokenAddress: Address | null; + transferId: string; + transferType: string; + txHash: `0x${string}`; + usdAmount: string | null; + usdPrice: string | null; +}; + +export type TreasuryTransactionAccountSyncResult = { + accountAddress: Address; + accountName: string; + importedTransfers: number; + importedTransactions: number; + scannedTransfers: number; + source: "main_safe" | "side_vault"; +}; + +export type TreasuryTransactionSyncResult = { + accounts: TreasuryTransactionAccountSyncResult[]; + importedTransfers: number; + importedTransactions: number; + scannedTransfers: number; + syncedAt: string; +}; + +function getMainSafeAddress() { + const address = process.env.MAIN_SAFE_ADDRESS; + + if (!address || !isAddress(address, { strict: false })) { + return null; + } + + return getAddress(address); +} + +function getSafeTransactionServiceUrl() { + return ( + process.env.SAFE_TRANSACTION_SERVICE_URL ?? + DEFAULT_SAFE_TRANSACTION_SERVICE_URL + ).replace(/\/$/, ""); +} + +function getPositiveInteger(value: number | undefined, fallback: number) { + if (!value || !Number.isInteger(value) || value <= 0) { + return fallback; + } + + return value; +} + +function normalizeHash(value: string | null | undefined) { + if (!value || !/^0x[a-fA-F0-9]{64}$/.test(value)) { + return null; + } + + return value.toLowerCase() as `0x${string}`; +} + +function normalizeAddress(value: string | null | undefined) { + if (!value || !isAddress(value, { strict: false })) { + return null; + } + + return getAddress(value); +} + +function getDirection({ + accountAddress, + fromAddress, + toAddress, +}: { + accountAddress: Address; + fromAddress: Address; + toAddress: Address; +}): "inflow" | "outflow" | "internal" { + const account = accountAddress.toLowerCase(); + const from = fromAddress.toLowerCase(); + const to = toAddress.toLowerCase(); + + if (from === account && to === account) { + return "internal"; + } + + return to === account ? "inflow" : "outflow"; +} + +function getAssetMetadata(transfer: SafeTransfer) { + if (transfer.type === "ETHER_TRANSFER") { + return { + assetName: "Gnosis xDAI", + assetSymbol: "xDAI", + decimals: 18, + tokenAddress: null, + usdPrice: "1.00000000", + }; + } + + const tokenInfo = transfer.tokenInfo; + const decimals = tokenInfo?.decimals; + const normalizedDecimals = + typeof decimals === "number" && Number.isInteger(decimals) ? decimals : 18; + + return { + assetName: tokenInfo?.name || tokenInfo?.symbol || "Token", + assetSymbol: tokenInfo?.symbol || "TOKEN", + decimals: normalizedDecimals, + tokenAddress: normalizeAddress(transfer.tokenAddress), + usdPrice: + tokenInfo?.symbol === "USDC" || tokenInfo?.symbol === "wxDAI" + ? "1.00000000" + : null, + }; +} + +function getUsdAmount({ + amount, + usdPrice, +}: { + amount: string; + usdPrice: string | null; +}) { + if (!usdPrice) { + return null; + } + + const usdAmount = Number(amount) * Number(usdPrice); + + return Number.isFinite(usdAmount) ? usdAmount.toFixed(2) : null; +} + +function getFallbackTransferId({ + transfer, + txHash, +}: { + transfer: SafeTransfer; + txHash: `0x${string}`; +}) { + return [ + "safe", + txHash, + transfer.type ?? "transfer", + transfer.logIndex ?? "no-log", + transfer.from ?? "unknown-from", + transfer.to ?? "unknown-to", + transfer.value ?? "0", + transfer.tokenAddress ?? "native", + ].join(":"); +} + +function normalizeTransfer({ + account, + transfer, +}: { + account: TreasuryTransactionSource; + transfer: SafeTransfer; +}): NormalizedTransfer | null { + const txHash = normalizeHash(transfer.transactionHash); + const fromAddress = normalizeAddress(transfer.from); + const toAddress = normalizeAddress(transfer.to); + const rawAmount = transfer.value ?? "0"; + const executedAt = transfer.executionDate + ? new Date(transfer.executionDate) + : null; + + if ( + !txHash || + !fromAddress || + !toAddress || + !executedAt || + Number.isNaN(executedAt.getTime()) || + !/^\d+$/.test(rawAmount) + ) { + return null; + } + + const asset = getAssetMetadata(transfer); + const amount = formatUnits(BigInt(rawAmount), asset.decimals); + const usdAmount = getUsdAmount({ amount, usdPrice: asset.usdPrice }); + + return { + amount, + assetName: asset.assetName, + assetSymbol: asset.assetSymbol, + blockNumber: + Number.isInteger(transfer.blockNumber) && transfer.blockNumber + ? transfer.blockNumber + : null, + decimals: asset.decimals, + direction: getDirection({ + accountAddress: account.address, + fromAddress, + toAddress, + }), + executedAt, + fromAddress, + rawAmount, + rawMetadata: transfer, + safeTransactionHash: normalizeHash(transfer.safeTxHash), + toAddress, + tokenAddress: asset.tokenAddress, + transferId: transfer.transferId ?? getFallbackTransferId({ transfer, txHash }), + transferType: transfer.type ?? "TRANSFER", + txHash, + usdAmount, + usdPrice: asset.usdPrice, + }; +} + +function getTransfersUrl({ + account, + limit, + offset, +}: { + account: TreasuryTransactionSource; + limit: number; + offset: number; +}) { + const url = new URL( + `${getSafeTransactionServiceUrl()}/safes/${account.address}/transfers/`, + ); + url.searchParams.set("limit", String(limit)); + url.searchParams.set("offset", String(offset)); + + return url; +} + +async function fetchSafeTransfersPage({ + account, + limit, + offset, +}: { + account: TreasuryTransactionSource; + limit: number; + offset: number; +}) { + const response = await fetch(getTransfersUrl({ account, limit, offset }), { + cache: "no-store", + }); + + if (!response.ok) { + throw new Error( + `Safe Transaction Service failed for ${account.name}: ${response.status}`, + ); + } + + return (await response.json()) as SafeTransfersResponse; +} + +async function listTransactionSources(): Promise { + const mainSafeAddress = getMainSafeAddress(); + const sideVaults = await listActiveGnosisSideVaultAccounts(); + const sources: TreasuryTransactionSource[] = []; + const seen = new Set(); + + if (mainSafeAddress) { + sources.push({ + id: "treasury", + name: "Treasury", + address: mainSafeAddress, + chainId: gnosis.id, + source: "main_safe", + treasuryAccountId: null, + }); + seen.add(`${gnosis.id}:${mainSafeAddress.toLowerCase()}`); + } + + for (const sideVault of sideVaults) { + const key = `${sideVault.chainId}:${sideVault.address.toLowerCase()}`; + + if (seen.has(key)) { + continue; + } + + sources.push({ + id: sideVault.id, + name: sideVault.name, + address: sideVault.address, + chainId: gnosis.id, + source: "side_vault", + treasuryAccountId: sideVault.id, + }); + seen.add(key); + } + + return sources; +} + +async function getOrCreateTransaction({ + account, + transfer, +}: { + account: TreasuryTransactionSource; + transfer: NormalizedTransfer; +}) { + const db = getDb(); + const [existingTransaction] = await db + .select({ id: treasuryTransactions.id }) + .from(treasuryTransactions) + .where( + and( + eq(treasuryTransactions.chainId, account.chainId), + eq(treasuryTransactions.accountAddress, account.address), + eq(treasuryTransactions.txHash, transfer.txHash), + ), + ) + .limit(1); + + if (existingTransaction) { + return { id: existingTransaction.id, inserted: false }; + } + + const [createdTransaction] = await db + .insert(treasuryTransactions) + .values({ + accountAddress: account.address, + blockNumber: transfer.blockNumber, + chainId: account.chainId, + executedAt: transfer.executedAt, + rawMetadata: { + sourceAccountId: account.id, + }, + safeTransactionHash: transfer.safeTransactionHash, + source: account.source, + transactionType: "safe_transfer", + treasuryAccountId: account.treasuryAccountId, + txHash: transfer.txHash, + }) + .onConflictDoNothing() + .returning(); + + if (createdTransaction) { + return { id: createdTransaction.id, inserted: true }; + } + + const [transaction] = await db + .select({ id: treasuryTransactions.id }) + .from(treasuryTransactions) + .where( + and( + eq(treasuryTransactions.chainId, account.chainId), + eq(treasuryTransactions.accountAddress, account.address), + eq(treasuryTransactions.txHash, transfer.txHash), + ), + ) + .limit(1); + + if (!transaction) { + throw new Error("Treasury transaction insert failed"); + } + + return { id: transaction.id, inserted: false }; +} + +async function insertTransfer({ + account, + transfer, + transactionId, +}: { + account: TreasuryTransactionSource; + transfer: NormalizedTransfer; + transactionId: string; +}) { + const [createdTransfer] = await getDb() + .insert(treasuryTransactionTransfers) + .values({ + accountAddress: account.address, + amount: transfer.amount, + assetName: transfer.assetName, + assetSymbol: transfer.assetSymbol, + chainId: account.chainId, + decimals: transfer.decimals, + direction: transfer.direction, + executedAt: transfer.executedAt, + fromAddress: transfer.fromAddress, + rawAmount: transfer.rawAmount, + rawMetadata: transfer.rawMetadata, + tokenAddress: transfer.tokenAddress, + toAddress: transfer.toAddress, + transferId: transfer.transferId, + transferType: transfer.transferType, + treasuryAccountId: account.treasuryAccountId, + treasuryTransactionId: transactionId, + txHash: transfer.txHash, + usdAmount: transfer.usdAmount, + usdPrice: transfer.usdPrice, + }) + .onConflictDoNothing() + .returning(); + + return Boolean(createdTransfer); +} + +async function syncAccountTransfers({ + account, + limit, + maxPages, +}: { + account: TreasuryTransactionSource; + limit: number; + maxPages: number; +}): Promise { + let importedTransfers = 0; + let importedTransactions = 0; + let scannedTransfers = 0; + + for (let page = 0; page < maxPages; page += 1) { + const offset = page * limit; + const body = await fetchSafeTransfersPage({ account, limit, offset }); + const transfers = body.results ?? []; + + if (transfers.length === 0) { + break; + } + + for (const rawTransfer of transfers) { + const transfer = normalizeTransfer({ account, transfer: rawTransfer }); + + if (!transfer) { + continue; + } + + scannedTransfers += 1; + + const transaction = await getOrCreateTransaction({ account, transfer }); + const didInsertTransfer = await insertTransfer({ + account, + transactionId: transaction.id, + transfer, + }); + + if (transaction.inserted) { + importedTransactions += 1; + } + + if (didInsertTransfer) { + importedTransfers += 1; + } + } + + if (!body.next) { + break; + } + } + + return { + accountAddress: account.address, + accountName: account.name, + importedTransfers, + importedTransactions, + scannedTransfers, + source: account.source, + }; +} + +export async function syncTreasuryTransactions({ + limit, + maxPages, +}: { + limit?: number; + maxPages?: number; +} = {}): Promise { + const sources = await listTransactionSources(); + const pageLimit = getPositiveInteger(limit, DEFAULT_PAGE_LIMIT); + const pageCount = getPositiveInteger(maxPages, DEFAULT_MAX_PAGES); + + if (sources.length === 0) { + throw new Error("MAIN_SAFE_ADDRESS is required to sync treasury transactions"); + } + + const accounts = await Promise.all( + sources.map((account) => + syncAccountTransfers({ account, limit: pageLimit, maxPages: pageCount }), + ), + ); + + return { + accounts, + importedTransfers: accounts.reduce( + (total, account) => total + account.importedTransfers, + 0, + ), + importedTransactions: accounts.reduce( + (total, account) => total + account.importedTransactions, + 0, + ), + scannedTransfers: accounts.reduce( + (total, account) => total + account.scannedTransfers, + 0, + ), + syncedAt: new Date().toISOString(), + }; +} + +export async function getRecentTreasuryTransactions(limit = 25) { + return getDb() + .select() + .from(treasuryTransactionTransfers) + .orderBy(sql`${treasuryTransactionTransfers.executedAt} desc`) + .limit(limit); +} From 066e1010e7690b71f14d075d0d96574ef96708b4 Mon Sep 17 00:00:00 2001 From: ECWireless Date: Wed, 3 Jun 2026 16:46:26 -0600 Subject: [PATCH 2/2] fix: address transaction ingestion review --- src/app/api/treasury/transactions/route.ts | 62 +++++++++++++++++++--- src/lib/treasury/transactions.ts | 54 ++++++++++++++++--- 2 files changed, 102 insertions(+), 14 deletions(-) diff --git a/src/app/api/treasury/transactions/route.ts b/src/app/api/treasury/transactions/route.ts index aed0e51..373fa6e 100644 --- a/src/app/api/treasury/transactions/route.ts +++ b/src/app/api/treasury/transactions/route.ts @@ -3,10 +3,12 @@ import { NextResponse } from "next/server"; import { writeAuditEvent } from "@/lib/audit"; import { getAuthSession } from "@/lib/auth/session"; import { - getRecentTreasuryTransactions, + getRecentTreasuryTransactionTransfers, syncTreasuryTransactions, } from "@/lib/treasury/transactions"; +const DEFAULT_RECENT_TRANSFER_LIMIT = 25; + function getPositiveQueryInteger(value: string | null) { if (!value) { return undefined; @@ -35,12 +37,47 @@ export async function GET(request: Request) { } const url = new URL(request.url); - const limit = getPositiveQueryInteger(url.searchParams.get("limit")); - const transfers = await getRecentTreasuryTransactions(limit); + const limit = + getPositiveQueryInteger(url.searchParams.get("limit")) ?? + DEFAULT_RECENT_TRANSFER_LIMIT; + const transfers = await getRecentTreasuryTransactionTransfers(limit); return NextResponse.json({ transfers }); } +function getErrorMetadata(error: unknown) { + if (error instanceof Error) { + return { + errorMessage: error.message, + errorStack: error.stack, + }; + } + + return { errorMessage: "Treasury transaction sync failed" }; +} + +async function writeTransactionSyncAuditEvent({ + actorWalletAddress, + metadata, + summary, +}: { + actorWalletAddress: string; + metadata: Record; + summary: string; +}) { + try { + await writeAuditEvent({ + action: "import", + actorWalletAddress, + metadata, + subjectTable: "treasury_transactions", + summary, + }); + } catch (auditError) { + console.error("Failed to write treasury transaction sync audit event", auditError); + } +} + export async function POST(request: Request) { const session = await requireAdminSession(); @@ -48,6 +85,12 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + const actorWalletAddress = session.address; + + if (!actorWalletAddress) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { const url = new URL(request.url); const result = await syncTreasuryTransactions({ @@ -55,16 +98,15 @@ export async function POST(request: Request) { maxPages: getPositiveQueryInteger(url.searchParams.get("maxPages")), }); - await writeAuditEvent({ - action: "import", - actorWalletAddress: session.address, + await writeTransactionSyncAuditEvent({ + actorWalletAddress, metadata: { accountCount: result.accounts.length, + errorCount: result.errors.length, importedTransactions: result.importedTransactions, importedTransfers: result.importedTransfers, scannedTransfers: result.scannedTransfers, }, - subjectTable: "treasury_transactions", summary: "Synced treasury transactions", }); @@ -72,6 +114,12 @@ export async function POST(request: Request) { } catch (error) { console.error("Treasury transaction sync failed", error); + await writeTransactionSyncAuditEvent({ + actorWalletAddress, + metadata: getErrorMetadata(error), + summary: "Treasury transaction sync failed", + }); + return NextResponse.json( { error: "Treasury transaction sync failed" }, { status: 502 }, diff --git a/src/lib/treasury/transactions.ts b/src/lib/treasury/transactions.ts index 2fb46e6..8038013 100644 --- a/src/lib/treasury/transactions.ts +++ b/src/lib/treasury/transactions.ts @@ -81,8 +81,16 @@ export type TreasuryTransactionAccountSyncResult = { source: "main_safe" | "side_vault"; }; +export type TreasuryTransactionAccountSyncError = { + accountAddress: Address; + accountName: string; + error: string; + source: "main_safe" | "side_vault"; +}; + export type TreasuryTransactionSyncResult = { accounts: TreasuryTransactionAccountSyncResult[]; + errors: TreasuryTransactionAccountSyncError[]; importedTransfers: number; importedTransactions: number; scannedTransfers: number; @@ -122,6 +130,10 @@ function normalizeHash(value: string | null | undefined) { return value.toLowerCase() as `0x${string}`; } +function lower(value: string) { + return value.toLowerCase(); +} + function normalizeAddress(value: string | null | undefined) { if (!value || !isAddress(value, { strict: false })) { return null; @@ -365,8 +377,10 @@ async function getOrCreateTransaction({ .where( and( eq(treasuryTransactions.chainId, account.chainId), - eq(treasuryTransactions.accountAddress, account.address), - eq(treasuryTransactions.txHash, transfer.txHash), + sql`lower(${treasuryTransactions.accountAddress}) = ${lower( + account.address, + )}`, + sql`lower(${treasuryTransactions.txHash}) = ${lower(transfer.txHash)}`, ), ) .limit(1); @@ -404,8 +418,10 @@ async function getOrCreateTransaction({ .where( and( eq(treasuryTransactions.chainId, account.chainId), - eq(treasuryTransactions.accountAddress, account.address), - eq(treasuryTransactions.txHash, transfer.txHash), + sql`lower(${treasuryTransactions.accountAddress}) = ${lower( + account.address, + )}`, + sql`lower(${treasuryTransactions.txHash}) = ${lower(transfer.txHash)}`, ), ) .limit(1); @@ -530,17 +546,41 @@ export async function syncTreasuryTransactions({ const pageCount = getPositiveInteger(maxPages, DEFAULT_MAX_PAGES); if (sources.length === 0) { - throw new Error("MAIN_SAFE_ADDRESS is required to sync treasury transactions"); + throw new Error( + "At least one treasury or active Gnosis side-vault account is required to sync treasury transactions", + ); } - const accounts = await Promise.all( + const results = await Promise.allSettled( sources.map((account) => syncAccountTransfers({ account, limit: pageLimit, maxPages: pageCount }), ), ); + const accounts: TreasuryTransactionAccountSyncResult[] = []; + const errors: TreasuryTransactionAccountSyncError[] = []; + + results.forEach((result, index) => { + const source = sources[index]; + + if (result.status === "fulfilled") { + accounts.push(result.value); + return; + } + + errors.push({ + accountAddress: source.address, + accountName: source.name, + error: + result.reason instanceof Error + ? result.reason.message + : "Treasury account sync failed", + source: source.source, + }); + }); return { accounts, + errors, importedTransfers: accounts.reduce( (total, account) => total + account.importedTransfers, 0, @@ -557,7 +597,7 @@ export async function syncTreasuryTransactions({ }; } -export async function getRecentTreasuryTransactions(limit = 25) { +export async function getRecentTreasuryTransactionTransfers(limit = 25) { return getDb() .select() .from(treasuryTransactionTransfers)