diff --git a/migrations/1_start.sh b/migrations/1_start.sh index f36ccd8..e3b4d0a 100644 --- a/migrations/1_start.sh +++ b/migrations/1_start.sh @@ -28,7 +28,6 @@ heroku pg:psql --app "$APP_NAME" DATABASE_URL < 0); + END IF; +END +\$\$; + +-- 3) Backfill nonce from legacy nonces table if present +DO +\$\$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'nonces' + ) THEN + UPDATE vaults v + SET nonce = n.nonce + FROM nonces n + WHERE v.vault = n.vault; + END IF; +END +\$\$; +-- 4) Drop legacy index and table if they exist (cleanup) +DROP INDEX IF EXISTS unique_lower_vault_nonces; +DROP TABLE IF EXISTS nonces; +EOF + +echo "Merge complete: vaults.nonce added, controllers non-empty enforced, nonces backfilled, legacy table dropped." + + diff --git a/scripts/setup-db.js b/scripts/setup-db.js index 5063cf5..bc65b8a 100755 --- a/scripts/setup-db.js +++ b/scripts/setup-db.js @@ -50,9 +50,8 @@ ${chalk.yellow('Tables Created:')} - bundles : Stores bundle data with IPFS CIDs - cids : Tracks submitted CIDs - balances : Manages vault token balances - - nonces : Tracks vault nonces - proposers : Records block proposers - - vaults : Maps vault IDs to controllers and rules + - vaults : Maps vault IDs to controllers and rules; stores nonce ${chalk.red('Warning:')} Using --drop-existing will DELETE ALL EXISTING DATA! `) diff --git a/scripts/setup-test-db.js b/scripts/setup-test-db.js index 0db94cb..fbdb770 100755 --- a/scripts/setup-test-db.js +++ b/scripts/setup-test-db.js @@ -51,9 +51,8 @@ ${chalk.yellow('Tables Created:')} - bundles : Stores bundle data with IPFS CIDs - cids : Tracks submitted CIDs - balances : Manages vault token balances - - nonces : Tracks vault nonces - proposers : Records block proposers - - vaults : Maps vault IDs to controllers and rules + - vaults : Maps vault IDs to controllers and rules; stores nonce ${chalk.red('Warning:')} Using --drop-existing will DELETE ALL EXISTING DATA! `) diff --git a/scripts/shared/db-setup.js b/scripts/shared/db-setup.js index b48c548..ecd1854 100644 --- a/scripts/shared/db-setup.js +++ b/scripts/shared/db-setup.js @@ -51,15 +51,6 @@ CREATE TABLE IF NOT EXISTS balances ( UNIQUE (vault, token) ); --- Create the nonces table (tracks vault nonces) -CREATE TABLE IF NOT EXISTS nonces ( - id SERIAL PRIMARY KEY, - vault TEXT NOT NULL, - nonce INTEGER NOT NULL, - timestamp TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - UNIQUE (vault) -); - -- Create the proposers table (records block proposers) CREATE TABLE IF NOT EXISTS proposers ( id SERIAL PRIMARY KEY, @@ -71,6 +62,7 @@ CREATE TABLE IF NOT EXISTS proposers ( CREATE TABLE IF NOT EXISTS vaults ( vault TEXT PRIMARY KEY, controllers TEXT[] NOT NULL, + nonce INTEGER NOT NULL DEFAULT 0, rules TEXT, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); @@ -102,7 +94,6 @@ CREATE TABLE IF NOT EXISTS deposit_assignment_events ( */ export const createIndexesSql = ` -- Create case-insensitive unique indexes for vault/token lookups -CREATE UNIQUE INDEX IF NOT EXISTS unique_lower_vault_nonces ON nonces (LOWER(vault)); CREATE UNIQUE INDEX IF NOT EXISTS unique_lower_vault_token_balances ON balances (LOWER(vault), LOWER(token)); -- Create indexes for Filecoin tracking @@ -131,7 +122,6 @@ DROP TABLE IF EXISTS deposits CASCADE; DROP TABLE IF EXISTS bundles CASCADE; DROP TABLE IF EXISTS cids CASCADE; DROP TABLE IF EXISTS balances CASCADE; -DROP TABLE IF EXISTS nonces CASCADE; DROP TABLE IF EXISTS proposers CASCADE; DROP TABLE IF EXISTS vaults CASCADE; ` @@ -199,11 +189,11 @@ export async function setupDatabase(options) { // Verify tables were created console.log(chalk.yellow('\nVerifying database schema...')) - const result = await pool.query(` + const result = await pool.query(` SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' - AND table_name IN ('bundles', 'cids', 'balances', 'nonces', 'proposers', 'vaults', 'deposits', 'deposit_assignment_events') + AND table_name IN ('bundles', 'cids', 'balances', 'proposers', 'vaults', 'deposits', 'deposit_assignment_events') ORDER BY table_name `) @@ -214,15 +204,12 @@ export async function setupDatabase(options) { }) // Check if we need to update existing data to lowercase - const balancesCount = await pool.query('SELECT COUNT(*) FROM balances') - const noncesCount = await pool.query('SELECT COUNT(*) FROM nonces') - - if (parseInt(balancesCount.rows[0].count) > 0 || parseInt(noncesCount.rows[0].count) > 0) { - console.log(chalk.yellow('\nUpdating existing data to lowercase...')) - await pool.query('UPDATE nonces SET vault = LOWER(vault)') - await pool.query('UPDATE balances SET vault = LOWER(vault), token = LOWER(token)') - console.log(chalk.green('āœ“ Existing data updated')) - } + const balancesCount = await pool.query('SELECT COUNT(*) FROM balances') + if (parseInt(balancesCount.rows[0].count) > 0) { + console.log(chalk.yellow('\nUpdating existing data to lowercase...')) + await pool.query('UPDATE balances SET vault = LOWER(vault), token = LOWER(token)') + console.log(chalk.green('āœ“ Existing data updated')) + } console.log(chalk.green(`\nāœ… ${environment} database is ready for use!\n`)) diff --git a/src/config/dbSettings.ts b/src/config/dbSettings.ts index a8b7826..bc336b4 100644 --- a/src/config/dbSettings.ts +++ b/src/config/dbSettings.ts @@ -36,7 +36,6 @@ export const REQUIRED_TABLES = [ 'bundles', 'cids', 'balances', - 'nonces', 'proposers', 'vaults', 'deposits', diff --git a/src/controllers.ts b/src/controllers.ts index 9ffad8a..1544112 100644 --- a/src/controllers.ts +++ b/src/controllers.ts @@ -295,7 +295,7 @@ export const getVaultNonce = async (req: Request, res: Response) => { const { vault } = req.params try { const result = await pool.query( - 'SELECT nonce FROM nonces WHERE LOWER(vault) = LOWER($1)', + 'SELECT nonce FROM vaults WHERE LOWER(vault) = LOWER($1)', [vault] ) logger.info('Getting vault nonce:', result.rows) @@ -320,14 +320,13 @@ export const setVaultNonce = async (req: Request, res: Response) => { try { const result = await pool.query( - `INSERT INTO nonces (vault, nonce) - VALUES (LOWER($1), $2) - ON CONFLICT (LOWER(vault)) - DO UPDATE SET nonce = EXCLUDED.nonce - RETURNING *`, + `UPDATE vaults SET nonce = $2 WHERE LOWER(vault) = LOWER($1) RETURNING vault, nonce`, [vault, nonce] ) - res.status(201).json(result.rows[0]) + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Vault not found' }) + } + res.status(200).json(result.rows[0]) } catch (err) { logger.error(err) res.status(500).json({ error: 'Internal Server Error' }) @@ -397,10 +396,8 @@ export const getMetrics = async (req: Request, res: Response) => { const latestCIDNonce = latestCIDResult.rows.length > 0 ? latestCIDResult.rows[0].nonce : null - // Get unique vault count - const vaultCountResult = await pool.query( - 'SELECT COUNT(DISTINCT vault) FROM nonces' - ) + // Get vault count + const vaultCountResult = await pool.query('SELECT COUNT(*) FROM vaults') const totalVaults = parseInt(vaultCountResult.rows[0].count) // Get latest bundle nonce @@ -756,6 +753,14 @@ export const removeControllerFromVault = async ( if (inner instanceof Error && inner.message === 'Vault not found') { return res.status(404).json({ error: 'Vault not found' }) } + if ( + inner instanceof Error && + inner.message === 'Controllers cannot be empty' + ) { + return res + .status(400) + .json({ error: 'Cannot remove the last controller from a vault' }) + } throw inner } } catch (error) { diff --git a/src/proposer.ts b/src/proposer.ts index 1b7c259..11e0a6c 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -386,7 +386,7 @@ async function getLatestNonce(): Promise { * Returns 0 if no nonce is found for the vault. */ async function getVaultNonce(vaultId: number | string): Promise { - const result = await pool.query('SELECT nonce FROM nonces WHERE vault = $1', [ + const result = await pool.query('SELECT nonce FROM vaults WHERE vault = $1', [ String(vaultId), ]) if (result.rows.length === 0) { @@ -605,13 +605,15 @@ async function saveBundleData( for (const execution of bundleData.bundle) { const vaultNonce = execution.intention.nonce const vault = execution.from - await pool.query( - `INSERT INTO nonces (vault, nonce) - VALUES ($1, $2) - ON CONFLICT (vault) - DO UPDATE SET nonce = EXCLUDED.nonce`, + const updateResult = await pool.query( + `UPDATE vaults SET nonce = $2 WHERE vault = $1`, [String(vault), vaultNonce] ) + if (updateResult.rowCount === 0) { + logger.warn( + `Nonce update skipped: vault ${String(vault)} does not exist` + ) + } } } } diff --git a/src/utils/vaults.ts b/src/utils/vaults.ts index f10441e..fe79e01 100644 --- a/src/utils/vaults.ts +++ b/src/utils/vaults.ts @@ -25,6 +25,9 @@ export async function updateVaultControllers( vaultId: number, controllers: string[] ): Promise { + if (!Array.isArray(controllers) || controllers.length === 0) { + throw new Error('Controllers cannot be empty') + } const lowercasedControllers = controllers.map((c) => c.toLowerCase()) try { const result = await pool.query( @@ -184,7 +187,20 @@ export async function removeControllerFromVault( if (result.rows.length === 0) { throw new Error('Vault not found') } - return (result.rows[0].controllers as string[]).map((c) => c.toLowerCase()) + const updated = (result.rows[0].controllers as string[]).map((c) => + c.toLowerCase() + ) + if (updated.length === 0) { + // Revert removal to satisfy invariant and report an error + await pool.query( + `UPDATE vaults + SET controllers = ARRAY[LOWER($2)] || controllers + WHERE vault = $1`, + [String(vaultId), lower] + ) + throw new Error('Controllers cannot be empty') + } + return updated } catch (error) { logger.error( `Failed to remove controller ${controller} from vault ${vaultId}:`, diff --git a/test/helpers/testServer.ts b/test/helpers/testServer.ts index 71d7391..3172f79 100644 --- a/test/helpers/testServer.ts +++ b/test/helpers/testServer.ts @@ -73,7 +73,7 @@ export async function clearTestDatabase(pool: Pool): Promise { try { // Clear application tables await client.query( - 'TRUNCATE TABLE bundles, cids, balances, nonces, proposers, vaults CASCADE' + 'TRUNCATE TABLE bundles, cids, balances, proposers, vaults CASCADE' ) } finally { client.release() diff --git a/test/integration/vaults.db.test.ts b/test/integration/vaults.db.test.ts index fa2a836..c71e8eb 100644 --- a/test/integration/vaults.db.test.ts +++ b/test/integration/vaults.db.test.ts @@ -73,10 +73,35 @@ describe('Vault utils (DB)', () => { ) }) - test('removeControllerFromVault: update-only', async () => { + test('removeControllerFromVault: cannot remove last controller', async () => { await createVaultRow(TEST_VAULT_1, CTRL_1, null) - const removed = await removeControllerFromVault(TEST_VAULT_1, CTRL_1) - expect(removed).toEqual([]) + let threw = false + try { + await removeControllerFromVault(TEST_VAULT_1, CTRL_1) + } catch (e) { + threw = e instanceof Error && e.message === 'Controllers cannot be empty' + } + expect(threw).toBe(true) + const controllers = await getControllersForVault(TEST_VAULT_1) + expect(controllers).toEqual([CTRL_1.toLowerCase()]) + }) + + test('vault nonce defaults to 0 and can be updated', async () => { + await createVaultRow(TEST_VAULT_1, CTRL_1, null) + const initial = await pool.query( + 'SELECT nonce FROM vaults WHERE vault = $1', + [String(TEST_VAULT_1)] + ) + expect(initial.rows[0].nonce).toBe(0) + await pool.query('UPDATE vaults SET nonce = $2 WHERE vault = $1', [ + String(TEST_VAULT_1), + 7, + ]) + const updated = await pool.query( + 'SELECT nonce FROM vaults WHERE vault = $1', + [String(TEST_VAULT_1)] + ) + expect(updated.rows[0].nonce).toBe(7) }) test('setRulesForVault and getRulesForVault: update-only', async () => {