Skip to content

Commit 204e672

Browse files
profile_lookup_result: re-add is_verified and wire through views
Why: profile_result requires is_verified. Add it back to the composite type and include it in profile_lookup and the referrer view. is_verified is computed from verified_at so we avoid redundant logic. Test plan: - Reset DB & generate types; confirm CompositeTypes.profile_lookup_result has is_verified and verified_at - Query: select is_verified, verified_at from profile_lookup('sendid','<id>');
1 parent e18cb1f commit 204e672

21 files changed

+2051
-901
lines changed

docs/real-time-verifications.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Real-time verifications
2+
3+
This document explains how Send computes and exposes a profile’s verification state in real time.
4+
5+
## Summary
6+
- Verified status requires all three conditions simultaneously (for the active distribution):
7+
1) tag_registration verification present with weight > 0
8+
2) send_token_hodler verification weight >= distributions.hodler_min_balance
9+
3) Earn balance condition: any address owned by the user has send_earn balance >= distributions.earn_min_balance
10+
- We expose two computed values:
11+
- verified_at(profiles) -> timestamptz: the timestamp when the user became currently verified. NULL if not verified now.
12+
- is_verified(profiles) -> boolean: derived as (verified_at IS NOT NULL)
13+
14+
## Active distribution
15+
Both functions first locate the active distribution using the current UTC time:
16+
- qualification_start <= now() <= qualification_end (UTC)
17+
- If multiple windows match, we choose the latest by qualification_start.
18+
19+
## verified_at semantics
20+
- If the user is currently verified, verified_at returns the latest time at which all required conditions were satisfied:
21+
- tag_at: earliest tag_registration DV for the active distribution
22+
- hodler_at: earliest send_token_hodler DV meeting the min balance for the active distribution
23+
- earn_at:
24+
- If earn_min_balance = 0, no earn requirement is enforced (earn_at = qualification_start of the active distribution)
25+
- Otherwise, the earliest send_earn_balances_timeline row where assets >= earn_min_balance for any of the user’s addresses
26+
- verified_at = GREATEST(tag_at, hodler_at, earn_at)
27+
- If any required timestamp is NULL, verified_at returns NULL and the user is not currently verified.
28+
29+
## is_verified
30+
is_verified(profiles) simply returns (verified_at(profiles) IS NOT NULL). This avoids duplicated logic and guarantees consistency.
31+
32+
## Query points and usage
33+
- profile_lookup returns is_verified (and verified_at is available separately via public.verified_at(p)).
34+
- UI can:
35+
- Show a badge when is_verified = true
36+
- Show the time since verification began using verified_at
37+
38+
## Edge cases
39+
- Missing any one of the conditions -> verified_at = NULL, is_verified = false
40+
- Earn threshold set to 0 -> earn condition is bypassed, so tag + hodler alone determine verification
41+
- 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
42+
43+
## Performance notes
44+
- The functions operate within a single SELECT using a single active distribution row and narrowly scoped subqueries.
45+
- The logic avoids repeated full scans by joining against specific user- and distribution-scoped data.
46+
47+
## Testing
48+
- 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).

packages/app/features/account/components/AccountHeader.tsx

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const ShareProfileDialog = lazy(() =>
3030
)
3131

