Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/real-time-verifications.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Real-time verifications

This document explains how Send computes and exposes a profile’s verification state in real time.

## Summary
- Verified status requires all three conditions simultaneously (for the active distribution):
1) tag_registration verification present with weight > 0
2) send_token_hodler verification weight >= distributions.hodler_min_balance
3) Earn balance condition: any address owned by the user has send_earn balance >= distributions.earn_min_balance
- We expose two computed values:
- verified_at(profiles) -> timestamptz: the timestamp when the user became currently verified. NULL if not verified now.
- is_verified(profiles) -> boolean: derived as (verified_at IS NOT NULL)

## Active distribution
Both functions first locate the active distribution using the current UTC time:
- qualification_start <= now() <= qualification_end (UTC)
- If multiple windows match, we choose the latest by qualification_start.

## verified_at semantics
- If the user is currently verified, verified_at returns the latest time at which all required conditions were satisfied:
- tag_at: earliest tag_registration DV for the active distribution
- hodler_at: earliest send_token_hodler DV meeting the min balance for the active distribution
- earn_at:
- If earn_min_balance = 0, no earn requirement is enforced (earn_at = qualification_start of the active distribution)
- Otherwise, the earliest send_earn_balances_timeline row where assets >= earn_min_balance for any of the user’s addresses
- verified_at = GREATEST(tag_at, hodler_at, earn_at)
- If any required timestamp is NULL, verified_at returns NULL and the user is not currently verified.

## is_verified
is_verified(profiles) simply returns (verified_at(profiles) IS NOT NULL). This avoids duplicated logic and guarantees consistency.

## Query points and usage
- profile_lookup returns is_verified (and verified_at is available separately via public.verified_at(p)).
- UI can:
- Show a badge when is_verified = true
- Show the time since verification began using verified_at

## Edge cases
- Missing any one of the conditions -> verified_at = NULL, is_verified = false
- Earn threshold set to 0 -> earn condition is bypassed, so tag + hodler alone determine verification
- When a condition is later lost (e.g., token holdings drop below hodler_min_balance), verified_at returns NULL again, and is_verified becomes false

## Performance notes
- The functions operate within a single SELECT using a single active distribution row and narrowly scoped subqueries.
- The logic avoids repeated full scans by joining against specific user- and distribution-scoped data.

## Testing
- See supabase/tests/verification_status_test.sql for coverage of the happy path and key edge cases (no DVs, single DV, both DVs, and losing a condition).
13 changes: 4 additions & 9 deletions packages/app/features/account/components/AccountHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const ShareProfileDialog = lazy(() =>
)

export const AccountHeader = memo<YStackProps>(function AccountHeader(props) {
const { profile, distributionShares } = useUser()
const { profile } = useUser()
const [shareDialogOpen, setShareDialogOpen] = useState(false)
const hoverStyles = useHoverStyles()

Expand All @@ -54,11 +54,6 @@ export const AccountHeader = memo<YStackProps>(function AccountHeader(props) {
[]
)

const isVerified = useMemo(
() => Boolean(distributionShares[0] && distributionShares[0].amount > 0n),
[distributionShares]
)

const handleSharePress = useCallback(async () => {
if (!referralHref) return

Expand All @@ -77,7 +72,7 @@ export const AccountHeader = memo<YStackProps>(function AccountHeader(props) {

// Verification status icon component
const VerificationIcon = () => {
if (isVerified) {
if (profile?.is_verified) {
return (
<IconBadgeCheckSolid
size={'$1.5'}
Expand Down Expand Up @@ -135,7 +130,7 @@ export const AccountHeader = memo<YStackProps>(function AccountHeader(props) {
<Paragraph size={'$8'} fontWeight={600} numberOfLines={1}>
{name || '---'}
</Paragraph>
{!isVerified ? (
{!profile?.is_verified ? (
<Tooltip placement={'bottom'} delay={0}>
<Tooltip.Content
enterStyle={{ x: 0, y: -5, opacity: 0, scale: 0.9 }}
Expand Down Expand Up @@ -168,7 +163,7 @@ export const AccountHeader = memo<YStackProps>(function AccountHeader(props) {
</Tooltip.Content>
<Tooltip.Trigger
onPress={(e) => {
if (isVerified) {
if (profile?.is_verified) {
return
}
e.preventDefault()
Expand Down
2 changes: 1 addition & 1 deletion packages/app/features/deposit/DepositCoinbase/screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export function DepositCoinbaseScreen({ defaultPaymentMethod }: DepositCoinbaseS
color={'$lightGrayTextField'}
$theme-light={{ color: '$darkGrayTextField' }}
>
Turn off "Block Popups" in iOS Safari Settings then try again.
Turn off &quot;Block Popups&quot; in iOS Safari Settings then try again.
</Paragraph>
)}
</>
Expand Down
2 changes: 1 addition & 1 deletion packages/app/features/home/InvestmentsBalanceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
View,
} from '@my/ui'
import formatAmount, { localizeAmount } from 'app/utils/formatAmount'
import { ChevronLeft, ChevronRight } from '@tamagui/lucide-icons'
import { ChevronRight } from '@tamagui/lucide-icons'
import { useIsPriceHidden } from './utils/useIsPriceHidden'
import { useSendAccountBalances } from 'app/utils/useSendAccountBalances'
import {
Expand Down
2 changes: 1 addition & 1 deletion packages/app/features/home/StablesBalanceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
} from '@my/ui'
import formatAmount from 'app/utils/formatAmount'

import { ChevronLeft, ChevronRight } from '@tamagui/lucide-icons'
import { ChevronRight } from '@tamagui/lucide-icons'
import { useIsPriceHidden } from 'app/features/home/utils/useIsPriceHidden'
import { useSendAccountBalances } from 'app/utils/useSendAccountBalances'
import { stableCoins, usdcCoin } from 'app/data/coins'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ export function ChartLineSection({
const native = useScrollNativeGesture()

const gesturePad = 24
const { onScrub, ...restPathProps } = (pathProps ?? {}) as {
onScrub?: (payload: { active: boolean; ox?: number; oy?: number }) => void
} & Record<string, unknown>
const restPathProps = Object.fromEntries(
Object.entries((pathProps ?? {}) as Record<string, unknown>).filter(([k]) => k !== 'onScrub')
) as Record<string, unknown>

const mergedPanProps = {
shouldCancelWhenOutside: false,
Expand Down
7 changes: 1 addition & 6 deletions packages/app/features/profile/screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,7 @@ import {
// Internal
import { useProfileLookup } from 'app/utils/useProfileLookup'
import { useProfileScreenParams } from 'app/routers/params'
import {
IconAccount,
IconLinkInBio,
IconCheckCircle,
IconBadgeCheckSolid,
} from 'app/components/icons'
import { IconAccount, IconLinkInBio, IconBadgeCheckSolid } from 'app/components/icons'
import { ShareOtherProfileDialog } from './components/ShareOtherProfileDialog'
import type { Functions } from '@my/supabase/database.types'
import { useTokenPrices } from 'app/utils/useTokenPrices'
Expand Down
2 changes: 1 addition & 1 deletion packages/app/utils/useUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const useUser = () => {
const { data, error } = await supabase
.from('profiles')
.select(
'*, tags(*), main_tag(*), links_in_bio(*), distribution_shares(*), canton_party_verifications(*)'
'*, is_verified, tags(*), main_tag(*), links_in_bio(*), distribution_shares(*), canton_party_verifications(*)'
)
.eq('id', user?.id ?? '')
.single()
Expand Down
Loading
Loading