diff --git a/e2e/faucet.test.ts b/e2e/faucet.test.ts index 76fa4bbb..65faf197 100644 --- a/e2e/faucet.test.ts +++ b/e2e/faucet.test.ts @@ -1,4 +1,5 @@ import { expect, test } from '@playwright/test' +import { getDemoStep } from './helpers' test('fund an address via faucet', async ({ page }) => { test.setTimeout(120000) @@ -26,8 +27,8 @@ test('fund an address via faucet', async ({ page }) => { ) }) -test('fund a connected passkey wallet via faucet', async ({ page }) => { - test.setTimeout(240000) +test('fund a connected passkey wallet and send a first payment', async ({ page }) => { + test.setTimeout(300000) const client = await page.context().newCDPSession(page) await client.send('WebAuthn.enable') @@ -73,6 +74,20 @@ test('fund a connected passkey wallet via faucet', async ({ page }) => { await expect(addFundsStep.getByRole('button', { name: 'Add more funds' })).toBeVisible({ timeout: 120000, }) + + const handoff = page.getByRole('link', { name: 'Send your first testnet payment' }) + await expect(handoff).toBeVisible() + await handoff.click() + await expect(page).toHaveURL(/\/docs\/guide\/payments\/send-a-payment#send-payment-demo$/) + + const sendPaymentStep = getDemoStep(page, 'Send 100 AlphaUSD to a recipient.') + const enterDetailsButton = sendPaymentStep.getByRole('button', { name: 'Enter details' }) + await expect(enterDetailsButton).toBeEnabled({ timeout: 90000 }) + await enterDetailsButton.click() + await sendPaymentStep.getByRole('button', { name: 'Send' }).click() + await expect(sendPaymentStep.getByRole('link', { name: 'View receipt' })).toBeVisible({ + timeout: 90000, + }) } finally { await client.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }).catch(() => {}) } diff --git a/src/components/guides/FaucetActivationExperiment.tsx b/src/components/guides/FaucetActivationExperiment.tsx new file mode 100644 index 00000000..0880fb02 --- /dev/null +++ b/src/components/guides/FaucetActivationExperiment.tsx @@ -0,0 +1,180 @@ +'use client' + +import * as React from 'react' +import { + bucketClaimDuration, + captureDeveloperActivationEvent, + categorizeActivationFailure, + FAUCET_ACTIVATION_EXPERIMENT_KEY, + type FaucetActivationVariant, + type FaucetClaimMethod, + readFaucetActivationJourney, + resolveFaucetActivationAssignment, + startFaucetActivationJourney, + trackDeveloperActivationQuickstart, + updateFaucetActivationJourney, +} from '../../lib/developer-activation' +import { onPostHogReady } from '../../lib/posthog' + +type FaucetActivationContextValue = { + claimFailed: (method: FaucetClaimMethod, error: unknown) => void + claimStarted: (method: FaucetClaimMethod) => void + claimSucceeded: (method: FaucetClaimMethod, receiptCount: number) => void + hasSuccessfulClaim: boolean + variant: FaucetActivationVariant +} + +const emptyContext: FaucetActivationContextValue = { + claimFailed: () => {}, + claimStarted: () => {}, + claimSucceeded: () => {}, + hasSuccessfulClaim: false, + variant: 'unassigned', +} + +const FaucetActivationContext = React.createContext(emptyContext) + +export function useFaucetActivation() { + return React.useContext(FaucetActivationContext) +} + +export function FaucetActivationProvider({ children }: React.PropsWithChildren) { + const e2eVariant = import.meta.env.VITE_E2E === 'true' ? ('guided_handoff' as const) : null + const [variant, setVariant] = React.useState(e2eVariant ?? 'unassigned') + const [hasSuccessfulClaim, setHasSuccessfulClaim] = React.useState(false) + const variantRef = React.useRef(e2eVariant ?? 'unassigned') + const variantLockedRef = React.useRef(Boolean(e2eVariant)) + const viewedRef = React.useRef(false) + const claimStartedAtRef = React.useRef>>({}) + + const trackView = React.useCallback((assignedVariant: FaucetActivationVariant) => { + if (viewedRef.current) return + viewedRef.current = true + captureDeveloperActivationEvent('faucet_viewed', assignedVariant, {}) + }, []) + + React.useEffect(() => { + const existingJourney = readFaucetActivationJourney() + if (existingJourney) { + variantRef.current = existingJourney.experimentVariant + variantLockedRef.current = true + setVariant(existingJourney.experimentVariant) + setHasSuccessfulClaim(true) + trackView(existingJourney.experimentVariant) + return + } + + if (e2eVariant) { + trackView(e2eVariant) + return + } + + let unsubscribeFeatureFlags: (() => void) | undefined + const unsubscribeReady = onPostHogReady((posthog) => { + unsubscribeFeatureFlags?.() + unsubscribeFeatureFlags = posthog.onFeatureFlags((_flags, _variants, context) => { + const assignedVariant = resolveFaucetActivationAssignment({ + errorsLoading: context?.errorsLoading, + featureFlag: posthog.getFeatureFlag(FAUCET_ACTIVATION_EXPERIMENT_KEY), + locked: variantLockedRef.current, + }) + if (!assignedVariant) return + variantRef.current = assignedVariant + variantLockedRef.current = true + setVariant(assignedVariant) + trackView(assignedVariant) + }) + }) + + return () => { + unsubscribeFeatureFlags?.() + unsubscribeReady() + } + }, [e2eVariant, trackView]) + + const claimStarted = React.useCallback( + (method: FaucetClaimMethod) => { + const assignedVariant = variantRef.current + variantLockedRef.current = true + claimStartedAtRef.current[method] = Date.now() + trackView(assignedVariant) + captureDeveloperActivationEvent('faucet_claim_started', assignedVariant, { + claim_method: method, + }) + }, + [trackView], + ) + + const claimSucceeded = React.useCallback((method: FaucetClaimMethod, receiptCount: number) => { + const now = Date.now() + const assignedVariant = variantRef.current + const startedAt = claimStartedAtRef.current[method] + const { persisted } = startFaucetActivationJourney(method, assignedVariant, now) + captureDeveloperActivationEvent('faucet_claim_succeeded', assignedVariant, { + claim_duration_bucket: bucketClaimDuration( + startedAt === undefined ? undefined : now - startedAt, + ), + claim_method: method, + journey_persisted: persisted, + receipt_count: receiptCount, + }) + setHasSuccessfulClaim(true) + }, []) + + const claimFailed = React.useCallback((method: FaucetClaimMethod, error: unknown) => { + const now = Date.now() + const startedAt = claimStartedAtRef.current[method] + captureDeveloperActivationEvent('faucet_claim_failed', variantRef.current, { + claim_duration_bucket: bucketClaimDuration( + startedAt === undefined ? undefined : now - startedAt, + ), + claim_method: method, + failure_category: categorizeActivationFailure(error), + }) + }, []) + + const value = React.useMemo( + () => ({ claimFailed, claimStarted, claimSucceeded, hasSuccessfulClaim, variant }), + [claimFailed, claimStarted, claimSucceeded, hasSuccessfulClaim, variant], + ) + + return ( + {children} + ) +} + +export function FaucetActivationHandoff() { + const { hasSuccessfulClaim, variant } = useFaucetActivation() + if (!hasSuccessfulClaim || variant !== 'guided_handoff') return null + + const destination = '/docs/guide/payments/send-a-payment#send-payment-demo' + return ( + + ) +} + +export function DeveloperActivationQuickstartTracker() { + React.useEffect(() => { + trackDeveloperActivationQuickstart() + }, []) + return null +} diff --git a/src/components/guides/steps/payments/AddFundsToOthers.tsx b/src/components/guides/steps/payments/AddFundsToOthers.tsx index 63e32d01..2a55abb4 100644 --- a/src/components/guides/steps/payments/AddFundsToOthers.tsx +++ b/src/components/guides/steps/payments/AddFundsToOthers.tsx @@ -7,7 +7,9 @@ import { mnemonicToAccount } from 'viem/accounts' import { Actions } from 'viem/tempo' import { useBlockNumber, useClient, useConnection } from 'wagmi' import { Hooks } from 'wagmi/tempo' +import { assertSuccessfulFaucetReceipts } from '../../../../lib/developer-activation' import { Button, ExplorerAccountLink, Step } from '../../Demo' +import { useFaucetActivation } from '../../FaucetActivationExperiment' import { alphaUsd } from '../../tokens' import type { DemoStepProps } from '../types' @@ -15,6 +17,7 @@ export function AddFundsToOthers(props: DemoStepProps) { const { stepNumber = 2, last = false } = props const { address } = useConnection() const queryClient = useQueryClient() + const activation = useFaucetActivation() const [fundAddress, setFundAddress] = React.useState(undefined) // Initialize fundAddress with connected wallet address (only once) @@ -37,7 +40,6 @@ export function AddFundsToOthers(props: DemoStepProps) { refetchInterval: 1_500, }, }) - // biome-ignore lint/correctness/useExhaustiveDependencies: _ React.useEffect(() => { balanceRefetch() }, [blockNumber]) @@ -47,7 +49,7 @@ export function AddFundsToOthers(props: DemoStepProps) { if (!isValidTarget) throw new Error('valid target address not found') if (!client) throw new Error('client not found') - let receipts = null + let receipts: readonly { status?: string }[] if (import.meta.env.VITE_TEMPO_ENV !== 'localnet') receipts = await Actions.faucet.fundSync(client as unknown as Client, { account: targetAddress as Address, @@ -66,12 +68,24 @@ export function AddFundsToOthers(props: DemoStepProps) { ) receipts = [result.receipt] } + assertSuccessfulFaucetReceipts(receipts) await new Promise((resolve) => setTimeout(resolve, 400)) queryClient.refetchQueries({ queryKey: ['getBalance'] }) return receipts }, + onSuccess(receipts) { + activation.claimSucceeded('address_form', receipts.length) + }, + onError(error) { + activation.claimFailed('address_form', error) + }, }) + const claimFunds = React.useCallback(() => { + activation.claimStarted('address_form') + fundAccount.mutate() + }, [activation, fundAccount.mutate]) + const active = React.useMemo(() => { // If this is the last step, simply has to be logged in if (last) return true @@ -87,7 +101,7 @@ export function AddFundsToOthers(props: DemoStepProps) { disabled={!isValidTarget || fundAccount.isPending} variant="default" className="font-normal text-[14px] -tracking-[2%]" - onClick={() => fundAccount.mutate()} + onClick={claimFunds} type="button" > {fundAccount.isPending ? 'Adding funds' : 'Add more funds'} @@ -99,7 +113,7 @@ export function AddFundsToOthers(props: DemoStepProps) { variant={isValidTarget ? 'accent' : 'default'} className="font-normal text-[14px] -tracking-[2%]" type="button" - onClick={() => fundAccount.mutate()} + onClick={claimFunds} > {fundAccount.isPending ? 'Adding funds' : 'Add funds'} @@ -109,7 +123,7 @@ export function AddFundsToOthers(props: DemoStepProps) { balance, fundAccount.isPending, fundAccount.isSuccess, - fundAccount.mutate, + claimFunds, fundAccount, ]) diff --git a/src/components/guides/steps/payments/SendPayment.tsx b/src/components/guides/steps/payments/SendPayment.tsx index eff63971..ac119e10 100644 --- a/src/components/guides/steps/payments/SendPayment.tsx +++ b/src/components/guides/steps/payments/SendPayment.tsx @@ -3,6 +3,12 @@ import * as React from 'react' import { isAddress, pad, parseUnits, stringToHex } from 'viem' import { useConnection, useConnectionEffect } from 'wagmi' import { Hooks } from 'wagmi/tempo' +import { + categorizeActivationFailure, + isDeliveredTestnetPayment, + trackDeveloperActivationPaymentFailed, + trackDeveloperActivationPaymentSucceeded, +} from '../../../../lib/developer-activation' import { Button, ExplorerLink, FAKE_RECIPIENT, Step } from '../../Demo' import { alphaUsd } from '../../tokens' import type { DemoStepProps } from '../types' @@ -20,6 +26,16 @@ export function SendPayment(props: DemoStepProps) { }) const sendPayment = Hooks.token.useTransferSync({ mutation: { + onSuccess(result, variables) { + if (isDeliveredTestnetPayment(result, variables.to)) { + trackDeveloperActivationPaymentSucceeded() + return + } + trackDeveloperActivationPaymentFailed('receive_policy_redirect') + }, + onError(error) { + trackDeveloperActivationPaymentFailed(categorizeActivationFailure(error)) + }, onSettled() { balanceRefetch() }, diff --git a/src/components/guides/steps/wallet/AddFundsToWallet.tsx b/src/components/guides/steps/wallet/AddFundsToWallet.tsx index 492465aa..23ef2488 100644 --- a/src/components/guides/steps/wallet/AddFundsToWallet.tsx +++ b/src/components/guides/steps/wallet/AddFundsToWallet.tsx @@ -7,8 +7,10 @@ import { mnemonicToAccount } from 'viem/accounts' import { Actions } from 'viem/tempo' import { useBlockNumber, useClient, useConnections } from 'wagmi' import { Hooks } from 'wagmi/tempo' +import { assertSuccessfulFaucetReceipts } from '../../../../lib/developer-activation' import { isFundableWalletConnector } from '../../../lib/wallets' import { Button, Step } from '../../Demo' +import { useFaucetActivation } from '../../FaucetActivationExperiment' import { alphaUsd } from '../../tokens' import type { DemoStepProps } from '../types' @@ -22,6 +24,7 @@ export function AddFundsToWallet(props: DemoStepProps) { const address = walletConnection?.accounts[0] const hasNonWebAuthnWallet = Boolean(address) const queryClient = useQueryClient() + const activation = useFaucetActivation() const { data: balance, refetch: balanceRefetch } = Hooks.token.useGetBalance({ account: address, @@ -36,7 +39,6 @@ export function AddFundsToWallet(props: DemoStepProps) { refetchInterval: 1_500, }, }) - // biome-ignore lint/correctness/useExhaustiveDependencies: _ React.useEffect(() => { balanceRefetch() }, [blockNumber]) @@ -46,23 +48,43 @@ export function AddFundsToWallet(props: DemoStepProps) { if (!address) throw new Error('account.address not found') if (!client) throw new Error('client not found') + let receipts: readonly { status?: string }[] if (import.meta.env.VITE_TEMPO_ENV !== 'localnet') - await Actions.faucet.fundSync(client as unknown as Client, { + receipts = await Actions.faucet.fundSync(client as unknown as Client, { account: address, }) else { - await Actions.token.transferSync(client as unknown as Client, { - account: mnemonicToAccount('test test test test test test test test test test test junk'), - amount: parseUnits('10000', 6), - to: address, - token: alphaUsd, - }) + const result = await Actions.token.transferSync( + client as unknown as Client, + { + account: mnemonicToAccount( + 'test test test test test test test test test test test junk', + ), + amount: parseUnits('10000', 6), + to: address, + token: alphaUsd, + }, + ) + receipts = [result.receipt] } + assertSuccessfulFaucetReceipts(receipts) await new Promise((resolve) => setTimeout(resolve, 400)) queryClient.refetchQueries({ queryKey: ['getBalance'] }) + return receipts + }, + onSuccess(receipts) { + activation.claimSucceeded('connected_wallet', receipts.length) + }, + onError(error) { + activation.claimFailed('connected_wallet', error) }, }) + const claimFunds = React.useCallback(() => { + activation.claimStarted('connected_wallet') + fundAccount.mutate() + }, [activation, fundAccount.mutate]) + const active = React.useMemo(() => { // If this is the last step, simply has to have a non-webauthn wallet if (last) return hasNonWebAuthnWallet @@ -78,7 +100,7 @@ export function AddFundsToWallet(props: DemoStepProps) { disabled={!hasNonWebAuthnWallet || fundAccount.isPending} variant="default" className="font-normal text-[14px] -tracking-[2%]" - onClick={() => fundAccount.mutate()} + onClick={claimFunds} type="button" > {fundAccount.isPending ? 'Adding funds' : 'Add more funds'} @@ -90,12 +112,12 @@ export function AddFundsToWallet(props: DemoStepProps) { variant={hasNonWebAuthnWallet ? 'accent' : 'default'} className="font-normal text-[14px] -tracking-[2%]" type="button" - onClick={() => fundAccount.mutate()} + onClick={claimFunds} > {fundAccount.isPending ? 'Adding funds' : 'Add funds'} ) - }, [hasNonWebAuthnWallet, balance, fundAccount.isPending, fundAccount.mutate, fundAccount]) + }, [hasNonWebAuthnWallet, balance, fundAccount.isPending, claimFunds, fundAccount]) return ( } { + const values = new Map() + return { + values, + getItem: (key) => values.get(key) ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, value), + } +} + +describe('faucet activation experiment', () => { + it('accepts only explicit multivariate assignments', () => { + expect(resolveFaucetActivationVariant(undefined)).toBe('unassigned') + expect(resolveFaucetActivationVariant(false)).toBe('unassigned') + expect(resolveFaucetActivationVariant(true)).toBe('unassigned') + expect(resolveFaucetActivationVariant('control')).toBe('control') + expect(resolveFaucetActivationVariant('guided_handoff')).toBe('guided_handoff') + }) + + it('locks the first valid assignment and ignores loading failures or refreshes', () => { + expect( + resolveFaucetActivationAssignment({ + featureFlag: 'guided_handoff', + locked: false, + }), + ).toBe('guided_handoff') + expect( + resolveFaucetActivationAssignment({ + errorsLoading: true, + featureFlag: 'guided_handoff', + locked: false, + }), + ).toBeNull() + expect( + resolveFaucetActivationAssignment({ + featureFlag: undefined, + locked: false, + }), + ).toBeNull() + expect( + resolveFaucetActivationAssignment({ + featureFlag: 'guided_handoff', + locked: true, + }), + ).toBeNull() + }) + + it('buckets claim and conversion timing without retaining exact durations', () => { + expect(bucketClaimDuration(undefined)).toBe('unknown') + expect(bucketClaimDuration(4_999)).toBe('under_5s') + expect(bucketClaimDuration(5_000)).toBe('5_to_15s') + expect(bucketClaimDuration(15_000)).toBe('15_to_30s') + expect(bucketClaimDuration(30_000)).toBe('30s_or_more') + + expect(bucketTimeSinceClaim(59_999)).toBe('under_1m') + expect(bucketTimeSinceClaim(60_000)).toBe('1_to_5m') + expect(bucketTimeSinceClaim(5 * 60_000)).toBe('5_to_30m') + expect(bucketTimeSinceClaim(30 * 60_000)).toBe('30m_to_2h') + expect(bucketTimeSinceClaim(2 * 60 * 60_000)).toBe('2_to_24h') + expect(bucketTimeSinceClaim(FAUCET_ACTIVATION_PRIMARY_WINDOW_MS)).toBe('2_to_24h') + expect(bucketTimeSinceClaim(FAUCET_ACTIVATION_PRIMARY_WINDOW_MS + 1)).toBe('1_to_3d') + expect(bucketTimeSinceClaim(3 * FAUCET_ACTIVATION_PRIMARY_WINDOW_MS)).toBe('3_to_7d') + expect(bucketTimeSinceClaim(FAUCET_ACTIVATION_TTL_MS)).toBe('3_to_7d') + expect(bucketTimeSinceClaim(FAUCET_ACTIVATION_TTL_MS + 1)).toBe('unknown') + }) + + it('requires at least one successful faucet receipt', () => { + expect(assertSuccessfulFaucetReceipts([{ status: 'success' }])).toBe(1) + expect(() => assertSuccessfulFaucetReceipts([])).toThrow(FaucetReceiptError) + expect(() => + assertSuccessfulFaucetReceipts([{ status: 'success' }, { status: 'reverted' }]), + ).toThrow(FaucetReceiptError) + }) + + it('requires both receipt success and recipient delivery for a payment', () => { + const recipient = '0xbeefcafe54750903ac1c8909323af7beb21ea2cb' + expect( + isDeliveredTestnetPayment({ receipt: { status: 'success' }, to: recipient }, recipient), + ).toBe(true) + expect( + isDeliveredTestnetPayment( + { + receipt: { status: 'success' }, + to: '0xb10c000000000000000000000000000000000000', + }, + recipient, + ), + ).toBe(false) + expect( + isDeliveredTestnetPayment({ receipt: { status: 'reverted' }, to: recipient }, recipient), + ).toBe(false) + }) + + it('reduces failures to safe categories', () => { + expect(categorizeActivationFailure(new FaucetReceiptError())).toBe('reverted') + expect( + categorizeActivationFailure(Object.assign(new Error('private'), { name: 'TimeoutError' })), + ).toBe('timeout') + expect(categorizeActivationFailure(new Error('private'))).toBe('rpc') + expect(categorizeActivationFailure('private')).toBe('unknown') + }) +}) + +describe('faucet activation journey storage', () => { + it('stores only scope-limited journey state and updates safe booleans', () => { + const storage = memoryStorage() + const now = 1_000_000 + const started = startFaucetActivationJourney('connected_wallet', 'guided_handoff', now, storage) + + expect(started.persisted).toBe(true) + + expect(JSON.parse(storage.values.get(FAUCET_ACTIVATION_STORAGE_KEY) ?? '{}')).toEqual({ + version: 1, + claimedAt: now, + claimMethod: 'connected_wallet', + experimentVariant: 'guided_handoff', + handoffClicked: false, + quickstartTracked: false, + }) + + expect(updateFaucetActivationJourney({ handoffClicked: true }, now, storage)).toMatchObject({ + handoffClicked: true, + quickstartTracked: false, + }) + + expect(trackDeveloperActivationQuickstart(now + 1, storage)).toBe(true) + expect(trackDeveloperActivationQuickstart(now + 2, storage)).toBe(false) + expect(trackDeveloperActivationPaymentSucceeded(now + 3, storage)).toBe(true) + expect(readFaucetActivationJourney(now + 3, storage)).toMatchObject({ + handoffClicked: true, + quickstartTracked: true, + }) + }) + + it('retains a claim for the seven-day repeat-payment window', () => { + const storage = memoryStorage() + const now = 1_000_000 + startFaucetActivationJourney('address_form', 'control', now, storage) + + expect(readFaucetActivationJourney(now + FAUCET_ACTIVATION_TTL_MS, storage)).not.toBeNull() + expect(readFaucetActivationJourney(now + FAUCET_ACTIVATION_TTL_MS + 1, storage)).toBeNull() + expect(storage.values.has(FAUCET_ACTIVATION_STORAGE_KEY)).toBe(false) + }) + + it('preserves an unassigned journey without treating it as a control exposure', () => { + const storage = memoryStorage() + const now = 1_000_000 + startFaucetActivationJourney('address_form', 'unassigned', now, storage) + + expect(readFaucetActivationJourney(now, storage)).toMatchObject({ + experimentVariant: 'unassigned', + }) + }) + + it('rejects malformed or future journey state', () => { + const storage = memoryStorage() + storage.setItem(FAUCET_ACTIVATION_STORAGE_KEY, '{') + expect(readFaucetActivationJourney(1_000, storage)).toBeNull() + + startFaucetActivationJourney('address_form', 'control', 2_000, storage) + expect(readFaucetActivationJourney(1_999, storage)).toBeNull() + }) + + it('contains storage write and cleanup failures', () => { + const restrictedStorage: ActivationStorage = { + getItem: () => '{', + removeItem: () => { + throw new Error('blocked') + }, + setItem: () => { + throw new Error('blocked') + }, + } + + expect( + startFaucetActivationJourney('address_form', 'control', 1_000, restrictedStorage).persisted, + ).toBe(false) + expect(readFaucetActivationJourney(1_000, restrictedStorage)).toBeNull() + }) +}) diff --git a/src/lib/developer-activation.ts b/src/lib/developer-activation.ts new file mode 100644 index 00000000..b1a60128 --- /dev/null +++ b/src/lib/developer-activation.ts @@ -0,0 +1,332 @@ +'use client' + +import { captureDocsEvent } from './posthog' + +export const FAUCET_ACTIVATION_EXPERIMENT_KEY = 'faucet-first-payment-handoff-v1' +export const FAUCET_ACTIVATION_JOURNEY_VERSION = 'v1' +export const FAUCET_ACTIVATION_STORAGE_KEY = 'tempo:faucet-activation:v1' +export const FAUCET_ACTIVATION_PRIMARY_WINDOW_MS = 24 * 60 * 60 * 1_000 +export const FAUCET_ACTIVATION_TTL_MS = 7 * FAUCET_ACTIVATION_PRIMARY_WINDOW_MS + +export const DEVELOPER_ACTIVATION_EVENTS = { + FAUCET_VIEWED: 'faucet_viewed', + FAUCET_CLAIM_STARTED: 'faucet_claim_started', + FAUCET_CLAIM_SUCCEEDED: 'faucet_claim_succeeded', + FAUCET_CLAIM_FAILED: 'faucet_claim_failed', + QUICKSTART_STARTED: 'quickstart_started', + TESTNET_PAYMENT_SUCCEEDED: 'testnet_payment_succeeded', + TESTNET_PAYMENT_FAILED: 'testnet_payment_failed', +} as const + +export type FaucetActivationVariant = 'control' | 'guided_handoff' | 'unassigned' +export type FaucetClaimMethod = 'address_form' | 'connected_wallet' +export type ActivationFailureCategory = + | 'receive_policy_redirect' + | 'reverted' + | 'rpc' + | 'timeout' + | 'unknown' + +export type ClaimDurationBucket = 'under_5s' | '5_to_15s' | '15_to_30s' | '30s_or_more' | 'unknown' + +export type TimeSinceClaimBucket = + | 'under_1m' + | '1_to_5m' + | '5_to_30m' + | '30m_to_2h' + | '2_to_24h' + | '1_to_3d' + | '3_to_7d' + | 'unknown' + +type DeveloperActivationEventProperties = { + faucet_viewed: Record + faucet_claim_started: { + claim_method: FaucetClaimMethod + } + faucet_claim_succeeded: { + claim_duration_bucket: ClaimDurationBucket + claim_method: FaucetClaimMethod + journey_persisted: boolean + receipt_count: number + } + faucet_claim_failed: { + claim_duration_bucket: ClaimDurationBucket + claim_method: FaucetClaimMethod + failure_category: ActivationFailureCategory + } + quickstart_started: { + entry_point: 'handoff' | 'organic_after_claim' + guide_id: 'send_payment' + time_since_claim_bucket: TimeSinceClaimBucket + } + testnet_payment_succeeded: { + asset: 'alpha_usd' + delivery_confirmation: 'recipient_match' + guide_id: 'send_payment' + time_since_claim_bucket: TimeSinceClaimBucket + } + testnet_payment_failed: { + failure_category: ActivationFailureCategory + guide_id: 'send_payment' + time_since_claim_bucket: TimeSinceClaimBucket + } +} + +export function captureDeveloperActivationEvent< + event extends keyof DeveloperActivationEventProperties, +>( + event: event, + experimentVariant: FaucetActivationVariant, + properties: DeveloperActivationEventProperties[event], +) { + captureDocsEvent(event, { + activation_scope: 'testnet_developer', + network: 'moderato', + chain_id: 42431, + journey_version: FAUCET_ACTIVATION_JOURNEY_VERSION, + experiment_key: FAUCET_ACTIVATION_EXPERIMENT_KEY, + experiment_variant: experimentVariant, + ...properties, + }) +} + +export function resolveFaucetActivationVariant( + featureFlag: boolean | string | undefined, +): FaucetActivationVariant { + if (featureFlag === 'guided_handoff') return 'guided_handoff' + if (featureFlag === 'control') return 'control' + return 'unassigned' +} + +export function resolveFaucetActivationAssignment({ + errorsLoading = false, + featureFlag, + locked, +}: { + errorsLoading?: boolean + featureFlag: boolean | string | undefined + locked: boolean +}): Exclude | null { + if (locked || errorsLoading) return null + const variant = resolveFaucetActivationVariant(featureFlag) + return variant === 'unassigned' ? null : variant +} + +export function bucketClaimDuration(durationMs: number | undefined): ClaimDurationBucket { + if (durationMs === undefined || !Number.isFinite(durationMs) || durationMs < 0) return 'unknown' + if (durationMs < 5_000) return 'under_5s' + if (durationMs < 15_000) return '5_to_15s' + if (durationMs < 30_000) return '15_to_30s' + return '30s_or_more' +} + +export function bucketTimeSinceClaim(durationMs: number | undefined): TimeSinceClaimBucket { + if (durationMs === undefined || !Number.isFinite(durationMs) || durationMs < 0) return 'unknown' + if (durationMs < 60_000) return 'under_1m' + if (durationMs < 5 * 60_000) return '1_to_5m' + if (durationMs < 30 * 60_000) return '5_to_30m' + if (durationMs < 2 * 60 * 60_000) return '30m_to_2h' + if (durationMs <= FAUCET_ACTIVATION_PRIMARY_WINDOW_MS) return '2_to_24h' + if (durationMs < 3 * FAUCET_ACTIVATION_PRIMARY_WINDOW_MS) return '1_to_3d' + if (durationMs <= FAUCET_ACTIVATION_TTL_MS) return '3_to_7d' + return 'unknown' +} + +export class FaucetReceiptError extends Error { + override name = 'FaucetReceiptError' + + constructor() { + super('Faucet funding transaction did not succeed') + } +} + +export function assertSuccessfulFaucetReceipts(receipts: readonly { status?: string }[]): number { + if (receipts.length === 0 || receipts.some((receipt) => receipt.status !== 'success')) + throw new FaucetReceiptError() + return receipts.length +} + +export function isDeliveredTestnetPayment( + result: { receipt?: { status?: string }; to?: string }, + requestedRecipient: string, +) { + return ( + result.receipt?.status === 'success' && + typeof result.to === 'string' && + result.to.toLowerCase() === requestedRecipient.toLowerCase() + ) +} + +export function categorizeActivationFailure(error: unknown): ActivationFailureCategory { + if (error instanceof FaucetReceiptError) return 'reverted' + if (!(error instanceof Error)) return 'unknown' + + const name = error.name.toLowerCase() + if (name.includes('timeout')) return 'timeout' + if (name.includes('revert')) return 'reverted' + return 'rpc' +} + +export type FaucetActivationJourney = { + claimedAt: number + claimMethod: FaucetClaimMethod + experimentVariant: FaucetActivationVariant + handoffClicked: boolean + quickstartTracked: boolean + version: 1 +} + +export type ActivationStorage = Pick + +function browserStorage(): ActivationStorage | undefined { + if (typeof window === 'undefined') return undefined + try { + return window.localStorage + } catch { + return undefined + } +} + +function isFaucetActivationJourney(value: unknown): value is FaucetActivationJourney { + if (!value || typeof value !== 'object') return false + const journey = value as Partial + return ( + journey.version === 1 && + typeof journey.claimedAt === 'number' && + Number.isFinite(journey.claimedAt) && + journey.claimedAt >= 0 && + (journey.claimMethod === 'address_form' || journey.claimMethod === 'connected_wallet') && + (journey.experimentVariant === 'control' || + journey.experimentVariant === 'guided_handoff' || + journey.experimentVariant === 'unassigned') && + typeof journey.handoffClicked === 'boolean' && + typeof journey.quickstartTracked === 'boolean' + ) +} + +export function writeFaucetActivationJourney( + journey: FaucetActivationJourney, + storage: ActivationStorage | undefined = browserStorage(), +) { + if (!storage) return false + try { + storage.setItem(FAUCET_ACTIVATION_STORAGE_KEY, JSON.stringify(journey)) + return true + } catch { + return false + } +} + +function removeFaucetActivationJourney(storage: ActivationStorage) { + try { + storage.removeItem(FAUCET_ACTIVATION_STORAGE_KEY) + } catch { + // Storage may be readable but not writable in restricted browser contexts. + } +} + +export function startFaucetActivationJourney( + claimMethod: FaucetClaimMethod, + experimentVariant: FaucetActivationVariant, + now = Date.now(), + storage: ActivationStorage | undefined = browserStorage(), +) { + const journey: FaucetActivationJourney = { + version: 1, + claimedAt: now, + claimMethod, + experimentVariant, + handoffClicked: false, + quickstartTracked: false, + } + return { + journey, + persisted: writeFaucetActivationJourney(journey, storage), + } +} + +export function readFaucetActivationJourney( + now = Date.now(), + storage: ActivationStorage | undefined = browserStorage(), +): FaucetActivationJourney | null { + if (!storage) return null + + try { + const value = storage.getItem(FAUCET_ACTIVATION_STORAGE_KEY) + if (!value) return null + const journey: unknown = JSON.parse(value) + if ( + !isFaucetActivationJourney(journey) || + journey.claimedAt > now || + now - journey.claimedAt > FAUCET_ACTIVATION_TTL_MS + ) { + removeFaucetActivationJourney(storage) + return null + } + return journey + } catch { + removeFaucetActivationJourney(storage) + return null + } +} + +export function updateFaucetActivationJourney( + update: Partial>, + now = Date.now(), + storage: ActivationStorage | undefined = browserStorage(), +) { + const journey = readFaucetActivationJourney(now, storage) + if (!journey) return null + const updated = { ...journey, ...update } + writeFaucetActivationJourney(updated, storage) + return updated +} + +export function trackDeveloperActivationQuickstart( + now = Date.now(), + storage: ActivationStorage | undefined = browserStorage(), +) { + const journey = readFaucetActivationJourney(now, storage) + if (!journey || journey.quickstartTracked) return false + + captureDeveloperActivationEvent('quickstart_started', journey.experimentVariant, { + entry_point: journey.handoffClicked ? 'handoff' : 'organic_after_claim', + guide_id: 'send_payment', + time_since_claim_bucket: bucketTimeSinceClaim(now - journey.claimedAt), + }) + updateFaucetActivationJourney({ quickstartTracked: true }, now, storage) + return true +} + +export function trackDeveloperActivationPaymentSucceeded( + now = Date.now(), + storage: ActivationStorage | undefined = browserStorage(), +) { + const journey = readFaucetActivationJourney(now, storage) + if (!journey) return false + + captureDeveloperActivationEvent('testnet_payment_succeeded', journey.experimentVariant, { + asset: 'alpha_usd', + delivery_confirmation: 'recipient_match', + guide_id: 'send_payment', + time_since_claim_bucket: bucketTimeSinceClaim(now - journey.claimedAt), + }) + return true +} + +export function trackDeveloperActivationPaymentFailed( + failureCategory: ActivationFailureCategory, + now = Date.now(), + storage: ActivationStorage | undefined = browserStorage(), +) { + const journey = readFaucetActivationJourney(now, storage) + if (!journey) return false + + captureDeveloperActivationEvent('testnet_payment_failed', journey.experimentVariant, { + failure_category: failureCategory, + guide_id: 'send_payment', + time_since_claim_bucket: bucketTimeSinceClaim(now - journey.claimedAt), + }) + return true +} diff --git a/src/lib/posthog.ts b/src/lib/posthog.ts index 7cb07b40..6aedf572 100644 --- a/src/lib/posthog.ts +++ b/src/lib/posthog.ts @@ -71,8 +71,10 @@ export const POSTHOG_PROPERTIES = { type EventProperties = Record type PendingEvent = { event: string; properties: EventProperties } type PostHogClient = typeof import('posthog-js')['default'] +type PostHogReadyListener = (client: PostHogClient) => void const pendingEvents: PendingEvent[] = [] +const posthogReadyListeners = new Set() const maxPendingEvents = 50 let posthogReady = false let posthogClient: PostHogClient | null = null @@ -105,6 +107,15 @@ export function markPostHogReady(client: PostHogClient) { for (const pending of pendingEvents.splice(0)) { posthogClient.capture(pending.event, pending.properties) } + for (const listener of posthogReadyListeners) listener(client) +} + +export function onPostHogReady(listener: PostHogReadyListener) { + posthogReadyListeners.add(listener) + if (posthogReady && posthogClient?.__loaded) listener(posthogClient) + return () => { + posthogReadyListeners.delete(listener) + } } export function trackDocsSearchOpened(source: 'header' | 'keyboard' | 'marketing') { diff --git a/src/pages/docs/guide/payments/send-a-payment.mdx b/src/pages/docs/guide/payments/send-a-payment.mdx index 95dc757e..b2e3dfb4 100644 --- a/src/pages/docs/guide/payments/send-a-payment.mdx +++ b/src/pages/docs/guide/payments/send-a-payment.mdx @@ -8,6 +8,7 @@ import * as Demo from '../../../../components/guides/Demo.tsx' import { AddFunds } from '../../../../components/guides/steps/payments/AddFunds.tsx' import { Connect } from '../../../../components/guides/steps/auth/Connect.tsx' import { SendPayment } from '../../../../components/guides/steps/payments/SendPayment.tsx' +import { DeveloperActivationQuickstartTracker } from '../../../../components/guides/FaucetActivationExperiment.tsx' import { Cards, Card } from 'vocs' import { Tabs, Tab } from 'vocs' @@ -15,6 +16,8 @@ import { Tabs, Tab } from 'vocs' Send stablecoin payments between accounts on Tempo. Payments can include optional memos for reconciliation and tracking. + + :::warning[Confirm delivery, not just transaction success] On post-T6 networks, a blocked TIP-20 `transfer` / `transferFrom` still succeeds, but credits `ReceivePolicyGuard` at `0xB10C000000000000000000000000000000000000` instead of the intended receiver. diff --git a/src/pages/docs/quickstart/faucet.mdx b/src/pages/docs/quickstart/faucet.mdx index 9f574328..de9f090c 100644 --- a/src/pages/docs/quickstart/faucet.mdx +++ b/src/pages/docs/quickstart/faucet.mdx @@ -11,6 +11,7 @@ import { AddFundsToWallet } from '../../../components/guides/steps/wallet/AddFun import { AddTokensToWallet } from '../../../components/guides/steps/wallet/AddTokensToWallet.tsx' import { ConnectWallet } from '../../../components/guides/steps/wallet/ConnectWallet.tsx' import { SetFeeToken } from '../../../components/guides/steps/wallet/SetFeeToken.tsx' +import { FaucetActivationHandoff, FaucetActivationProvider } from '../../../components/guides/FaucetActivationExperiment.tsx' import * as Token from '../../../components/guides/tokens' import { Tabs, Tab } from 'vocs' @@ -19,6 +20,7 @@ import { Tabs, Tab } from 'vocs' Get test stablecoins on Tempo testnet. + @@ -84,6 +86,8 @@ Replace `` with your wallet address. + + The faucet funds the following assets.