3232
export const AccountHeader = memo<YStackProps>(function AccountHeader(props) {
33-
const { profile, distributionShares } = useUser()
33+
const { profile } = useUser()
3434
const [shareDialogOpen, setShareDialogOpen] = useState(false)
3535
const hoverStyles = useHoverStyles()
3636

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

57-
const isVerified = useMemo(
58-
() => Boolean(distributionShares[0] && distributionShares[0].amount > 0n),
59-
[distributionShares]
60-
)
61-
6257
const handleSharePress = useCallback(async () => {
6358
if (!referralHref) return
6459

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

7873
// Verification status icon component
7974
const VerificationIcon = () => {
80-
if (isVerified) {
75+
if (profile?.is_verified) {
8176
return (
8277
<IconBadgeCheckSolid
8378
size={'$1.5'}
@@ -135,7 +130,7 @@ export const AccountHeader = memo<YStackProps>(function AccountHeader(props) {
135130
<Paragraph size={'$8'} fontWeight={600} numberOfLines={1}>
136131
{name || '---'}
137132
</Paragraph>
138-
{!isVerified ? (
133+
{!profile?.is_verified ? (
139134
<Tooltip placement={'bottom'} delay={0}>
140135
<Tooltip.Content
141136
enterStyle={{ x: 0, y: -5, opacity: 0, scale: 0.9 }}
@@ -168,7 +163,7 @@ export const AccountHeader = memo<YStackProps>(function AccountHeader(props) {
168163
</Tooltip.Content>
169164
<Tooltip.Trigger
170165
onPress={(e) => {
171-
if (isVerified) {
166+
if (profile?.is_verified) {
172167
return
173168
}
174169
e.preventDefault()

packages/app/features/deposit/DepositCoinbase/screen.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export function DepositCoinbaseScreen({ defaultPaymentMethod }: DepositCoinbaseS
8585
color={'$lightGrayTextField'}
8686
$theme-light={{ color: '$darkGrayTextField' }}
8787
>
88-
Turn off "Block Popups" in iOS Safari Settings then try again.
88+
Turn off &quot;Block Popups&quot; in iOS Safari Settings then try again.
8989
</Paragraph>
9090
)}
9191
</>

packages/app/features/home/InvestmentsBalanceCard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
View,
1717
} from '@my/ui'
1818
import formatAmount, { localizeAmount } from 'app/utils/formatAmount'
19-
import { ChevronLeft, ChevronRight } from '@tamagui/lucide-icons'
19+
import { ChevronRight } from '@tamagui/lucide-icons'
2020
import { useIsPriceHidden } from './utils/useIsPriceHidden'
2121
import { useSendAccountBalances } from 'app/utils/useSendAccountBalances'
2222
import {

packages/app/features/home/StablesBalanceCard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
} from '@my/ui'
1414
import formatAmount from 'app/utils/formatAmount'
1515

16-
import { ChevronLeft, ChevronRight } from '@tamagui/lucide-icons'
16+
import { ChevronRight } from '@tamagui/lucide-icons'
1717
import { useIsPriceHidden } from 'app/features/home/utils/useIsPriceHidden'
1818
import { useSendAccountBalances } from 'app/utils/useSendAccountBalances'
1919
import { stableCoins, usdcCoin } from 'app/data/coins'

packages/app/features/home/charts/shared/components/ChartLineSection.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ export function ChartLineSection({
3030
const native = useScrollNativeGesture()
3131

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

3737
const mergedPanProps = {
3838
shouldCancelWhenOutside: false,

packages/app/features/profile/screen.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,7 @@ import {
2222
// Internal
2323
import { useProfileLookup } from 'app/utils/useProfileLookup'
2424
import { useProfileScreenParams } from 'app/routers/params'
25-
import {
26-
IconAccount,
27-
IconLinkInBio,
28-
IconCheckCircle,
29-
IconBadgeCheckSolid,
30-
} from 'app/components/icons'
25+
import { IconAccount, IconLinkInBio, IconBadgeCheckSolid } from 'app/components/icons'
3126
import { ShareOtherProfileDialog } from './components/ShareOtherProfileDialog'
3227
import type { Functions } from '@my/supabase/database.types'
3328
import { useTokenPrices } from 'app/utils/useTokenPrices'

packages/app/utils/useUser.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export const useUser = () => {
4646
const { data, error } = await supabase
4747
.from('profiles')
4848
.select(
49-
'*, tags(*), main_tag(*), links_in_bio(*), distribution_shares(*), canton_party_verifications(*)'
49+
'*, is_verified, tags(*), main_tag(*), links_in_bio(*), distribution_shares(*), canton_party_verifications(*)'
5050
)
5151
.eq('id', user?.id ?? '')
5252
.single()

0 commit comments

Comments
 (0)