mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-10-20 18:56:16 +00:00
The migrations did not describe this database, and could not be made to.
Three findings, in the order they surfaced:
* 25 functions declared across six migrations — all recorded as applied — did
not exist. Their tables did. Two were reached by the app, so merchant tier
upgrades and post-trade reputation updates had been quietly dead. This only
came to light because a user hit "Could not find the function
public.upsert_user_profile(...)" while toggling a notification setting.
* admin_roles has three conflicting definitions across the set and production
matches none of them. 001 says (id, user_id, role, granted_by, granted_at),
COMBINED says (user_id, role, created_at), production has
(id, user_id, role, permissions, created_at, updated_at).
* Applied to an empty database, five migrations fail. The legacy 0NN filenames
sort before the 14-digit timestamps they depend on — "013" < "20241117054600"
— so 013 runs before the migration creating the table it alters. The set
could never have been replayed from scratch.
So it could not be tested, could not rebuild the database, and did not match what
was running. Widening or patching it would have been dressing up a history that
was already fiction.
The baseline is a pg_dump of the live public schema, which matches production by
construction. Privileges are included deliberately: the REVOKEs on
lock_escrow_internal, release_escrow_internal, refund_escrow_internal and
request_withdraw are the 20260725030000 hardening, and dropping them would hand
fund movement back to anon.
Verified step by step against production before committing:
- applies to an empty database cleanly, after three real obstacles were fixed
(extensions live in the `extensions` schema, supabase_admin membership,
platform-level ALTER DEFAULT PRIVILEGES that cannot apply outside Supabase)
- produces 83 tables / 39 functions / 222 indexes / 360 policies — identical
counts to production
- recorded as applied in supabase_migrations without touching the 37 existing
rows, then dry-run confirmed "up to date — no pending migrations", so the
next deploy will not try to replay it over live tables
- drift check now reports "every declared function is present"; the known-gaps
list drops from 22 entries to zero
The old files move to migrations/archive/ rather than being deleted — they are
the only record of why parts of this look the way they do. Their README says
plainly not to run them, and why.
CI now applies migrations to an empty Postgres on every PR. That check was
impossible while the old set was the starting point; it is the thing that stops
this class of drift from being discovered by a user again.
212 lines
6.7 KiB
PL/PgSQL
212 lines
6.7 KiB
PL/PgSQL
-- =====================================================
|
|
-- Migration 017: Withdraw Completion Function
|
|
-- Properly handles successful withdrawal balance updates
|
|
-- =====================================================
|
|
|
|
-- Function to complete a withdrawal (called after blockchain TX success)
|
|
CREATE OR REPLACE FUNCTION complete_withdraw(
|
|
p_user_id UUID,
|
|
p_token TEXT,
|
|
p_amount DECIMAL(20, 12),
|
|
p_tx_hash TEXT,
|
|
p_request_id UUID
|
|
) RETURNS JSON AS $$
|
|
DECLARE
|
|
v_balance RECORD;
|
|
v_locked_before DECIMAL(20, 12);
|
|
BEGIN
|
|
-- Get current balance
|
|
SELECT * INTO v_balance
|
|
FROM user_internal_balances
|
|
WHERE user_id = p_user_id AND token = p_token
|
|
FOR UPDATE;
|
|
|
|
IF v_balance IS NULL THEN
|
|
RETURN json_build_object('success', false, 'error', 'Balance record not found');
|
|
END IF;
|
|
|
|
IF v_balance.locked_balance < p_amount THEN
|
|
RETURN json_build_object(
|
|
'success', false,
|
|
'error', 'Insufficient locked balance for withdrawal completion'
|
|
);
|
|
END IF;
|
|
|
|
v_locked_before := v_balance.locked_balance;
|
|
|
|
-- Deduct from locked balance (already deducted from available when request was made)
|
|
UPDATE user_internal_balances
|
|
SET
|
|
locked_balance = locked_balance - p_amount,
|
|
total_withdrawn = total_withdrawn + p_amount,
|
|
last_withdraw_at = NOW(),
|
|
updated_at = NOW()
|
|
WHERE user_id = p_user_id AND token = p_token;
|
|
|
|
-- Log the transaction
|
|
INSERT INTO p2p_balance_transactions (
|
|
user_id, token, transaction_type, amount,
|
|
balance_before, balance_after, reference_type, reference_id,
|
|
description
|
|
) VALUES (
|
|
p_user_id, p_token, 'withdraw', -p_amount,
|
|
v_locked_before, v_locked_before - p_amount, 'withdraw_request', p_request_id,
|
|
'Withdrawal completed. TX: ' || p_tx_hash
|
|
);
|
|
|
|
RETURN json_build_object(
|
|
'success', true,
|
|
'withdrawn_amount', p_amount,
|
|
'remaining_locked', v_locked_before - p_amount,
|
|
'tx_hash', p_tx_hash
|
|
);
|
|
END;
|
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
|
|
-- Grant execute to service role only (called from Edge Function)
|
|
REVOKE EXECUTE ON FUNCTION complete_withdraw FROM PUBLIC;
|
|
REVOKE EXECUTE ON FUNCTION complete_withdraw FROM authenticated;
|
|
REVOKE EXECUTE ON FUNCTION complete_withdraw FROM anon;
|
|
|
|
-- Create index for faster withdrawal request lookups
|
|
CREATE INDEX IF NOT EXISTS idx_withdraw_requests_pending
|
|
ON p2p_deposit_withdraw_requests(user_id, status)
|
|
WHERE request_type = 'withdraw' AND status = 'pending';
|
|
|
|
-- Add fee tracking columns if not exists
|
|
ALTER TABLE p2p_deposit_withdraw_requests
|
|
ADD COLUMN IF NOT EXISTS fee_amount DECIMAL(20, 12) DEFAULT 0;
|
|
|
|
ALTER TABLE p2p_deposit_withdraw_requests
|
|
ADD COLUMN IF NOT EXISTS net_amount DECIMAL(20, 12);
|
|
|
|
-- Trigger to calculate net amount
|
|
CREATE OR REPLACE FUNCTION calculate_withdraw_net_amount()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
IF NEW.request_type = 'withdraw' THEN
|
|
-- Default fees: HEZ = 0.1, PEZ = 1
|
|
IF NEW.fee_amount IS NULL OR NEW.fee_amount = 0 THEN
|
|
NEW.fee_amount := CASE NEW.token
|
|
WHEN 'HEZ' THEN 0.1
|
|
WHEN 'PEZ' THEN 1
|
|
ELSE 0
|
|
END;
|
|
END IF;
|
|
NEW.net_amount := NEW.amount - NEW.fee_amount;
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
DROP TRIGGER IF EXISTS trigger_calculate_withdraw_net ON p2p_deposit_withdraw_requests;
|
|
CREATE TRIGGER trigger_calculate_withdraw_net
|
|
BEFORE INSERT ON p2p_deposit_withdraw_requests
|
|
FOR EACH ROW EXECUTE FUNCTION calculate_withdraw_net_amount();
|
|
|
|
-- Add daily/monthly withdrawal limits table
|
|
CREATE TABLE IF NOT EXISTS p2p_withdrawal_limits (
|
|
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
daily_withdrawn DECIMAL(20, 12) DEFAULT 0,
|
|
monthly_withdrawn DECIMAL(20, 12) DEFAULT 0,
|
|
daily_limit DECIMAL(20, 12) DEFAULT 1000, -- Default 1000 HEZ/day
|
|
monthly_limit DECIMAL(20, 12) DEFAULT 10000, -- Default 10000 HEZ/month
|
|
last_daily_reset TIMESTAMPTZ DEFAULT NOW(),
|
|
last_monthly_reset TIMESTAMPTZ DEFAULT NOW(),
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
-- Function to check withdrawal limits
|
|
CREATE OR REPLACE FUNCTION check_withdrawal_limit(
|
|
p_user_id UUID,
|
|
p_amount DECIMAL(20, 12)
|
|
) RETURNS JSON AS $$
|
|
DECLARE
|
|
v_limits RECORD;
|
|
v_daily_remaining DECIMAL;
|
|
v_monthly_remaining DECIMAL;
|
|
BEGIN
|
|
-- Get or create limits record
|
|
INSERT INTO p2p_withdrawal_limits (user_id)
|
|
VALUES (p_user_id)
|
|
ON CONFLICT (user_id) DO NOTHING;
|
|
|
|
SELECT * INTO v_limits
|
|
FROM p2p_withdrawal_limits
|
|
WHERE user_id = p_user_id
|
|
FOR UPDATE;
|
|
|
|
-- Reset daily counter if needed
|
|
IF v_limits.last_daily_reset < CURRENT_DATE THEN
|
|
UPDATE p2p_withdrawal_limits
|
|
SET daily_withdrawn = 0, last_daily_reset = NOW()
|
|
WHERE user_id = p_user_id;
|
|
v_limits.daily_withdrawn := 0;
|
|
END IF;
|
|
|
|
-- Reset monthly counter if needed
|
|
IF v_limits.last_monthly_reset < DATE_TRUNC('month', CURRENT_DATE) THEN
|
|
UPDATE p2p_withdrawal_limits
|
|
SET monthly_withdrawn = 0, last_monthly_reset = NOW()
|
|
WHERE user_id = p_user_id;
|
|
v_limits.monthly_withdrawn := 0;
|
|
END IF;
|
|
|
|
v_daily_remaining := v_limits.daily_limit - v_limits.daily_withdrawn;
|
|
v_monthly_remaining := v_limits.monthly_limit - v_limits.monthly_withdrawn;
|
|
|
|
IF p_amount > v_daily_remaining THEN
|
|
RETURN json_build_object(
|
|
'allowed', false,
|
|
'error', format('Daily limit exceeded. Remaining: %s', v_daily_remaining),
|
|
'daily_remaining', v_daily_remaining,
|
|
'monthly_remaining', v_monthly_remaining
|
|
);
|
|
END IF;
|
|
|
|
IF p_amount > v_monthly_remaining THEN
|
|
RETURN json_build_object(
|
|
'allowed', false,
|
|
'error', format('Monthly limit exceeded. Remaining: %s', v_monthly_remaining),
|
|
'daily_remaining', v_daily_remaining,
|
|
'monthly_remaining', v_monthly_remaining
|
|
);
|
|
END IF;
|
|
|
|
RETURN json_build_object(
|
|
'allowed', true,
|
|
'daily_remaining', v_daily_remaining,
|
|
'monthly_remaining', v_monthly_remaining
|
|
);
|
|
END;
|
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
|
|
-- Function to record withdrawal in limits
|
|
CREATE OR REPLACE FUNCTION record_withdrawal_limit(
|
|
p_user_id UUID,
|
|
p_amount DECIMAL(20, 12)
|
|
) RETURNS void AS $$
|
|
BEGIN
|
|
UPDATE p2p_withdrawal_limits
|
|
SET
|
|
daily_withdrawn = daily_withdrawn + p_amount,
|
|
monthly_withdrawn = monthly_withdrawn + p_amount,
|
|
updated_at = NOW()
|
|
WHERE user_id = p_user_id;
|
|
END;
|
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
|
|
GRANT EXECUTE ON FUNCTION check_withdrawal_limit TO authenticated;
|
|
GRANT EXECUTE ON FUNCTION record_withdrawal_limit TO authenticated;
|
|
|
|
-- RLS for withdrawal limits
|
|
ALTER TABLE p2p_withdrawal_limits ENABLE ROW LEVEL SECURITY;
|
|
|
|
CREATE POLICY "Users can view own withdrawal limits"
|
|
ON p2p_withdrawal_limits FOR SELECT
|
|
USING (user_id = auth.uid());
|
|
|
|
COMMENT ON FUNCTION complete_withdraw IS 'Completes a withdrawal after blockchain TX success. Service role only.';
|
|
COMMENT ON FUNCTION check_withdrawal_limit IS 'Check if user can withdraw the specified amount based on daily/monthly limits.';
|