diff --git a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx index f3dc378de9..8c1a147584 100644 --- a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx +++ b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx @@ -1003,6 +1003,27 @@ describe('GROUP 1: Landing / Method Selection', () => { expect(screen.getByText('Add Money')).toBeInTheDocument() }) + test('an earned Offramp badge keeps its migration entry regardless of provenance', () => { + mockUseAuth.mockReturnValue({ + user: { + user: { + username: 'test-user', + userId: 'user-123', + badges: [{ code: 'OFFRAMP_USER' }], + }, + }, + isFetchingUser: false, + fetchUser: jest.fn(), + }) + + renderWithProviders() + + fireEvent.click(screen.getByTestId('action-card-migrate-from-offramp')) + expect(mockRouterPush).toHaveBeenCalledWith('/add-money/crypto?network=EVM&source=offramp') + expect(screen.getByText('Crypto')).toBeInTheDocument() + expect(screen.getByText('Bank Transfer')).toBeInTheDocument() + }) + test('clicking Crypto opens the network drawer', () => { renderWithProviders() diff --git a/src/app/(mobile-ui)/card/page.tsx b/src/app/(mobile-ui)/card/page.tsx index 294ca5471e..640801a059 100644 --- a/src/app/(mobile-ui)/card/page.tsx +++ b/src/app/(mobile-ui)/card/page.tsx @@ -653,6 +653,7 @@ const CardPage: FC = () => { const allBadges = user?.user?.badges?.map((b) => ({ code: b.code, + iconUrl: b.iconUrl, earnedAt: b.earnedAt, })) ?? cardInfo!.skipBadges.map((code) => ({ code })) return ( diff --git a/src/app/(setup)/setup/page.tsx b/src/app/(setup)/setup/page.tsx index 44e61e3307..9f65786ff3 100644 --- a/src/app/(setup)/setup/page.tsx +++ b/src/app/(setup)/setup/page.tsx @@ -93,17 +93,15 @@ function SetupPageContent() { // Skip the invite-code gate straight to signup when either: // - an invite code is present (cookie survives the PWA-install hop), or - // - the URL asks for it via ?step=signup — the signal every campaign / - // skip flow already sends (ShhhhhLandingPage, InvitesPage.handleClaim) - // when it pushes to /setup. useZeroDev still reads the campaignTag - // cookie post-signup to award the badge; the step decision no longer - // trusts that cookie. + // - the URL asks for it via ?step=signup — the signal every campaign + // entrypoint sends when it pushes to /setup. After authentication, + // useZeroDev submits the queued opaque campaign list to the canonical + // claim service; the step decision never interprets that cookie. // - // Why not the campaignTag cookie: it's a session cookie cleared on signup - // (only once every stacked award succeeds — a failed /badge/award keeps - // it for retry), so a returning user who claimed a campaign earlier in - // the same session was routed past Landing (the only screen with Log In) - // onto Signup, unable to log back in (regression from PR #2346). + // Why not the campaignTag cookie: retryable campaign acquisition can + // intentionally persist for 30 days. Using it as onboarding state would + // route a returning user past Landing (the only screen with Log In) onto + // Signup, unable to log back in (regression from PR #2346). const inviteCodeFromCookie = getFromCookie('inviteCode') const userInviteCode = inviteCode || inviteCodeFromCookie // pwa-sunset notice window: web signups are closed (Landing hides diff --git a/src/app/actions/__tests__/invites-resolution.test.ts b/src/app/actions/__tests__/invites-resolution.test.ts new file mode 100644 index 0000000000..a27042ba87 --- /dev/null +++ b/src/app/actions/__tests__/invites-resolution.test.ts @@ -0,0 +1,89 @@ +import { validateInviteCode } from '../invites' +import { serverFetch } from '@/utils/api-fetch' + +jest.mock('@/utils/api-fetch', () => ({ serverFetch: jest.fn() })) + +const mockServerFetch = serverFetch as jest.MockedFunction + +function response(status: number, body: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + json: jest.fn().mockResolvedValue(body), + } as unknown as Response +} + +describe('validateInviteCode mixed-version resolution', () => { + beforeEach(() => jest.clearAllMocks()) + + it('treats a typed campaign-only HTTP 409 as a settled transport result', async () => { + mockServerFetch.mockResolvedValue( + response(409, { + message: 'Campaign processed without invite attribution', + attributionResolved: false, + onboardingResolved: false, + username: '', + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + }) + ) + + await expect(validateInviteCode('offramp')).resolves.toEqual({ + data: { + success: true, + attributionResolved: false, + onboardingResolved: false, + username: '', + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + }, + }) + }) + + it('keeps an untyped HTTP 409 as a transport failure', async () => { + mockServerFetch.mockResolvedValue(response(409, { error: 'Invite code is not valid' })) + + await expect(validateInviteCode('not-an-invite')).resolves.toEqual({ + error: 'Invite code is not valid', + }) + }) + + it('supports a pre-discriminator HTTP 200 validation response with a username', async () => { + mockServerFetch.mockResolvedValue(response(200, { username: 'legacy-inviter' })) + + await expect(validateInviteCode('legacy-invite')).resolves.toEqual({ + data: { + success: true, + attributionResolved: true, + onboardingResolved: true, + username: 'legacy-inviter', + legacyAcquisition: undefined, + }, + }) + }) + + it('lets an explicit false discriminator override the legacy username signal', async () => { + mockServerFetch.mockResolvedValue( + response(200, { + username: 'legacy-inviter', + attributionResolved: false, + onboardingResolved: false, + }) + ) + + await expect(validateInviteCode('legacy-invite')).resolves.toMatchObject({ + data: { + success: true, + attributionResolved: false, + onboardingResolved: false, + username: 'legacy-inviter', + }, + }) + }) +}) diff --git a/src/app/actions/invites.ts b/src/app/actions/invites.ts index 48279dc577..d914ba9a76 100644 --- a/src/app/actions/invites.ts +++ b/src/app/actions/invites.ts @@ -1,22 +1,47 @@ import { serverFetch } from '@/utils/api-fetch' +import { parseLegacyInviteAcquisition, type LegacyInviteAcquisition } from '@/services/invite-acquisition' +import { isTypedCampaignOnlyInviteResponse, resolveInviteResolutionFlags } from '@/services/invite-response' -export async function validateInviteCode( - inviteCode: string -): Promise<{ data?: { success: boolean; username: string }; error?: string }> { +export async function validateInviteCode(inviteCode: string): Promise<{ + data?: { + success: boolean + attributionResolved: boolean + onboardingResolved: boolean + username: string + legacyAcquisition?: LegacyInviteAcquisition + } + error?: string +}> { try { const response = await serverFetch('/invites/validate', { method: 'POST', body: JSON.stringify({ inviteCode }), }) - if (!response.ok) { - const data = await response.json() - return { error: data.error || 'Failed to validate invite code.' } + const data: unknown = await response.json() + const typedCampaignOnly = response.status === 409 && isTypedCampaignOnlyInviteResponse(data) + + if (!response.ok && !typedCampaignOnly) { + const error = + data && typeof data === 'object' && typeof (data as { error?: unknown }).error === 'string' + ? (data as { error: string }).error + : 'Failed to validate invite code.' + return { error } } - const data = await response.json() + const body = data && typeof data === 'object' ? (data as Record) : {} + const username = typeof body.username === 'string' ? body.username : '' + const resolution = resolveInviteResolutionFlags(data, username.trim().length > 0) + const legacyAcquisition = parseLegacyInviteAcquisition(body.legacyAcquisition) - return { data: { success: true, username: data.username } } + return { + data: { + success: true, + ...resolution, + username, + legacyAcquisition, + }, + } } catch (error) { console.error('Error calling validate invite code API:', error) if (error instanceof Error) { diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx index 39e4cd0cc2..d4a23c4e2c 100644 --- a/src/app/invite/page.tsx +++ b/src/app/invite/page.tsx @@ -12,7 +12,7 @@ async function getInviteCodeData(inviteCode: string) { const response = await validateInviteCode(inviteCode) - if (response.data?.success) { + if (response.data?.success && response.data.onboardingResolved) { return { username: response.data.username, } diff --git a/src/app/shhhhh/ShhhhhLandingPage.tsx b/src/app/shhhhh/ShhhhhLandingPage.tsx index d28d69a2ed..b0c44c7369 100644 --- a/src/app/shhhhh/ShhhhhLandingPage.tsx +++ b/src/app/shhhhh/ShhhhhLandingPage.tsx @@ -13,14 +13,15 @@ import { PixelatedCardFace } from '@/components/Card/share-asset/PixelatedCardFa import { inflateWaitlistPosition } from '@/components/Card/doorTally.utils' import { Sparkle, Star } from '@/assets/illustrations' import { cardApi } from '@/services/card' -import { invitesApi } from '@/services/invites' -import { saveToCookie } from '@/utils/general.utils' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' - -// /shhhhh?campaign=skip — the Skip Pass link. Awards the WAITLIST_SKIP badge -// (same contract as /invite?campaign=skip) so friends-of-Peanut skip the line. -// A BARE visit grants nothing: the door joins the waitlist, it is not a bypass. -const SKIP_CAMPAIGN = 'skip' +import { badgeCampaignsFromSearchParams, queuePendingBadgeCampaigns } from '@/components/Invites/badge-campaign-context' +import { claimAndSettlePendingBadgeCampaigns, isConfirmedBadgeCampaignClaim } from '@/services/badge-campaigns' +import { captureException } from '@sentry/nextjs' +import { + destinationForShhhhhClaims, + queueShhhhhCampaignContinuation, + shhhhhCampaignSignupRoute, +} from './shhhhh-acquisition' // Inline "you're on the waitlist" confirmation shown in place of the door CTA // once the user joins (pre-launch, non-skip path). `dark` = on the black §7. @@ -193,39 +194,48 @@ export default function ShhhhhLandingPage() { }, [user]) const handleCTA = async () => { - // /shhhhh?campaign=skip → Skip Pass (awards the badge). A bare press is - // NOT a bypass — it joins the waitlist (Hugo 2026-06-07). Read the param - // at click time (client-only) — avoids useSearchParams (which would bail - // /shhhhh out of static prerendering) and any mount-effect race. - const isSkipCampaign = - new URLSearchParams(window.location.search).get('campaign')?.toLowerCase() === SKIP_CAMPAIGN + // Read opaque campaign identities at click time (client-only) to avoid + // bailing static prerendering. The backend resolves award semantics; + // provenance is audit-only and does not weaken an earned skip badge. + const badgeCampaigns = badgeCampaignsFromSearchParams(new URLSearchParams(window.location.search)) posthog.capture(ANALYTICS_EVENTS.DOOR_TRY, { signed_in: !!user, - campaign: isSkipCampaign ? SKIP_CAMPAIGN : null, + campaign_tags: badgeCampaigns, }) - // Skip Pass link → award the badge; the user is in. (The bare door never - // awards a badge — see below.) - if (isSkipCampaign) { + if (badgeCampaigns.length > 0) { + const queuedBadgeCampaigns = queuePendingBadgeCampaigns(badgeCampaigns, 30) if (!user) { - // useZeroDev's post-signup `campaignTag` branch awards the badge - // (awaited inside registration, before setup redirects) — so - // landing on /card afterwards is race-free. - saveToCookie('campaignTag', SKIP_CAMPAIGN) - router.push(`/setup?step=signup&redirect_uri=${encodeURIComponent('/card')}`) + queueShhhhhCampaignContinuation() + router.push(shhhhhCampaignSignupRoute()) return } + + let destination: '/card' | '/home' = '/home' try { - await invitesApi.awardBadge(SKIP_CAMPAIGN) - posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPTED, { campaign_tag: SKIP_CAMPAIGN }) - await fetchUser() - } catch (err) { - console.error('[shhhhh] awardBadge(skip) failed:', err) + const batch = await claimAndSettlePendingBadgeCampaigns(queuedBadgeCampaigns) + const confirmed = batch.claims.filter(isConfirmedBadgeCampaignClaim) + destination = destinationForShhhhhClaims(batch.claims) + if (confirmed.length > 0) { + posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPTED, { + campaign_tags: confirmed.map((claim) => claim.badgeCampaign), + }) + await fetchUser() + } + if (batch.pending.length > 0) { + captureException(new Error('shhhhh campaign claim retained for retry'), { + tags: { error_type: 'campaign_claim_retryable' }, + extra: { pendingCampaigns: batch.pending, claims: batch.claims }, + }) + } + } catch (error) { + captureException(error, { tags: { error_type: 'campaign_claim_unexpected_failure' } }) } - // Straight into the card flow — the badge award just opened both - // gates BE-side; /home would make them hunt for the card CTA. - router.push('/card') + + // Only a confirmed Skip Pass continues to the card flow. Unknown, + // inactive, expired, malformed, and retryable claims fall back home. + router.push(destination) return } diff --git a/src/app/shhhhh/shhhhh-acquisition.test.ts b/src/app/shhhhh/shhhhh-acquisition.test.ts new file mode 100644 index 0000000000..c0a8f9641a --- /dev/null +++ b/src/app/shhhhh/shhhhh-acquisition.test.ts @@ -0,0 +1,68 @@ +import { getRedirectUrl } from '@/utils/general.utils' +import { + destinationForShhhhhClaims, + queueShhhhhCampaignContinuation, + settleShhhhhCampaignContinuation, + shhhhhCampaignSignupRoute, +} from './shhhhh-acquisition' + +describe('Shhhhh campaign continuation', () => { + beforeEach(() => localStorage.clear()) + + it.each(['awarded', 'already_owned'] as const)('continues a confirmed Skip Pass %s to /card', (outcome) => { + expect(destinationForShhhhhClaims([{ badgeCampaign: 'skip', badgeCode: 'WAITLIST_SKIP', outcome }])).toBe( + '/card' + ) + }) + + it.each(['inactive', 'expired', 'unknown', 'definition_missing', 'retryable_error'] as const)( + 'falls back home for %s', + (outcome) => { + expect(destinationForShhhhhClaims([{ badgeCampaign: 'skip', badgeCode: 'WAITLIST_SKIP', outcome }])).toBe( + '/home' + ) + } + ) + + it('does not treat an unrelated confirmed badge as a card continuation', () => { + expect( + destinationForShhhhhClaims([{ badgeCampaign: 'event', badgeCode: 'EVENT_ALUMNI', outcome: 'awarded' }]) + ).toBe('/home') + }) + + it('starts signed-out registration without an unconditional card redirect', () => { + expect(shhhhhCampaignSignupRoute()).toBe('/setup?step=signup') + }) + + it.each(['awarded', 'already_owned'] as const)( + 'replaces the safe signed-out marker with /card after confirmed Skip Pass %s', + (outcome) => { + queueShhhhhCampaignContinuation() + expect( + settleShhhhhCampaignContinuation([{ badgeCampaign: 'skip', badgeCode: 'WAITLIST_SKIP', outcome }]) + ).toBe('/card') + expect(getRedirectUrl()).toBe('/card') + } + ) + + it.each(['inactive', 'expired', 'unknown', 'definition_missing', 'retryable_error'] as const)( + 'keeps the signed-out continuation on the normal app after %s', + (outcome) => { + queueShhhhhCampaignContinuation() + expect( + settleShhhhhCampaignContinuation([{ badgeCampaign: 'skip', badgeCode: 'WAITLIST_SKIP', outcome }]) + ).toBe('/home') + expect(getRedirectUrl()).toBe('/home') + } + ) + + it("does not consume another acquisition flow's stored destination", () => { + localStorage.setItem('redirect', JSON.stringify('/claim?step=claim')) + expect( + settleShhhhhCampaignContinuation([ + { badgeCampaign: 'skip', badgeCode: 'WAITLIST_SKIP', outcome: 'awarded' }, + ]) + ).toBeUndefined() + expect(getRedirectUrl()).toBe('/claim?step=claim') + }) +}) diff --git a/src/app/shhhhh/shhhhh-acquisition.ts b/src/app/shhhhh/shhhhh-acquisition.ts new file mode 100644 index 0000000000..2b849827b4 --- /dev/null +++ b/src/app/shhhhh/shhhhh-acquisition.ts @@ -0,0 +1,38 @@ +import { isConfirmedBadgeCampaignClaim, type BadgeCampaignClaim } from '@/services/badge-campaigns' +import { getRedirectUrl, saveToLocalStorage } from '@/utils/general.utils' + +const WAITLIST_SKIP_BADGE_CODE = 'WAITLIST_SKIP' +const PENDING_SHHHHH_REDIRECT = '/home?badge_campaign_continuation=shhhhh' + +/** + * Start from a safe normal-app continuation. Registration replaces this marker + * with `/card` only after the API confirms that this intent awarded Skip Pass. + */ +export function queueShhhhhCampaignContinuation(): void { + saveToLocalStorage('redirect', PENDING_SHHHHH_REDIRECT) +} + +export function shhhhhCampaignSignupRoute(): string { + return '/setup?step=signup' +} + +/** + * The Shhhhh link has one established continuation: a confirmed Skip Pass goes + * to the card flow. Every unconfirmed or unrelated campaign uses normal_app. + */ +export function destinationForShhhhhClaims(claims: readonly BadgeCampaignClaim[]): '/card' | '/home' { + return claims.some((claim) => isConfirmedBadgeCampaignClaim(claim) && claim.badgeCode === WAITLIST_SKIP_BADGE_CODE) + ? '/card' + : '/home' +} + +/** + * Resolve the signed-out Shhhhh continuation once, after registration claims + * settle. `undefined` means this registration did not originate at Shhhhh. + */ +export function settleShhhhhCampaignContinuation(claims: readonly BadgeCampaignClaim[]): '/card' | '/home' | undefined { + if (getRedirectUrl() !== PENDING_SHHHHH_REDIRECT) return undefined + const destination = destinationForShhhhhClaims(claims) + saveToLocalStorage('redirect', destination) + return destination +} diff --git a/src/components/AddMoney/views/AddMoneyMethodSelection.view.tsx b/src/components/AddMoney/views/AddMoneyMethodSelection.view.tsx index 861200f43e..4f7e31d8f9 100644 --- a/src/components/AddMoney/views/AddMoneyMethodSelection.view.tsx +++ b/src/components/AddMoney/views/AddMoneyMethodSelection.view.tsx @@ -3,14 +3,14 @@ import { ActionListCard } from '@/components/ActionListCard' import AvatarWithBadge from '@/components/Profile/AvatarWithBadge' import ChooseNetworkDrawer from '../components/ChooseNetworkDrawer' -// offramp.xyz migrants get this link-granted badge at signup (peanut-api-ts -// invite/badge routes, code `offramp` / utm `offramp`). -import { OFFRAMP_BADGE_CODE } from '@/components/Invites/campaign-maps' import type { RhinoChainType } from '@/services/services.types' import { useAuth } from '@/context/authContext' import { useRouter } from 'next/navigation' import { useState } from 'react' import { useTranslations } from 'next-intl' +import { OFFRAMP_MIGRATION_ROUTE } from '@/services/acquisition-navigation' + +const OFFRAMP_BADGE_CODE = 'OFFRAMP_USER' interface AddMoneyMethodSelectionProps { onBankTransferClick: () => void @@ -22,8 +22,9 @@ const AddMoneyMethodSelection = ({ onBankTransferClick }: AddMoneyMethodSelectio const t = useTranslations('addMoney') const [isDrawerOpen, setIsDrawerOpen] = useState(false) - // offramp migrants get a tailored, de-cluttered arbitrum deposit entry - const hasOfframpBadge = user?.user?.badges?.some((b) => b.code === OFFRAMP_BADGE_CODE) ?? false + // Existing OFFRAMP_USER possession carries the migration entry. Acquisition + // provenance is audit-only and must not weaken this established benefit. + const hasOfframpMigrationEntry = user?.user?.badges?.some((badge) => badge.code === OFFRAMP_BADGE_CODE) ?? false const handleNetworkSelect = (network: RhinoChainType) => { setIsDrawerOpen(false) @@ -35,7 +36,7 @@ const AddMoneyMethodSelection = ({ onBankTransferClick }: AddMoneyMethodSelectio

{t('howWouldYouLikeToAdd')}

- {hasOfframpBadge && ( + {hasOfframpMigrationEntry && ( } - onClick={() => router.push('/add-money/crypto?network=EVM&source=offramp')} + onClick={() => router.push(OFFRAMP_MIGRATION_ROUTE)} /> )} } onClick={() => setIsDrawerOpen(true)} /> diff --git a/src/components/Badges/BadgeDetailModal.tsx b/src/components/Badges/BadgeDetailModal.tsx index fae5044a20..11e6f6e05f 100644 --- a/src/components/Badges/BadgeDetailModal.tsx +++ b/src/components/Badges/BadgeDetailModal.tsx @@ -1,9 +1,9 @@ 'use client' -import Image from 'next/image' import type { StaticImageData } from 'next/image' import { useLocale, useTranslations } from 'next-intl' import ActionModal from '../Global/ActionModal' +import { BadgeImage } from './BadgeImage' import ShareButton from '../Global/ShareButton' import { getBadgeShareText } from './badge.utils' import { useUserStore } from '@/redux/hooks' @@ -35,7 +35,16 @@ export const BadgeDetailModal = ({ isOpen, onClose, code, title, description, lo return ( } + icon={ + + } iconContainerClassName="bg-transparent min-w-60 h-auto" modalPanelClassName="m-0" visible={isOpen} diff --git a/src/components/Badges/BadgeEarnToast.tsx b/src/components/Badges/BadgeEarnToast.tsx index 2000f84142..2143810cb3 100644 --- a/src/components/Badges/BadgeEarnToast.tsx +++ b/src/components/Badges/BadgeEarnToast.tsx @@ -18,14 +18,14 @@ import { useEffect, useRef, useState } from 'react' import { usePathname, useRouter } from 'next/navigation' -import Image from 'next/image' import posthog from 'posthog-js' import { useTranslations } from 'next-intl' import { useToast } from '@/components/0_Bruddle/Toast' import { BadgeDetailModal } from '@/components/Badges/BadgeDetailModal' -import { getBadgeDisplayName, getBadgeIcon, getPublicBadgeDescription } from '@/components/Badges/badge.utils' +import { getBadgeDescription, getBadgeDisplayName, getBadgeIcon } from '@/components/Badges/badge.utils' import { useBadgeEarnToast } from '@/components/Badges/useBadgeEarnToast' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { BadgeImage } from '@/components/Badges/BadgeImage' const HOME_PATH = '/home' @@ -53,7 +53,7 @@ export default function BadgeEarnToast() { const count = badges.length const newest = badges[0] const newestName = getBadgeDisplayName(newest.code, newest.name) - const newestIcon = getBadgeIcon(newest.code) + const newestIcon = getBadgeIcon(newest.code, newest.iconUrl) // Per-batch id (not a fixed id): a fixed id de-dupes in the Toast layer, // so a second badge earned within the toast's window would be marked // seen but never shown. Keying on the codes lets a distinct later batch @@ -68,7 +68,7 @@ export default function BadgeEarnToast() { setModalBadge({ code: newest.code, title: newestName, - description: newest.description || getPublicBadgeDescription(newest.code) || '', + description: getBadgeDescription(newest.description) || '', logo: newestIcon, }) } else { @@ -85,7 +85,7 @@ export default function BadgeEarnToast() { className: 'border-yellow-1', content: ( + ), +})) +jest.mock('./InvitesPageLayout', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) +jest.mock('../Global/PeanutLoading', () => ({ + __esModule: true, + default: () =>
Loading
, +})) +jest.mock('../Payment/Views/Error.validation.view', () => ({ + __esModule: true, + default: ({ title, message }: { title: string; message: string }) => ( +
+

{title}

+

{message}

+
+ ), +})) +jest.mock('../Global/UnsupportedBrowserModal', () => ({ + __esModule: true, + default: () => null, +})) + +const awardedBatch = { + transport: 'canonical', + pending: [], + claims: [{ badgeCampaign: 'nita', badgeCode: 'NITA', outcome: 'awarded' }], +} + +describe('invite and badge campaign routing boundaries', () => { + beforeEach(() => { + jest.clearAllMocks() + mockSearch = '' + mockAuth = { + user: { user: { userId: 'user-1', username: 'member', hasAppAccess: true } }, + isFetchingUser: false, + fetchUser: mockFetchUser, + } + mockQueryResult = { isLoading: false, isError: false } + mockClaimBadgeCampaigns.mockResolvedValue(awardedBatch) + mockQueuePendingBadgeCampaigns.mockImplementation((badgeCampaigns: readonly string[]) => [...badgeCampaigns]) + }) + + it('never derives NITA acquisition from the Juana inviter code alone', async () => { + mockSearch = 'code=juanacervio' + mockQueryResult.data = { + success: true, + attributionResolved: true, + onboardingResolved: true, + username: 'juanacervio', + } + + render() + + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/profile/juanacervio')) + expect(mockClaimBadgeCampaigns).not.toHaveBeenCalled() + }) + + it('uses backend validation metadata for an authenticated code-only Offramp journey', async () => { + mockSearch = 'code=offramp' + mockQueryResult.data = { + success: true, + attributionResolved: true, + onboardingResolved: true, + username: 'peanut', + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + } + mockClaimBadgeCampaigns.mockResolvedValue({ + transport: 'canonical', + pending: [], + claims: [ + { + badgeCampaign: 'offramp', + badgeCode: 'OFFRAMP_USER', + outcome: 'already_owned', + }, + ], + }) + + render() + + await waitFor(() => expect(mockClaimBadgeCampaigns).toHaveBeenCalledWith(['offramp'])) + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/add-money/crypto?network=EVM&source=offramp')) + }) + + it('falls back to the normal app when code-only legacy acquisition is unconfirmed', async () => { + mockSearch = 'code=offramp' + mockQueryResult.data = { + success: true, + attributionResolved: true, + onboardingResolved: true, + username: 'peanut', + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + } + mockClaimBadgeCampaigns.mockResolvedValue({ + transport: 'canonical', + pending: ['offramp'], + claims: [{ badgeCampaign: 'offramp', outcome: 'retryable_error' }], + }) + + render() + + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/home')) + }) + + it.each([ + ['token-nation-2026', 'TOKEN_NATION_SP_2026'], + ['nita', 'NITA'], + ])('source-qualifies historic UTM alias %s and follows only the typed backend outcome', async (raw, badgeCode) => { + mockSearch = `utm_campaign=${raw}` + mockClaimBadgeCampaigns.mockResolvedValue({ + transport: 'canonical', + pending: [], + claims: [ + { + badgeCampaign: `utm:${raw}`, + badgeCode, + outcome: 'awarded', + }, + ], + }) + + render() + + await waitFor(() => expect(mockClaimBadgeCampaigns).toHaveBeenCalledWith([`utm:${raw}`])) + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/home')) + }) + + it.each([ + ['campaign=naija', 'naija', 'NAIJA'], + ['badge_campaign=naija', 'naija', 'NAIJA'], + ['campaign=terere', 'terere', 'TERERE'], + ['badge_campaign=terere', 'terere', 'TERERE'], + ])('keeps the bare campaign URL %s claimable without an inviter', async (search, badgeCampaign, badgeCode) => { + mockSearch = search + mockClaimBadgeCampaigns.mockResolvedValue({ + transport: 'canonical', + pending: [], + claims: [{ badgeCampaign, badgeCode, outcome: 'awarded' }], + }) + + render() + + await waitFor(() => expect(mockClaimBadgeCampaigns).toHaveBeenCalledWith([badgeCampaign])) + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/home')) + expect(screen.queryByText('Invalid Invite Code')).not.toBeInTheDocument() + }) + + it.each([ + ['badge_campaign=offramp', 'offramp'], + ['campaign=offramp', 'offramp'], + ['utm_campaign=offramp', 'utm:offramp'], + ])('uses confirmed acquisition navigation for badge-campaign-only %s', async (search, badgeCampaign) => { + mockSearch = search + mockClaimBadgeCampaigns.mockResolvedValue({ + transport: 'canonical', + pending: [], + claims: [ + { + badgeCampaign, + badgeCode: 'OFFRAMP_USER', + outcome: 'awarded', + capabilities: [{ key: 'app.offramp_migration_entry' }], + acquisition: { + fallback: 'normal_app', + destination: 'offramp_migration', + }, + }, + ], + }) + + render() + + await waitFor(() => expect(mockClaimBadgeCampaigns).toHaveBeenCalledWith([badgeCampaign])) + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/add-money/crypto?network=EVM&source=offramp')) + }) + + it.each([ + ['badge_campaign=offramp', 'offramp'], + ['campaign=offramp', 'offramp'], + ['utm_campaign=offramp', 'utm:offramp'], + ['campaign=naija', 'naija'], + ['campaign=terere', 'terere'], + ])('queues signed-out %s for post-registration badge settlement', async (search, badgeCampaign) => { + mockAuth.user = null + mockSearch = search + + render() + fireEvent.click(await screen.findByRole('button', { name: 'Sign up' })) + + expect(mockQueuePendingBadgeCampaigns).toHaveBeenCalledWith([badgeCampaign]) + expect(mockPush).toHaveBeenCalledWith('/setup?step=signup') + }) + + it('processes campaign independently when the inviter code is invalid', async () => { + mockSearch = 'code=bad&badge_campaign=nita' + mockQueryResult.data = { + success: false, + attributionResolved: false, + onboardingResolved: false, + username: '', + } + mockQueryResult.isError = true + + render() + + await waitFor(() => expect(mockClaimBadgeCampaigns).toHaveBeenCalledWith(['nita'])) + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/home')) + expect(screen.queryByText('Invalid Invite Code')).not.toBeInTheDocument() + }) + + it('processes code plus campaign while preserving the normal inviter destination', async () => { + mockSearch = 'code=alice&badge_campaign=nita' + mockQueryResult.data = { + success: true, + attributionResolved: true, + onboardingResolved: true, + username: 'alice', + } + + render() + + await waitFor(() => expect(mockClaimBadgeCampaigns).toHaveBeenCalledWith(['nita'])) + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/profile/alice')) + }) + + it('lets a confirmed bespoke campaign destination override a personal inviter profile', async () => { + mockSearch = 'code=alice&badge_campaign=offramp' + mockQueryResult.data = { + success: true, + attributionResolved: true, + onboardingResolved: true, + username: 'alice', + } + mockClaimBadgeCampaigns.mockResolvedValue({ + transport: 'canonical', + pending: [], + claims: [ + { + badgeCampaign: 'offramp', + outcome: 'awarded', + acquisition: { + fallback: 'normal_app', + destination: 'offramp_migration', + }, + }, + ], + }) + + render() + + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/add-money/crypto?network=EVM&source=offramp')) + }) + + it('preserves a safe financial continuation over campaign and inviter navigation', async () => { + mockSearch = + 'code=alice&badge_campaign=nita&redirect_uri=%2Fclaim%3Fstep%3Dclaim%26link%3Dhttps%253A%252F%252Fpeanut.to%252Fclaim' + mockQueryResult.data = { + success: true, + attributionResolved: true, + onboardingResolved: true, + username: 'alice', + } + + render() + + await waitFor(() => expect(mockClaimBadgeCampaigns).toHaveBeenCalledWith(['nita'])) + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/claim?step=claim&link=https://peanut.to/claim')) + expect(mockPush).not.toHaveBeenCalledWith('/profile/alice') + }) + + it('preserves a safe caller continuation while a published compatibility badge settles', async () => { + mockSearch = 'code=offramp&redirect_uri=%2Fclaim%3Fstep%3Dclaim%26id%3Dpayment-1' + mockQueryResult.data = { + success: true, + attributionResolved: true, + onboardingResolved: true, + username: 'peanut', + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + } + mockClaimBadgeCampaigns.mockResolvedValue({ + transport: 'canonical', + pending: [], + claims: [{ badgeCampaign: 'offramp', outcome: 'already_owned' }], + }) + + render() + + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/claim?step=claim&id=payment-1')) + expect(mockPush).not.toHaveBeenCalledWith('/add-money/crypto?network=EVM&source=offramp') + }) + + it('shows Invalid Invite only for an invalid code with no independent campaign', async () => { + mockSearch = 'code=bad' + mockQueryResult.data = { + success: false, + attributionResolved: false, + onboardingResolved: false, + username: '', + } + mockQueryResult.isError = true + + render() + + expect(await screen.findByText('Invalid Invite Code')).toBeInTheDocument() + expect(mockClaimBadgeCampaigns).not.toHaveBeenCalled() + }) + + it('falls through to the normal app for a terminal unavailable campaign', async () => { + mockSearch = 'badge_campaign=retired' + mockClaimBadgeCampaigns.mockResolvedValue({ + transport: 'canonical', + pending: [], + claims: [{ badgeCampaign: 'retired', outcome: 'expired' }], + }) + + render() + + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/home')) + expect(screen.queryByText(/badge unlocked/i)).not.toBeInTheDocument() + }) + + it('stores valid inviter attribution and campaign identity separately for signup', async () => { + mockAuth.user = null + mockSearch = 'code=alice&utm_campaign=summer-analytics&badge_campaign=Creator%2FSummer&badge_campaign=second' + mockQueryResult.data = { + success: true, + attributionResolved: true, + onboardingResolved: true, + username: 'alice', + } + + render() + fireEvent.click(await screen.findByRole('button', { name: 'Claim your spot' })) + + expect(mockDispatch).toHaveBeenCalledWith({ type: 'invite/code', payload: 'alice' }) + expect(mockSaveToCookie).toHaveBeenCalledWith('inviteCode', 'alice') + expect(mockQueuePendingBadgeCampaigns).toHaveBeenCalledWith(['Creator/Summer', 'second']) + expect(mockPush).toHaveBeenCalledWith('/setup?step=signup') + }) + + it('leaves code-only compatibility acquisition to signed-out invite acceptance', async () => { + mockAuth.user = null + mockSearch = 'code=offramp' + mockQueryResult.data = { + success: true, + attributionResolved: true, + onboardingResolved: true, + username: 'peanut', + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + } + + render() + fireEvent.click(await screen.findByRole('button', { name: 'Claim your spot' })) + + expect(mockSaveToCookie).toHaveBeenCalledWith('inviteCode', 'offramp') + expect(mockQueuePendingBadgeCampaigns).not.toHaveBeenCalled() + expect(mockPush).toHaveBeenCalledWith('/setup?step=signup') + }) + + it('uses onboardingResolved rather than a username-shaped field for a NONE adapter', async () => { + mockAuth.user = null + mockSearch = 'code=founderhaus' + mockQueryResult.data = { + success: true, + attributionResolved: false, + onboardingResolved: false, + username: 'legacy-placeholder', + legacyAcquisition: { + campaignTag: 'founderhaus', + fallback: 'normal_app', + destination: 'normal_app', + }, + } + + render() + + expect(await screen.findByText('Claim your badge')).toBeInTheDocument() + expect(screen.queryByText(/legacy-placeholder invited you/i)).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Sign up' })) + + expect(mockSaveToCookie).toHaveBeenCalledWith('inviteCode', 'founderhaus') + expect(mockQueuePendingBadgeCampaigns).not.toHaveBeenCalled() + expect(mockPush).toHaveBeenCalledWith('/setup?step=signup') + }) + + it('keeps a validated system inviter distinct from its generic analytics UTM during signup', async () => { + mockAuth.user = null + mockSearch = 'code=SQUIRRELINVITESYOU&utm_campaign=summer-analytics' + mockQueryResult.data = { + success: true, + attributionResolved: true, + onboardingResolved: true, + username: 'peanut', + legacyAcquisition: { + campaignTag: 'arbiverseinvitesyou', + fallback: 'normal_app', + destination: 'normal_app', + }, + } + + render() + + expect(await screen.findByText('peanut invited you to Peanut')).toBeInTheDocument() + expect(screen.queryByText('Claim your badge')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Claim your spot' })) + + expect(mockSaveToCookie).toHaveBeenCalledWith('inviteCode', 'squirrelinvitesyou') + expect(mockQueuePendingBadgeCampaigns).toHaveBeenCalledWith(['utm:summer-analytics']) + expect(mockPush).toHaveBeenCalledWith('/setup?step=signup') + }) + + it('consumes unknown analytics without blocking a typed system acquisition normal fallback', async () => { + mockSearch = 'code=SQUIRRELINVITESYOU&utm_campaign=summer-analytics' + mockQueryResult.data = { + success: true, + attributionResolved: true, + onboardingResolved: true, + username: 'peanut', + legacyAcquisition: { + campaignTag: 'arbiverseinvitesyou', + fallback: 'normal_app', + destination: 'normal_app', + }, + } + mockClaimBadgeCampaigns.mockResolvedValue({ + transport: 'canonical', + pending: [], + claims: [ + { badgeCampaign: 'utm:summer-analytics', outcome: 'unknown' }, + { badgeCampaign: 'arbiverseinvitesyou', outcome: 'already_owned' }, + ], + }) + + render() + + await waitFor(() => + expect(mockClaimBadgeCampaigns).toHaveBeenCalledWith(['utm:summer-analytics', 'arbiverseinvitesyou']) + ) + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/home')) + expect(mockPush).not.toHaveBeenCalledWith('/profile/peanut') + }) + + it('returns existing-user login to code-only badge acquisition before normal fallback', async () => { + mockAuth.user = null + mockSearch = 'code=offramp' + mockQueryResult.data = { + success: true, + attributionResolved: true, + onboardingResolved: true, + username: 'peanut', + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + } + + render() + fireEvent.click(await screen.findByRole('button', { name: /already have an account/i })) + + expect(mockQueuePendingBadgeCampaigns).toHaveBeenCalledWith(['offramp'], 30) + expect(mockSaveRedirectUrl).toHaveBeenCalledTimes(1) + expect(mockLogin).toHaveBeenCalledTimes(1) + }) + + it('does not persist a bad inviter alongside a valid campaign', async () => { + mockAuth.user = null + mockSearch = 'code=bad&utm_campaign=analytics&campaign=legacy&badge_campaign=nita' + mockQueryResult.data = { + success: false, + attributionResolved: false, + onboardingResolved: false, + username: '', + } + + render() + fireEvent.click(await screen.findByRole('button', { name: 'Sign up' })) + + expect(mockSaveToCookie).not.toHaveBeenCalled() + expect(mockDispatch).not.toHaveBeenCalled() + expect(mockQueuePendingBadgeCampaigns).toHaveBeenCalledWith(['nita']) + expect(mockPush).toHaveBeenCalledWith('/setup?step=signup') + }) +}) diff --git a/src/components/Invites/InvitesPage.tsx b/src/components/Invites/InvitesPage.tsx index dd8e33786d..ef3a9d4241 100644 --- a/src/components/Invites/InvitesPage.tsx +++ b/src/components/Invites/InvitesPage.tsx @@ -13,14 +13,26 @@ import { useAppDispatch } from '@/redux/hooks' import { setupActions } from '@/redux/slices/setup-slice' import { useAuth } from '@/context/authContext' import { EInviteType } from '@/services/services.types' -import { saveToCookie } from '@/utils/general.utils' +import { getValidRedirectUrl, saveRedirectUrl, saveToCookie } from '@/utils/general.utils' import { useLogin } from '@/hooks/useLogin' import UnsupportedBrowserModal from '../Global/UnsupportedBrowserModal' import posthog from 'posthog-js' import { useTranslations } from 'next-intl' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { profileUrl } from '@/utils/native-routes' -import { OFFRAMP_BADGE_CODE, classifyBareCampaigns, resolveCampaigns } from './campaign-maps' +import { captureException } from '@sentry/nextjs' +import { + badgeCampaignsFromSearchParams, + queuePendingBadgeCampaigns, + sanitizeBadgeCampaignIdentities, +} from './badge-campaign-context' +import { + claimAndSettlePendingBadgeCampaigns, + destinationForConfirmedBadgeCampaignAcquisition, + isConfirmedBadgeCampaignClaim, + isUnavailableBadgeCampaignClaim, +} from '@/services/badge-campaigns' +import { destinationForInviteAcquisition } from '@/services/invite-acquisition' function InvitePageContent() { const t = useTranslations('invites') @@ -29,36 +41,19 @@ function InvitePageContent() { // trim trailing '?' from invite code to handle qr codes with ? at the end const inviteCode = searchParams.get('code')?.toLowerCase().replace(/\?+$/, '') const redirectUri = searchParams.get('redirect_uri') + const safeRedirectUri = redirectUri ? getValidRedirectUrl(redirectUri, '') : '' const { user, isFetchingUser, fetchUser } = useAuth() - // support 'campaign', 'campaignTag', and 'utm_campaign' query parameters — - // repeated params and comma-separated values stack (all get awarded). - // union + lowercase-tag mapping live in campaign-maps.ts (unit-tested). - // memoized: the array is an effect dependency below, and a fresh identity - // every render would refire the effect. - const campaigns = useMemo( - () => - resolveCampaigns( - [...searchParams.getAll('campaign'), ...searchParams.getAll('campaignTag')], - inviteCode, - searchParams.get('utm_campaign')?.toLowerCase() - ), - [searchParams, inviteCode] - ) - - // Bare campaign link (no invite code) that's claimable without an invite. The - // effects below treat these specially so a returning logged-in user still gets - // the badge (useZeroDev's award only fires on new-account registration). The - // copy distinguishes the two flavours: `isWaitlistSkip` (skip + event_alumni) - // promises a card-waitlist skip; vanity badges (touched_grass) are claimable - // but show generic copy. See classifyBareCampaigns in campaign-maps.ts. - const { isBareClaimCampaign, isWaitlistSkip } = classifyBareCampaigns(campaigns, inviteCode) + // Badge campaign identities are opaque and backend-owned. The canonical + // namespace wins over legacy/UTM transports; inviter `code` is unrelated. + const urlBadgeCampaigns = useMemo(() => badgeCampaignsFromSearchParams(searchParams), [searchParams]) + const hasUrlBadgeCampaigns = urlBadgeCampaigns.length > 0 const dispatch = useAppDispatch() const router = useRouter() const { handleLoginClick, isLoggingIn } = useLogin() - const [isAwardingBadge, setIsAwardingBadge] = useState(false) - const hasStartedAwardingRef = useRef(false) + const [isClaimingBadgeCampaigns, setIsClaimingBadgeCampaigns] = useState(false) + const hasStartedBadgeClaimingRef = useRef(false) // Track if we should show content (prevents flash) const [shouldShowContent, setShouldShowContent] = useState(false) @@ -73,10 +68,25 @@ function InvitePageContent() { enabled: !!inviteCode, }) + // A published code-only compatibility path may carry backend-owned + // acquisition metadata. Combining that typed descriptor with explicit URL + // identities is not code inference: the browser forwards both opaque tags + // to the same canonical claim endpoint. + const legacyAcquisition = inviteCodeData?.legacyAcquisition + const acquisitionBadgeCampaigns = useMemo( + () => + sanitizeBadgeCampaignIdentities([ + ...urlBadgeCampaigns, + ...(legacyAcquisition ? [legacyAcquisition.campaignTag] : []), + ]), + [urlBadgeCampaigns, legacyAcquisition] + ) + const hasAcquisitionBadgeCampaigns = acquisitionBadgeCampaigns.length > 0 + // track invite page view (ref guard prevents duplicate fires when shouldShowContent toggles) const hasTrackedPageView = useRef(false) useEffect(() => { - if (shouldShowContent && inviteCodeData?.success && !hasTrackedPageView.current) { + if (shouldShowContent && inviteCodeData?.onboardingResolved && !hasTrackedPageView.current) { hasTrackedPageView.current = true posthog.capture(ANALYTICS_EVENTS.INVITE_PAGE_VIEWED, { invite_code: inviteCode, @@ -96,58 +106,98 @@ function InvitePageContent() { setShouldShowContent(false) return } - // a logged-in visitor on either claim path will be auto-routed by the + // A logged-in visitor on either claim path will be auto-routed by the // effect below — keep the loading spinner so they don't see the CTA flash. + const canClaimBadgeCampaign = hasAcquisitionBadgeCampaigns if ( user?.user && - (isBareClaimCampaign || (!redirectUri && user.user.hasAppAccess && inviteCodeData?.success)) + (canClaimBadgeCampaign || (!redirectUri && user.user.hasAppAccess && inviteCodeData?.onboardingResolved)) ) { setShouldShowContent(false) return } setShouldShowContent(true) - }, [user, isFetchingUser, redirectUri, inviteCodeData, isLoading, isBareClaimCampaign, inviteCode]) + }, [user, isFetchingUser, redirectUri, inviteCodeData, isLoading, hasAcquisitionBadgeCampaigns, inviteCode]) - // Logged-in auto-claim: award the campaign badge then push /home, or fall - // back to the inviter's profile when there's a valid invite but no campaign. - // Fires on both the invite-code path (needs hasAppAccess) and the Skip Pass - // path (badge GRANTS access, so no hasAppAccess gate). + // Logged-in auto-claim. Inviter attribution and badge acquisition are + // independent inputs: a bad inviter is ignored without blocking a valid + // badge campaign, and a badge campaign never makes a bad inviter valid. useEffect(() => { - if (!user?.user || isFetchingUser || hasStartedAwardingRef.current) return + if (!user?.user || isFetchingUser || hasStartedBadgeClaimingRef.current) return // wait for invite-code validation when there is one if (inviteCode && (isLoading || !inviteCodeData)) return - const hasValidInvite = !!inviteCodeData?.success && !!inviteCodeData.username + const hasValidInvite = !!inviteCodeData?.onboardingResolved && !!inviteCodeData.username const isInviteAutoClaim = !redirectUri && user.user.hasAppAccess && hasValidInvite - if (!isInviteAutoClaim && !isBareClaimCampaign) return + const canClaimBadgeCampaign = hasAcquisitionBadgeCampaigns + if (!isInviteAutoClaim && !canClaimBadgeCampaign) return - hasStartedAwardingRef.current = true + hasStartedBadgeClaimingRef.current = true - if (campaigns.length > 0) { - setIsAwardingBadge(true) - // sequential on purpose: each award is an idempotent POST and the - // backend flips access flags as side effects — parallel fire-and-forget - // would race fetchUser below. awardBadge never throws (the service - // catches internally) — check the result instead. - ;(async () => { - for (const tag of campaigns) { - const { success } = await invitesApi.awardBadge(tag) - if (!success) console.error('Error awarding campaign badge', tag) - } - })().finally(async () => { - await fetchUser() - setIsAwardingBadge(false) - // offramp migrants came here to move their balance — land them - // directly on the migration deposit screen, not /home. - // case-insensitive: dedup keeps the first-seen casing, so the - // stack may carry 'offramp_user' rather than the canonical code. - const hasOfframp = campaigns.some((tag) => tag.toLowerCase() === OFFRAMP_BADGE_CODE.toLowerCase()) - router.push(hasOfframp ? '/add-money/crypto?network=EVM&source=offramp' : '/home') - }) + if (canClaimBadgeCampaign) { + setIsClaimingBadgeCampaigns(true) + const queuedBadgeCampaigns = queuePendingBadgeCampaigns(acquisitionBadgeCampaigns, 30) + void claimAndSettlePendingBadgeCampaigns(queuedBadgeCampaigns) + .then(async (batch) => { + const confirmed = batch.claims.filter(isConfirmedBadgeCampaignClaim) + if (confirmed.length > 0) { + try { + await fetchUser() + } catch (error) { + captureException(error, { tags: { error_type: 'campaign_profile_refresh_failed' } }) + } + } + + const unavailable = batch.claims.some(isUnavailableBadgeCampaignClaim) + const retryable = batch.pending.length > 0 + if (unavailable) { + console.warn('Badge campaign unavailable; continuing normally', { + claims: batch.claims, + }) + } + if (retryable) { + captureException(new Error('invite-page campaign claim retained for retry'), { + tags: { error_type: 'campaign_claim_retryable' }, + extra: { pendingCampaigns: batch.pending, claims: batch.claims }, + }) + } + + // A validated caller continuation (notably a pending financial + // claim) outranks acquisition navigation. The small backend-owned + // destination enum is honored only after its matching claim is + // confirmed; all other outcomes fall through to the normal app. + const badgeCampaignDestination = destinationForConfirmedBadgeCampaignAcquisition(batch.claims) + const legacyDestination = legacyAcquisition + ? destinationForInviteAcquisition(legacyAcquisition, batch.claims) + : '/home' + const destination = + safeRedirectUri || + (legacyDestination !== '/home' + ? legacyDestination + : badgeCampaignDestination !== '/home' + ? badgeCampaignDestination + : legacyAcquisition + ? '/home' + : hasValidInvite + ? profileUrl(inviteCodeData!.username!) + : '/home') + router.push(destination) + }) + .catch((error) => { + captureException(error, { tags: { error_type: 'campaign_claim_unexpected_failure' } }) + router.push( + safeRedirectUri || + (legacyAcquisition + ? '/home' + : hasValidInvite + ? profileUrl(inviteCodeData!.username!) + : '/home') + ) + }) return } - // No campaign on a validated invite → route to inviter profile. + // No badge campaign on a validated invite → route to inviter profile. if (hasValidInvite) { router.push(profileUrl(inviteCodeData!.username!)) } @@ -157,11 +207,13 @@ function InvitePageContent() { isLoading, isFetchingUser, router, - campaigns, + acquisitionBadgeCampaigns, + hasAcquisitionBadgeCampaigns, redirectUri, + safeRedirectUri, fetchUser, - isBareClaimCampaign, inviteCode, + legacyAcquisition, ]) // A bare link that resolves to nothing claimable — unknown ?campaign= value, @@ -170,30 +222,25 @@ function InvitePageContent() { // is reserved for links that actually carried an invite code. Safe from a // redirect loop: the root redirect only fires when the params are present, // and we replace with a bare '/'. - const isDeadBareLink = !inviteCode && !isBareClaimCampaign - useEffect(() => { - if (isDeadBareLink) router.replace('/') - }, [isDeadBareLink, router]) const handleClaim = () => { - // invite_code keeps its single-value shape for existing PostHog filters; - // stacked campaigns ride in their own property. posthog.capture(ANALYTICS_EVENTS.INVITE_CLAIM_CLICKED, { - invite_code: inviteCode || (isBareClaimCampaign ? campaigns[0] : undefined), - campaign_tags: campaigns.join(',') || undefined, + invite_code: inviteCode, + campaign_tags: acquisitionBadgeCampaigns, }) - if (inviteCode) { + const hasValidInvite = !!inviteCode && !!inviteCodeData?.onboardingResolved && !!inviteCodeData.username + const hasBackendLegacyAcceptance = !!inviteCode && !!inviteCodeData?.success && !!legacyAcquisition + if (hasValidInvite || hasBackendLegacyAcceptance) { dispatch(setupActions.setInviteCode(inviteCode)) dispatch(setupActions.setInviteType(EInviteType.PAYMENT_LINK)) // Save to cookie so PWA-install + later signup still see the invite. saveToCookie('inviteCode', inviteCode) } - if (campaigns.length > 0) { - // useZeroDev reads `campaignTag` post-signup (comma-separated for - // stacked campaigns) and calls /badge/award for each. - saveToCookie('campaignTag', campaigns.join(',')) - } + // Explicit URL acquisition survives signup in the shared cookie. + // Backend legacy acquisition is processed by `/invites/accept`, whose + // typed claim result is consumed after registration. + if (hasUrlBadgeCampaigns) queuePendingBadgeCampaigns(urlBadgeCampaigns) const signupUrl = redirectUri ? `/setup?step=signup&redirect_uri=${encodeURIComponent(redirectUri)}` @@ -201,15 +248,34 @@ function InvitePageContent() { router.push(signupUrl) } - if (isAwardingBadge || !shouldShowContent || isDeadBareLink) { + const handleLoginWithBadgeCampaign = () => { + // Existing-user login does not call `/invites/accept`, so queue both + // explicit and backend-resolved compatibility acquisition for the + // authenticated recovery flow. + if (hasAcquisitionBadgeCampaigns) { + queuePendingBadgeCampaigns(acquisitionBadgeCampaigns, 30) + // Return to this acquisition boundary after the passkey ceremony so + // the authenticated page can settle and present the badge before its + // normal-app fallback. + saveRedirectUrl() + } + void handleLoginClick() + } + + const isDeadBareLink = !inviteCode && !hasUrlBadgeCampaigns + useEffect(() => { + if (isDeadBareLink) router.replace('/') + }, [isDeadBareLink, router]) + + if (isClaimingBadgeCampaigns || !shouldShowContent || isDeadBareLink) { return } // Invalid invite code (only reachable when an invite code was supplied). - // Bare-claim campaigns (skip / event_alumni / touched_grass) carry no invite - // code, so they bypass this gate and never show the invalid-invite screen; - // bare links with nothing claimable were bounced to '/' above. - if (!isBareClaimCampaign && (isError || !inviteCodeData?.success)) { + // A badge-campaign-only link has no inviter to validate. When a code is present, + // it must validate independently; badge campaigns never make a bad inviter valid. + const hasValidInvite = !!inviteCode && !!inviteCodeData?.onboardingResolved && !!inviteCodeData.username + if (inviteCode && !hasUrlBadgeCampaigns && !legacyAcquisition && (isError || !hasValidInvite)) { return (
@@ -259,7 +320,7 @@ function InvitePageContent() { disabled={isLoggingIn} loading={isLoggingIn} variant="primary-soft" - onClick={handleLoginClick} + onClick={handleLoginWithBadgeCampaign} shadowSize="4" > {loginLabel} diff --git a/src/components/Invites/JoinWaitlistPage.test.tsx b/src/components/Invites/JoinWaitlistPage.test.tsx new file mode 100644 index 0000000000..e339ad1508 --- /dev/null +++ b/src/components/Invites/JoinWaitlistPage.test.tsx @@ -0,0 +1,147 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithIntl as render } from '@/test-utils/intl' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import JoinWaitlistPage from './JoinWaitlistPage' + +const mockAcceptInvite = jest.fn() +const mockFetchUser = jest.fn() +const mockRemoveFromCookie = jest.fn() +const mockSetStep = jest.fn() +const mockSettleAcceptedInviteAcquisition = jest.fn() +const mockCapture = jest.fn() + +jest.mock('@/context/authContext', () => ({ + useAuth: () => ({ + user: { user: { userId: 'user-1', email: 'member@example.com' } }, + fetchUser: mockFetchUser, + isFetchingUser: false, + logoutUser: jest.fn(), + }), +})) + +jest.mock('@/services/invites', () => ({ + invitesApi: { + acceptInvite: (...args: unknown[]) => mockAcceptInvite(...args), + validateInviteCode: jest.fn(), + getWaitlistQueuePosition: jest.fn(), + }, +})) +jest.mock('@/services/invite-acquisition', () => ({ + settleAcceptedInviteAcquisition: (...args: unknown[]) => mockSettleAcceptedInviteAcquisition(...args), +})) + +jest.mock('next/navigation', () => ({ useRouter: () => ({ push: jest.fn() }) })) +jest.mock('@tanstack/react-query', () => ({ + useQuery: () => ({ data: { success: true, position: 7 }, isLoading: false }), +})) +jest.mock('@/redux/hooks', () => ({ + useSetupStore: () => ({ inviteType: 'PAYMENT_LINK', inviteCode: '' }), +})) +jest.mock('@/hooks/useNotifications', () => ({ + useNotifications: () => ({ + requestPermission: jest.fn(), + afterPermissionAttempt: jest.fn(), + isPermissionGranted: true, + }), +})) +jest.mock('@/app/actions/users', () => ({ updateUserById: jest.fn() })) +jest.mock('nuqs', () => ({ + useQueryState: () => ['jail', mockSetStep], + parseAsStringEnum: () => ({ withDefault: () => ({}) }), +})) +jest.mock('@/utils/general.utils', () => ({ + getFromCookie: () => null, + removeFromCookie: (...args: unknown[]) => mockRemoveFromCookie(...args), + toInviteCode: (value: string) => value.trim().toLowerCase(), +})) +jest.mock('@/utils/format.utils', () => ({ isValidEmail: () => true })) +jest.mock('posthog-js', () => ({ capture: (...args: unknown[]) => mockCapture(...args) })) +jest.mock('@/assets/mascot', () => ({ + PeanutWavingHello: { src: '/waving.svg' }, + PeanutPointing: { src: '/pointing.svg' }, +})) +jest.mock('./InvitesPageLayout', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) +jest.mock('../Global/ValidatedInput', () => ({ + __esModule: true, + default: ({ + onUpdate, + }: { + onUpdate: (value: { value: string; isValid: boolean; isChanging: boolean }) => void + }) => ( + + ), +})) +jest.mock('@/components/0_Bruddle/Button', () => ({ + Button: ({ + children, + onClick, + disabled, + }: { + children: React.ReactNode + onClick?: () => void + disabled?: boolean + }) => ( + + ), +})) +jest.mock('../Global/ErrorAlert', () => ({ + __esModule: true, + default: ({ description }: { description: string }) =>
{description}
, +})) +jest.mock('../Global/PeanutLoading', () => ({ __esModule: true, default: () =>
Loading
})) +jest.mock('@/components/0_Bruddle/BaseInput', () => ({ BaseInput: () => })) + +describe('JoinWaitlistPage invite onboarding boundary', () => { + beforeEach(() => { + jest.clearAllMocks() + sessionStorage.clear() + mockSettleAcceptedInviteAcquisition.mockReturnValue({ destination: '/home', pending: [] }) + }) + + it.each([ + ['awarded', true], + ['already_owned', true], + ['inactive', false], + ['expired', false], + ['unknown', false], + ] as const)( + 'settles a terminal %s campaign-only response and refreshes only confirmed possession', + async (outcome, shouldRefresh) => { + mockAcceptInvite.mockResolvedValue({ + success: true, + attributionResolved: false, + onboardingResolved: false, + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + claims: [{ badgeCampaign: 'offramp', badgeCode: 'OFFRAMP_USER', outcome }], + }) + + render() + fireEvent.click(screen.getByRole('button', { name: 'Enter legacy code' })) + fireEvent.click(screen.getByRole('button', { name: 'Next' })) + + await waitFor(() => expect(mockAcceptInvite).toHaveBeenCalledWith('offramp', 'PAYMENT_LINK')) + expect(mockSettleAcceptedInviteAcquisition).toHaveBeenCalledWith( + expect.objectContaining({ campaignTag: 'offramp' }), + [expect.objectContaining({ badgeCampaign: 'offramp', badgeCode: 'OFFRAMP_USER', outcome })] + ) + expect(sessionStorage.getItem('showNoMoreJailModal')).toBeNull() + expect(mockRemoveFromCookie).toHaveBeenCalledWith('inviteCode') + if (shouldRefresh) expect(mockFetchUser).toHaveBeenCalledTimes(1) + else expect(mockFetchUser).not.toHaveBeenCalled() + expect(screen.queryByText('Something went wrong. Please try again or contact support.')).toBeNull() + expect(mockCapture).not.toHaveBeenCalledWith(ANALYTICS_EVENTS.INVITE_ACCEPTED, expect.anything()) + expect(mockCapture).not.toHaveBeenCalledWith(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, expect.anything()) + } + ) +}) diff --git a/src/components/Invites/JoinWaitlistPage.tsx b/src/components/Invites/JoinWaitlistPage.tsx index 5db6791e4f..8a2e7a6eaf 100644 --- a/src/components/Invites/JoinWaitlistPage.tsx +++ b/src/components/Invites/JoinWaitlistPage.tsx @@ -23,8 +23,11 @@ import { useTranslations } from 'next-intl' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { getFromCookie, removeFromCookie, toInviteCode } from '@/utils/general.utils' import { USERNAME_MIN_LENGTH } from '@/constants/general.consts' +import { settleAcceptedInviteAcquisition } from '@/services/invite-acquisition' +import { isConfirmedBadgeCampaignClaim } from '@/services/badge-campaigns' type WaitlistStep = 'email' | 'notifications' | 'jail' +type InviteAcceptanceOutcome = 'onboarding_resolved' | 'campaign_only' | 'failed' const nextStepAfterEmail = (isPermissionGranted: boolean): WaitlistStep => isPermissionGranted ? 'jail' : 'notifications' @@ -164,8 +167,12 @@ const JoinWaitlistPage = () => { setIsValidating(true) try { const res = await invitesApi.validateInviteCode(code) - posthog.capture(ANALYTICS_EVENTS.INVITE_CODE_VALIDATED, { valid: res.success, source: 'waitlist_page' }) - return res.success + const onboardingResolved = res.success && res.onboardingResolved + posthog.capture(ANALYTICS_EVENTS.INVITE_CODE_VALIDATED, { + valid: onboardingResolved, + source: 'waitlist_page', + }) + return onboardingResolved } catch (e) { posthog.capture(ANALYTICS_EVENTS.INVITE_CODE_VALIDATED, { valid: false, source: 'waitlist_page' }) throw e @@ -175,8 +182,11 @@ const JoinWaitlistPage = () => { } // Shared by the manual Next button and the automatic attempt below. - // Returns true on success (access flipped, user refetched). - const acceptInviteWithCode = async (code: string, source: 'waitlist_page' | 'waitlist_auto'): Promise => { + // Campaign-only compatibility can settle without granting onboarding. + const acceptInviteWithCode = async ( + code: string, + source: 'waitlist_page' | 'waitlist_auto' + ): Promise => { const res = await invitesApi.acceptInvite(code, inviteType) if (!res.success) { posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, { @@ -184,20 +194,40 @@ const JoinWaitlistPage = () => { error_message: 'API returned unsuccessful', source, }) - return false + return 'failed' + } + if (!res.onboardingResolved) { + if (!res.legacyAcquisition) { + posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, { + invite_code: code, + error_message: 'Campaign-only response omitted acquisition descriptor', + source, + }) + return 'failed' + } + + // The adapter has done all it can. Settle terminal claims and keep + // only retryable campaign state; never retry the non-invite code. + settleAcceptedInviteAcquisition(res.legacyAcquisition, res.claims) + removeFromCookie('inviteCode') + // Provenance is audit-only. Refresh every confirmed permanent award + // so any badge-owned access/reward projection is visible immediately; + // unavailable outcomes must not masquerade as a capability change. + if (res.claims.some(isConfirmedBadgeCampaignClaim)) await fetchUser() + return 'campaign_only' } posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPTED, { invite_code: code, source }) sessionStorage.setItem('showNoMoreJailModal', 'true') removeFromCookie('inviteCode') await fetchUser() - return true + return 'onboarding_resolved' } const handleAcceptInvite = async () => { setIsAccepting(true) try { - const success = await acceptInviteWithCode(inviteCode, 'waitlist_page') - if (!success) { + const outcome = await acceptInviteWithCode(inviteCode, 'waitlist_page') + if (outcome === 'failed') { setError(tCommon('genericError')) } } catch { diff --git a/src/components/Invites/badge-campaign-context.test.ts b/src/components/Invites/badge-campaign-context.test.ts new file mode 100644 index 0000000000..1338db9c3a --- /dev/null +++ b/src/components/Invites/badge-campaign-context.test.ts @@ -0,0 +1,254 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { + MAX_BADGE_CAMPAIGN_IDENTITY_LENGTH, + MAX_RAW_BADGE_CAMPAIGN_LENGTH, + MAX_BADGE_CAMPAIGNS, + LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE, + LEGACY_PENDING_BADGE_CAMPAIGNS_V2_COOKIE, + badgeCampaignForLegacyWire, + badgeCampaignsFromSearchParams, + clearPendingBadgeCampaigns, + getPendingBadgeCampaigns, + parsePendingBadgeCampaigns, + queuePendingBadgeCampaigns, + savePendingBadgeCampaigns, +} from './badge-campaign-context' +import { getFromCookie } from '@/utils/general.utils' +import { removeFromCookie, saveToCookie } from '@/utils/general.utils' + +function filesUnder(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + return entry.isDirectory() ? filesUnder(path) : [path] + }) +} + +describe('badge campaign identity transport', () => { + it('preserves repeated canonical badge campaign identities and their first-seen spelling', () => { + const params = new URLSearchParams( + 'badge_campaign=%20nita%20&badge_campaign=NITA&badge_campaign=Creator%2FSummer' + ) + + expect(badgeCampaignsFromSearchParams(params)).toEqual(['nita', 'Creator/Summer']) + }) + + it('preserves published legacy campaign and campaignTag spellings', () => { + const params = new URLSearchParams('campaignTag=NITA&campaign=nita&campaign=second') + + expect(badgeCampaignsFromSearchParams(params)).toEqual(['NITA', 'second']) + }) + + it('enforces the backend tag count and length bounds', () => { + const params = new URLSearchParams() + for (let index = 0; index < MAX_BADGE_CAMPAIGNS + 3; index += 1) { + params.append('badge_campaign', `tag-${index}`) + } + params.append('badge_campaign', 'x'.repeat(MAX_RAW_BADGE_CAMPAIGN_LENGTH + 1)) + + const badgeCampaigns = badgeCampaignsFromSearchParams(params) + expect(badgeCampaigns).toHaveLength(MAX_BADGE_CAMPAIGNS) + expect(badgeCampaigns).toEqual(Array.from({ length: MAX_BADGE_CAMPAIGNS }, (_, index) => `tag-${index}`)) + }) + + it('drops an overlong identity even when the count limit is not reached', () => { + const params = new URLSearchParams() + params.append('badge_campaign', 'x'.repeat(MAX_RAW_BADGE_CAMPAIGN_LENGTH + 1)) + params.append('badge_campaign', ' valid ') + + expect(badgeCampaignsFromSearchParams(params)).toEqual(['valid']) + }) + + it('gives canonical badge campaigns precedence over legacy and analytics parameters', () => { + const params = new URLSearchParams( + 'utm_campaign=summer-analytics&campaign=legacy&campaignTag=older&badge_campaign=nita&badge_campaign=second' + ) + + expect(badgeCampaignsFromSearchParams(params)).toEqual(['nita', 'second']) + }) + + it.each([ + `badge_campaign=&campaign=legacy&utm_campaign=offramp`, + `badge_campaign=${'x'.repeat(MAX_RAW_BADGE_CAMPAIGN_LENGTH + 1)}&campaign=legacy&utm_campaign=offramp`, + ])('does not let a lower-priority transport override a rejected canonical campaign: %s', (query) => { + expect(badgeCampaignsFromSearchParams(new URLSearchParams(query))).toEqual([]) + }) + + it('gives published legacy explicit parameters precedence over UTM analytics', () => { + const params = new URLSearchParams('utm_campaign=offramp&campaignTag=legacy-tag&campaign=second') + + expect(badgeCampaignsFromSearchParams(params)).toEqual(['legacy-tag', 'second']) + }) + + it.each([ + `campaign=&utm_campaign=offramp`, + `campaignTag=${'x'.repeat(MAX_RAW_BADGE_CAMPAIGN_LENGTH + 1)}&utm_campaign=offramp`, + ])('does not let UTM override a rejected legacy campaign: %s', (query) => { + expect(badgeCampaignsFromSearchParams(new URLSearchParams(query))).toEqual([]) + }) + + it('source-qualifies arbitrary UTM values without deciding whether they award', () => { + expect(badgeCampaignsFromSearchParams(new URLSearchParams('utm_campaign=Summer-Analytics'))).toEqual([ + 'utm:Summer-Analytics', + ]) + expect(badgeCampaignsFromSearchParams(new URLSearchParams('badge_campaign=arbitrum'))).toEqual(['arbitrum']) + expect(badgeCampaignsFromSearchParams(new URLSearchParams('utm_campaign=arbitrum'))).toEqual(['utm:arbitrum']) + expect(badgeCampaignsFromSearchParams(new URLSearchParams('badge_campaign=offramp'))).toEqual(['offramp']) + expect(badgeCampaignsFromSearchParams(new URLSearchParams('utm_campaign=offramp'))).toEqual(['utm:offramp']) + expect(badgeCampaignsFromSearchParams(new URLSearchParams('badge_campaign=utm:offramp'))).toEqual([ + 'utm:offramp', + ]) + expect(badgeCampaignsFromSearchParams(new URLSearchParams('utm_campaign=utm:offramp'))).toEqual([ + 'utm:utm:offramp', + ]) + expect(badgeCampaignsFromSearchParams(new URLSearchParams('badge_campaign=IRL_NOMADS'))).toEqual(['IRL_NOMADS']) + expect(badgeCampaignsFromSearchParams(new URLSearchParams('badge_campaign=irl_nomads'))).toEqual(['irl_nomads']) + expect(badgeCampaignsFromSearchParams(new URLSearchParams('utm_campaign=irl-nomads'))).toEqual([ + 'utm:irl-nomads', + ]) + }) + + it('source-qualifies every published content UTM without a frontend allowlist', () => { + const publishedValues = new Set() + for (const file of filesUnder(join(process.cwd(), 'src/content'))) { + const source = readFileSync(file, 'utf8') + for (const match of source.matchAll(/utm_campaign=([^&"'\s<>)]*)/g)) { + if (match[1]) publishedValues.add(match[1]) + } + } + + // Guard the corpus scan itself with the known collision that motivated + // source qualification, then apply the generic transport invariant to + // every current and future literal value discovered above. + expect(publishedValues.has('arbitrum')).toBe(true) + expect(publishedValues.size).toBeGreaterThan(0) + for (const rawValue of publishedValues) { + expect(badgeCampaignsFromSearchParams(new URLSearchParams({ utm_campaign: rawValue }))).toEqual([ + `utm:${rawValue}`, + ]) + } + }) + + it('accepts a 64-character raw UTM value within the 68-character qualified transport bound', () => { + const maximumRaw = 'x'.repeat(MAX_RAW_BADGE_CAMPAIGN_LENGTH) + + const badgeCampaigns = badgeCampaignsFromSearchParams(new URLSearchParams(`utm_campaign=${maximumRaw}`)) + + expect(badgeCampaigns).toEqual([`utm:${maximumRaw}`]) + expect(badgeCampaigns[0]).toHaveLength(MAX_BADGE_CAMPAIGN_IDENTITY_LENGTH) + expect( + badgeCampaignsFromSearchParams( + new URLSearchParams(`utm_campaign=${'x'.repeat(MAX_RAW_BADGE_CAMPAIGN_LENGTH + 1)}`) + ) + ).toEqual([]) + }) + + it('never infers a campaign from code-only creator attribution', () => { + expect(badgeCampaignsFromSearchParams(new URLSearchParams('code=juanacervio'))).toEqual([]) + }) + + it('keeps inviter attribution and campaign acquisition independent', () => { + const params = new URLSearchParams('code=juanacervio&utm_campaign=summer-analytics&badge_campaign=nita') + + expect(params.get('code')).toBe('juanacervio') + expect(params.get('utm_campaign')).toBe('summer-analytics') + expect(badgeCampaignsFromSearchParams(params)).toEqual(['nita']) + }) + + it('adapts canonical-first URL intent to published singular campaignTag wires', () => { + expect( + badgeCampaignForLegacyWire( + new URLSearchParams( + 'utm_campaign=analytics&campaign=legacy&badge_campaign=canonical-first&badge_campaign=canonical-second' + ) + ) + ).toBe('canonical-first') + expect(badgeCampaignForLegacyWire(new URLSearchParams('campaignTag=published-legacy'))).toBe('published-legacy') + expect(badgeCampaignForLegacyWire(new URLSearchParams('utm_campaign=historic-alias'))).toBe( + 'utm:historic-alias' + ) + expect(badgeCampaignForLegacyWire(new URLSearchParams('code=creator'))).toBeUndefined() + }) + + it('round-trips array cookies losslessly and reads a legacy scalar cookie', () => { + expect(parsePendingBadgeCampaigns(['Creator/Summer', 'Tag,With,Commas'])).toEqual([ + 'Creator/Summer', + 'Tag,With,Commas', + ]) + expect(parsePendingBadgeCampaigns('legacy-tag')).toEqual(['legacy-tag']) + }) + + it('keeps the legacy key scalar while v2 stores the lossless queue', () => { + savePendingBadgeCampaigns(['single']) + expect(getFromCookie(LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE)).toBe('single') + expect(getFromCookie(LEGACY_PENDING_BADGE_CAMPAIGNS_V2_COOKIE)).toEqual(['single']) + expect(getPendingBadgeCampaigns()).toEqual(['single']) + + savePendingBadgeCampaigns(['first', 'second']) + expect(getFromCookie(LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE)).toBe('second') + expect(getFromCookie(LEGACY_PENDING_BADGE_CAMPAIGNS_V2_COOKIE)).toEqual(['first', 'second']) + expect(getPendingBadgeCampaigns()).toEqual(['first', 'second']) + clearPendingBadgeCampaigns() + }) + + it('migrates the previous array-shaped legacy cookie into v2 and scalarizes the old key', () => { + saveToCookie(LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE, ['legacy-first', 'legacy-newest']) + + expect(getPendingBadgeCampaigns()).toEqual(['legacy-first', 'legacy-newest']) + expect(getFromCookie(LEGACY_PENDING_BADGE_CAMPAIGNS_V2_COOKIE)).toEqual(['legacy-first', 'legacy-newest']) + expect(getFromCookie(LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE)).toBe('legacy-newest') + + clearPendingBadgeCampaigns() + }) + + it('merges a newer old-bundle scalar without letting old clearing erase v2', () => { + savePendingBadgeCampaigns(['v2-first', 'v2-second']) + + // An older open bundle knows only campaignTag and writes a new intent. + saveToCookie(LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE, 'old-bundle-newest') + expect(getPendingBadgeCampaigns()).toEqual(['v2-first', 'v2-second', 'old-bundle-newest']) + expect(getFromCookie(LEGACY_PENDING_BADGE_CAMPAIGNS_V2_COOKIE)).toEqual([ + 'v2-first', + 'v2-second', + 'old-bundle-newest', + ]) + + // Its terminal cleanup removes only the scalar it understands. + removeFromCookie(LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE) + expect(getPendingBadgeCampaigns()).toEqual(['v2-first', 'v2-second', 'old-bundle-newest']) + + clearPendingBadgeCampaigns() + expect(getFromCookie(LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE)).toBeNull() + expect(getFromCookie(LEGACY_PENDING_BADGE_CAMPAIGNS_V2_COOKIE)).toBeNull() + }) + + it('keeps a newly clicked campaign when a full retry queue must evict an old entry', () => { + const oldCampaigns = Array.from({ length: MAX_BADGE_CAMPAIGNS }, (_, index) => `old-${index}`) + savePendingBadgeCampaigns(oldCampaigns) + + expect(queuePendingBadgeCampaigns(['new-live-campaign'])).toEqual([ + ...oldCampaigns.slice(1), + 'new-live-campaign', + ]) + expect(getPendingBadgeCampaigns()).toEqual([...oldCampaigns.slice(1), 'new-live-campaign']) + + clearPendingBadgeCampaigns() + }) + + it('clears bearer acquisition intent before an explicit account switch', () => { + savePendingBadgeCampaigns(['first-account-retry'], 30) + expect(getPendingBadgeCampaigns()).toEqual(['first-account-retry']) + + clearPendingBadgeCampaigns() + + expect(getPendingBadgeCampaigns()).toEqual([]) + }) + + it('treats a lost campaign query as no acquisition intent, never as attribution', () => { + const before = badgeCampaignsFromSearchParams(new URLSearchParams('code=juanacervio&badge_campaign=nita')) + const after = badgeCampaignsFromSearchParams(new URLSearchParams('code=juanacervio')) + + expect(before).toEqual(['nita']) + expect(after).toEqual([]) + }) +}) diff --git a/src/components/Invites/badge-campaign-context.ts b/src/components/Invites/badge-campaign-context.ts new file mode 100644 index 0000000000..a0344ad304 --- /dev/null +++ b/src/components/Invites/badge-campaign-context.ts @@ -0,0 +1,235 @@ +import { + getFromCookie, + getFromLocalStorage, + removeFromCookie, + saveToCookie, + saveToLocalStorage, +} from '@/utils/general.utils' + +/** + * Badge campaigns are opaque backend-owned identities. The UI may transport them, + * but it must never translate them into badge codes or derive them from an + * inviter. In particular, `?code=juanacervio` carries no NITA campaign unless + * `?badge_campaign=nita` is also present. + */ +/** Published storage key retained for old bundles. Do not rename the string value. */ +export const LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE = 'campaignTag' +/** Published lossless queue key retained across rolling deploys. */ +export const LEGACY_PENDING_BADGE_CAMPAIGNS_V2_COOKIE = 'campaignTagsV2' +/** Persisted account-boundary epoch; the string value is compatibility state. */ +export const PENDING_BADGE_CAMPAIGN_INTENT_EPOCH_STORAGE_KEY = 'campaignIntentEpoch' +export const BADGE_CAMPAIGN_QUERY_PARAM = 'badge_campaign' +export const MAX_BADGE_CAMPAIGNS = 20 +export const LEGACY_UTM_BADGE_CAMPAIGN_PREFIX = 'utm:' +/** Maximum raw length for explicit and UTM values before source qualification. */ +export const MAX_RAW_BADGE_CAMPAIGN_LENGTH = 64 +/** `utm:` plus the maximum 64-character raw UTM value. */ +export const MAX_BADGE_CAMPAIGN_IDENTITY_LENGTH = + MAX_RAW_BADGE_CAMPAIGN_LENGTH + LEGACY_UTM_BADGE_CAMPAIGN_PREFIX.length + +// Explicit logout invalidates every settlement that began under the prior +// account. The durable epoch is shared by tabs; the process counter preserves +// the same guarantee if localStorage is unavailable in a restricted browser. +let processBadgeCampaignIntentGeneration = 0 + +function getDurableBadgeCampaignIntentEpoch(): number { + const value = getFromLocalStorage(PENDING_BADGE_CAMPAIGN_INTENT_EPOCH_STORAGE_KEY) + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0 +} + +export type BadgeCampaignSearchParams = Pick + +export function sanitizeBadgeCampaignIdentities( + values: readonly unknown[], + maxLength = MAX_BADGE_CAMPAIGN_IDENTITY_LENGTH +): string[] { + const badgeCampaigns: string[] = [] + const seen = new Set() + + for (const value of values) { + if (typeof value !== 'string') continue + const badgeCampaign = value.trim() + const normalized = badgeCampaign.toLowerCase() + if (badgeCampaign.length === 0 || badgeCampaign.length > maxLength || seen.has(normalized)) { + continue + } + seen.add(normalized) + badgeCampaigns.push(badgeCampaign) + if (badgeCampaigns.length === MAX_BADGE_CAMPAIGNS) break + } + + return badgeCampaigns +} + +/** + * Canonical badge campaign parameters win as a group over published legacy + * spellings, which in turn win over analytics-shaped UTM parameters. Presence + * establishes precedence even if every value in that group is malformed: a + * lower-priority parameter must never replace a rejected acquisition intent. + * + * Repeated values remain repeated identities (apart from case-insensitive + * duplicate removal), and casing/punctuation are preserved for the backend. + * `code` is deliberately not accepted by this function. + */ +/** + * Published links stack campaigns either as repeated params or as one + * comma-separated value (`?campaign=a,b`). Both shapes must survive; dropping + * the CSV form would silently award only the first badge. Splitting happens + * HERE and not in sanitize, because a stored queue identity may itself contain + * a comma and must round-trip unchanged. + */ +function splitUrlCampaignValues(values: readonly string[]): string[] { + return values.flatMap((value) => value.split(',')) +} + +function badgeCampaignsFromSearchParamsWithExplicitBound( + searchParams: BadgeCampaignSearchParams, + explicitMaxLength: number, + splitCommas = true +): string[] { + const split = splitCommas ? splitUrlCampaignValues : (values: readonly string[]) => [...values] + const canonicalValues = split(searchParams.getAll(BADGE_CAMPAIGN_QUERY_PARAM)) + if (canonicalValues.length > 0) { + return sanitizeBadgeCampaignIdentities(canonicalValues, explicitMaxLength) + } + + const legacyExplicitValues = split( + [...searchParams.entries()] + .filter(([key]) => key === 'campaign' || key === 'campaignTag') + .map(([, value]) => value) + ) + if (legacyExplicitValues.length > 0) { + return sanitizeBadgeCampaignIdentities(legacyExplicitValues, explicitMaxLength) + } + + // utm_campaign is analytics-only. It is still source-qualified and forwarded + // so the backend can honour its own dated compatibility aliases (today: only + // `offramp`, which expires 2026-09-07). Everything else resolves `unknown`, + // so a marketing link can no longer mint a badge as a side effect. + const utmValues = sanitizeBadgeCampaignIdentities( + split(searchParams.getAll('utm_campaign')), + MAX_RAW_BADGE_CAMPAIGN_LENGTH + ) + return sanitizeBadgeCampaignIdentities(utmValues.map((value) => `${LEGACY_UTM_BADGE_CAMPAIGN_PREFIX}${value}`)) +} + +/** Parse raw public URL values. Canonical slugs and legacy raw aliases are at most 64 characters. */ +export function badgeCampaignsFromSearchParams(searchParams: BadgeCampaignSearchParams): string[] { + return badgeCampaignsFromSearchParamsWithExplicitBound(searchParams, MAX_RAW_BADGE_CAMPAIGN_LENGTH) +} + +/** + * Deferred install payloads carry already-qualified queue identities, including + * `utm:` plus a 64-character raw value. Keep the same precedence rules without + * truncating that 68-character internal wire representation. + */ +export function badgeCampaignIdentitiesFromDeferredSearchParams(searchParams: BadgeCampaignSearchParams): string[] { + // No comma splitting: these are already-qualified queue identities, not raw + // public URL input, and one may legitimately contain a comma. + return badgeCampaignsFromSearchParamsWithExplicitBound(searchParams, MAX_BADGE_CAMPAIGN_IDENTITY_LENGTH, false) +} + +/** + * Published send-link claim endpoints still expose a singular `campaignTag` + * request field. Resolve their URL input through the shared namespace rules and + * forward the first identity from the winning group. New typed badge-claim + * callers must use the complete array returned above instead. + */ +export function badgeCampaignForLegacyWire(searchParams: BadgeCampaignSearchParams): string | undefined { + return badgeCampaignsFromSearchParams(searchParams)[0] +} + +/** Accept both the new array cookie and the legacy single-string cookie. */ +export function parsePendingBadgeCampaigns(value: unknown): string[] { + if (Array.isArray(value)) return sanitizeBadgeCampaignIdentities(value) + return sanitizeBadgeCampaignIdentities([value]) +} + +function mergeBadgeCampaignQueues(existing: readonly string[], incoming: readonly string[]): string[] { + const merged = [...sanitizeBadgeCampaignIdentities(existing)] + for (const badgeCampaign of sanitizeBadgeCampaignIdentities(incoming)) { + const existingIndex = merged.findIndex((candidate) => candidate.toLowerCase() === badgeCampaign.toLowerCase()) + if (existingIndex >= 0) merged.splice(existingIndex, 1) + merged.push(badgeCampaign) + if (merged.length > MAX_BADGE_CAMPAIGNS) merged.shift() + } + return merged +} + +function sameBadgeCampaignQueue(left: readonly string[], right: readonly string[]): boolean { + return ( + left.length === right.length && + left.every((badgeCampaign, index) => badgeCampaign.toLowerCase() === right[index]?.toLowerCase()) + ) +} + +export function getPendingBadgeCampaigns(): string[] { + const rawV2 = getFromCookie(LEGACY_PENDING_BADGE_CAMPAIGNS_V2_COOKIE) + const rawLegacy = getFromCookie(LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE) + const v2 = parsePendingBadgeCampaigns(rawV2) + const legacy = parsePendingBadgeCampaigns(rawLegacy) + const merged = mergeBadgeCampaignQueues(v2, legacy) + + // Upgrade a legacy scalar/array or a newer value written by an old bundle + // into the lossless queue. The old key remains scalar-only from this point + // forward. A missing legacy scalar is deliberately not reconstructed: an + // old bundle may have cleared it, but that cannot erase the v2 queue. + if (legacy.length > 0 && (!sameBadgeCampaignQueue(v2, merged) || Array.isArray(rawLegacy))) { + saveToCookie(LEGACY_PENDING_BADGE_CAMPAIGNS_V2_COOKIE, merged, 30) + if (Array.isArray(rawLegacy)) { + saveToCookie(LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE, merged[merged.length - 1], 30) + } + } + + return merged +} + +export function getPendingBadgeCampaignIntentGeneration(): string { + return `${processBadgeCampaignIntentGeneration}:${getDurableBadgeCampaignIntentEpoch()}` +} + +export function savePendingBadgeCampaigns(badgeCampaigns: readonly string[], expiryDays?: number): void { + const pending = sanitizeBadgeCampaignIdentities(badgeCampaigns) + if (pending.length === 0) { + removeFromCookie(LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE) + removeFromCookie(LEGACY_PENDING_BADGE_CAMPAIGNS_V2_COOKIE) + return + } + // V2 owns the lossless queue. The unversioned key is a compatibility mirror + // for older bundles and must always remain scalar; prefer the newest intent. + saveToCookie(LEGACY_PENDING_BADGE_CAMPAIGNS_V2_COOKIE, pending, expiryDays) + saveToCookie(LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE, pending[pending.length - 1], expiryDays) +} + +/** Add acquisition intent without dropping an earlier retryable campaign. */ +export function queuePendingBadgeCampaigns(badgeCampaigns: readonly string[], expiryDays?: number): string[] { + const incoming = sanitizeBadgeCampaignIdentities(badgeCampaigns) + if (incoming.length === 0) return getPendingBadgeCampaigns() + + // A newly clicked acquisition must not disappear behind a cookie already at + // the contract cap. Move incoming identities to the newest end, evicting + // only the oldest retryable entries when capacity is exhausted. + const incomingKeys = new Set(incoming.map((badgeCampaign) => badgeCampaign.toLowerCase())) + const existing = getPendingBadgeCampaigns().filter( + (badgeCampaign) => !incomingKeys.has(badgeCampaign.toLowerCase()) + ) + const existingCapacity = MAX_BADGE_CAMPAIGNS - incoming.length + const retainedExisting = existingCapacity > 0 ? existing.slice(-existingCapacity) : [] + const queued = [...retainedExisting, ...incoming] + savePendingBadgeCampaigns(queued, expiryDays) + return queued +} + +/** + * Badge campaign identities are bearer acquisition intents, not user-owned preferences. + * Clear them on an intentional account switch so the next account cannot + * inherit an award. Do not call this for passive auth expiry or network loss; + * those cases retain the original user's safe retry path. + */ +export function clearPendingBadgeCampaigns(): void { + processBadgeCampaignIntentGeneration += 1 + const durableEpoch = getDurableBadgeCampaignIntentEpoch() + saveToLocalStorage(PENDING_BADGE_CAMPAIGN_INTENT_EPOCH_STORAGE_KEY, Math.max(Date.now(), durableEpoch + 1)) + removeFromCookie(LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE) + removeFromCookie(LEGACY_PENDING_BADGE_CAMPAIGNS_V2_COOKIE) +} diff --git a/src/components/Invites/campaign-maps.test.ts b/src/components/Invites/campaign-maps.test.ts deleted file mode 100644 index b51480e969..0000000000 --- a/src/components/Invites/campaign-maps.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { BADGES } from '@/components/Badges/badge.utils' -import { - INVITE_CODE_TO_CAMPAIGN_MAP, - UTM_CAMPAIGN_TO_BADGE_MAP, - WAITLIST_SKIP_CAMPAIGNS, - BARE_VANITY_CAMPAIGNS, - OFFRAMP_BADGE_CODE, - classifyBareCampaigns, - resolveCampaigns, -} from './campaign-maps' - -// Regression guard. The /invite flow resolves an inbound invite code / utm_campaign -// to one of these badge codes, carries it through signup, and the UI renders -// BADGES[code] for the awarded badge. If a code here has no BADGES entry, getBadgeIcon -// falls back to the Peanutman logo and getBadgeDisplayName returns the raw backend -// name — a silent visual regression with no conflict, no type error, no runtime throw. -// -// This is exactly how TOKEN_NATION_SP_2026 + ETHFLORIPA_HUB fell out of dev (the -// May 29 event-badge hotfixes were written against the old parallel-map shape and -// their additions evaporated when merged across the May 23 single-BADGES refactor), -// and later out of main (regressed on release, fixed by 32699f171). This test fails -// the moment a campaign code points at a badge the FE can't render. -const badgeCodes = new Set(Object.keys(BADGES)) - -describe('campaign maps reference real BADGES codes', () => { - it.each(Object.entries(UTM_CAMPAIGN_TO_BADGE_MAP))('utm_campaign "%s" → "%s" exists in BADGES', (_utm, code) => { - expect(badgeCodes).toContain(code) - }) - - it.each(Object.entries(INVITE_CODE_TO_CAMPAIGN_MAP))( - 'invite code "%s" → "%s" exists in BADGES', - (_invite, code) => { - expect(badgeCodes).toContain(code) - } - ) -}) - -describe('classifyBareCampaigns', () => { - // A bare campaign (no invite code) must be claimable, or /invite dead-ends at - // the "Invalid Invite Code" screen. This is the bug that left touched_grass - // unclaimable — it was never registered as a bare campaign. - it.each([...WAITLIST_SKIP_CAMPAIGNS, ...BARE_VANITY_CAMPAIGNS])( - 'campaign "%s" is bare-claimable with no invite code', - (campaign) => { - expect(classifyBareCampaigns([campaign], undefined).isBareClaimCampaign).toBe(true) - } - ) - - it('classifies waitlist-skip vs vanity, case-insensitively', () => { - // event_alumni skips the card waitlist → skip copy - expect(classifyBareCampaigns(['EVENT_ALUMNI'], undefined)).toEqual({ - isBareClaimCampaign: true, - isWaitlistSkip: true, - }) - // touched_grass is a vanity badge → claimable but NOT a waitlist skip - expect(classifyBareCampaigns(['TOUCHED_GRASS'], undefined)).toEqual({ - isBareClaimCampaign: true, - isWaitlistSkip: false, - }) - }) - - // Regression: both country-launch cohorts ship BARE links with no inviter. - // Mapping them in UTM_CAMPAIGN_TO_BADGE_MAP alone is not enough — without a - // bare-campaign registration the link dead-ends on "Invalid Invite Code". - it.each(['naija', 'terere'])('country-launch campaign "%s" is a bare claimable skip', (c) => { - expect(classifyBareCampaigns([c], undefined)).toEqual({ - isBareClaimCampaign: true, - isWaitlistSkip: true, - }) - expect(classifyBareCampaigns([c.toUpperCase()], undefined).isBareClaimCampaign).toBe(true) - }) - - it('only waitlist-skip campaigns promise a card-waitlist skip', () => { - for (const c of BARE_VANITY_CAMPAIGNS) { - expect(classifyBareCampaigns([c], undefined).isWaitlistSkip).toBe(false) - } - }) - - it('a stacked link is claimable and shows skip copy when ANY campaign qualifies', () => { - expect(classifyBareCampaigns(['TOUCHED_GRASS', 'skip'], undefined)).toEqual({ - isBareClaimCampaign: true, - isWaitlistSkip: true, - }) - // vanity + unknown → claimable, but no skip promise - expect(classifyBareCampaigns(['TOUCHED_GRASS', 'SOMETHING_ELSE'], undefined)).toEqual({ - isBareClaimCampaign: true, - isWaitlistSkip: false, - }) - }) - - it('an invite code defers to the invite flow (not bare-claimable)', () => { - expect(classifyBareCampaigns(['TOUCHED_GRASS'], 'somecode')).toEqual({ - isBareClaimCampaign: false, - isWaitlistSkip: false, - }) - expect(classifyBareCampaigns(['TOUCHED_GRASS', 'skip'], 'somecode')).toEqual({ - isBareClaimCampaign: false, - isWaitlistSkip: false, - }) - }) - - it('an unrelated or missing campaign is not bare-claimable', () => { - expect(classifyBareCampaigns([], undefined).isBareClaimCampaign).toBe(false) - expect(classifyBareCampaigns(['FOUNDER_HOUSE'], undefined).isBareClaimCampaign).toBe(false) - }) -}) - -describe('resolveCampaigns', () => { - // The offramp migration regression this guards: ?campaign=offramp used to - // reach /badge/award as the raw string 'offramp' and 400 (the backend matches - // badge codes). The explicit param must resolve through the UTM map first. - it('resolves a lowercase human-facing tag in the explicit param (?campaign=offramp)', () => { - expect(resolveCampaigns(['offramp'], undefined, undefined)).toEqual([OFFRAMP_BADGE_CODE]) - expect(resolveCampaigns(['OFFRAMP'], undefined, undefined)).toEqual([OFFRAMP_BADGE_CODE]) - }) - - it('passes an unmapped explicit param through raw (?campaign=OFFRAMP_USER)', () => { - expect(resolveCampaigns(['OFFRAMP_USER'], undefined, undefined)).toEqual(['OFFRAMP_USER']) - expect(resolveCampaigns(['FOUNDER_HOUSE'], undefined, undefined)).toEqual(['FOUNDER_HOUSE']) - }) - - it('documents that ?campaign= behaves exactly like ?utm_campaign=', () => { - for (const [utmKey, badgeCode] of Object.entries(UTM_CAMPAIGN_TO_BADGE_MAP)) { - expect(resolveCampaigns([utmKey], undefined, undefined)).toEqual([badgeCode]) - expect(resolveCampaigns([], undefined, utmKey)).toEqual([badgeCode]) - } - }) - - // Stacking: every source contributes — repeated params, comma-separated - // values, a mapped invite code, and a mapped utm_campaign all UNION. - it('stacks repeated params', () => { - expect(resolveCampaigns(['skip', 'touched-grass'], undefined, undefined)).toEqual(['skip', 'TOUCHED_GRASS']) - }) - - it('stacks comma-separated values in one param', () => { - expect(resolveCampaigns(['skip,touched-grass'], undefined, undefined)).toEqual(['skip', 'TOUCHED_GRASS']) - // whitespace + empty segments are tolerated - expect(resolveCampaigns([' skip , ,offramp '], undefined, undefined)).toEqual(['skip', OFFRAMP_BADGE_CODE]) - }) - - it('unions explicit params with a mapped invite code and a mapped utm_campaign', () => { - expect(resolveCampaigns(['skip'], 'alumni', 'touched-grass')).toEqual(['skip', 'EVENT_ALUMNI', 'TOUCHED_GRASS']) - }) - - it('dedupes case-insensitively across sources (first occurrence wins)', () => { - // ?campaign=offramp and ?code=offramp resolve to the same badge → once - expect(resolveCampaigns(['offramp'], 'offramp', 'offramp')).toEqual([OFFRAMP_BADGE_CODE]) - expect(resolveCampaigns(['skip', 'SKIP'], undefined, undefined)).toEqual(['skip']) - }) - - it('an unmapped invite code or utm_campaign contributes nothing', () => { - expect(resolveCampaigns([], 'not-a-special-code', undefined)).toEqual([]) - expect(resolveCampaigns([], undefined, 'ordinary-marketing-utm')).toEqual([]) - expect(resolveCampaigns([], undefined, undefined)).toEqual([]) - }) - - // The exact URL shape handed to a paid creator: a personal invite code for - // the referral plus the campaign tag for the badge. The personal code is not - // in INVITE_CODE_TO_CAMPAIGN_MAP, so the tag has to carry the badge alone. - it('resolves a creator link that pairs a personal invite code with a campaign tag', () => { - expect(resolveCampaigns(['nita'], 'somepersonalcode', undefined)).toEqual(['NITA']) - }) -}) diff --git a/src/components/Invites/campaign-maps.ts b/src/components/Invites/campaign-maps.ts deleted file mode 100644 index 8d13651d69..0000000000 --- a/src/components/Invites/campaign-maps.ts +++ /dev/null @@ -1,147 +0,0 @@ -// Inbound campaign-resolution maps for /invite. Pure data, kept out of the -// InvitesPage client component so they can be unit-tested in isolation: every -// value here is a badge code, and if it isn't present in BADGES the awarded -// badge silently renders the Peanutman fallback + raw backend name (the -// parallel-maps→single-record regression). campaign-maps.test.ts guards that. - -// offramp.xyz → Peanut migration badge. Single FE source of truth for the code — -// the add-money entry gate (AddMoneyMethodSelection) and both maps below key on -// it. Mirrors peanut-api-ts BADGE_CODES.OFFRAMP_USER. -export const OFFRAMP_BADGE_CODE = 'OFFRAMP_USER' - -// mapping of special invite codes to their campaign tags -// when these invite codes are used, the corresponding campaign tag is automatically applied -export const INVITE_CODE_TO_CAMPAIGN_MAP: Record = { - arbiverseinvitesyou: 'ARBIVERSE_DEVCONNECT_BA_2025', - squirrelinvitesyou: 'ARBIVERSE_DEVCONNECT_BA_2025', // temporary: maps to arbiverse until 12pm noon tomorrow - founderhaus: 'FOUNDER_HOUSE', - alumni: 'EVENT_ALUMNI', - touched_grass: 'TOUCHED_GRASS', - offramp: OFFRAMP_BADGE_CODE, - irl_nomads: 'IRL_NOMADS', - survivor: 'SUPPORT_SURVIVOR', - notsoshhh: 'NOT_SO_SHHHH', - festajunina: 'FESTA_JUNINA_2026', - cardalpha: 'CARD_ALPHA', - psyops: 'PSYOPS_DIVISION', - founding: 'FOUNDING_PIONEER', - manicero: 'MANICERO', -} - -// Map inbound `utm_campaign` values to the badge codes the backend whitelists. -// Lets marketing/event links use a single UTM-shaped URL — PostHog auto-captures -// utm_* on $pageview so the same string also flows into the analytics funnel. -// Backend whitelist lives in peanut-api-ts/src/routes/badge.ts + invite.ts; keep -// in sync when adding a new entry here. -export const UTM_CAMPAIGN_TO_BADGE_MAP: Record = { - 'token-nation-2026': 'TOKEN_NATION_SP_2026', - ethfloripa: 'ETHFLORIPA_HUB', - alumni: 'EVENT_ALUMNI', - 'touched-grass': 'TOUCHED_GRASS', - offramp: OFFRAMP_BADGE_CODE, - 'festa-junina': 'FESTA_JUNINA_2026', - 'card-alpha': 'CARD_ALPHA', - 'irl-nomads': 'IRL_NOMADS', - manicero: 'MANICERO', - // Creator collab. Nita's link is /invite?code=&campaign=nita, - // so the tag arrives in the explicit param — source 1 below maps it here. Her - // own invite code stays personal, so there is no INVITE_CODE_TO_CAMPAIGN_MAP entry. - nita: 'NITA', - // Nigeria launch cohort. Arrives as ?campaign=naija; there is no - // INVITE_CODE_TO_CAMPAIGN_MAP entry because no invite code `naija` exists. - naija: 'NAIJA', - // Paraguay launch cohort. Same shape as naija above. - terere: 'TERERE', -} - -// Resolve the effective campaigns (badge codes, or raw passthrough tags) from -// the three places they can arrive on /invite. Campaigns STACK — a link carrying -// several (repeated ?campaign= params, comma-separated values, an invite code -// that maps to a badge, plus a mapped utm_campaign) resolves to the union, and -// the claim flow awards every one. Sources, in output order: -// 1. explicit ?campaign= / ?campaignTag= — each mapped through the UTM map so -// a human-facing lowercase token (?campaign=offramp) resolves to its badge -// code instead of reaching /badge/award raw and 400ing; unmapped values -// pass through unchanged (?campaign=OFFRAMP_USER still works). -// NOTE: this deliberately makes ?campaign= behave exactly like -// ?utm_campaign= — users and partners can't be expected to know the -// difference between the two param spellings. -// 2. a mapped special invite code (?code=offramp). -// 3. ?utm_campaign= — only badge-mapped values contribute; ordinary marketing -// UTMs resolve to nothing. -// Deduped case-insensitively on the resolved value, first occurrence wins. -export function resolveCampaigns( - campaignParams: readonly string[], - inviteCode: string | null | undefined, - utmCampaignParam: string | null | undefined -): string[] { - const resolved: string[] = [] - const seen = new Set() - const add = (tag: string | undefined) => { - if (!tag) return - const key = tag.toLowerCase() - if (seen.has(key)) return - seen.add(key) - resolved.push(tag) - } - for (const param of campaignParams) { - for (const raw of param.split(',')) { - const tag = raw.trim() - if (!tag) continue - add(UTM_CAMPAIGN_TO_BADGE_MAP[tag.toLowerCase()] ?? tag) - } - } - if (inviteCode) add(INVITE_CODE_TO_CAMPAIGN_MAP[inviteCode]) - if (utmCampaignParam) add(UTM_CAMPAIGN_TO_BADGE_MAP[utmCampaignParam]) - return resolved -} - -// Bare ?campaign= links (no invite code) that are claimable without an invite — -// the InvitesPage auto-claim effect fires for these even for an already-logged-in -// user, and they bypass the "invalid invite" gate. Two flavours: -// - Waitlist-skip: the badge is also in peanut-api-ts SKIP_BADGE_CODES, so -// claiming it skips the card waitlist. `/invite` shows skip-the-waitlist copy. -// `skip` itself is the original bypass — the backend /badge/award also flips -// hasAppAccess + cardFlowEarlyAccessAt for it. Keep this set in sync with -// peanut-api-ts SKIP_BADGE_CODES. -// - Vanity: a commemorative badge with NO card-waitlist skip. Claimable from a -// bare link, but `/invite` shows generic badge-claim copy (not "skip"). -export const SKIP_CAMPAIGN = 'skip' -// naija and terere are the country-launch cohorts. Both are distributed as BARE -// campaign links with no inviter, and both are in peanut-api-ts -// POSTLAUNCH_SKIP_BADGE_CODES — so they belong here, not in BARE_VANITY_CAMPAIGNS -// (that set is for badges with no card-waitlist skip, and would show the wrong -// copy while the backend granted a skip anyway). -export const WAITLIST_SKIP_CAMPAIGNS: ReadonlySet = new Set([SKIP_CAMPAIGN, 'event_alumni', 'naija', 'terere']) -export const BARE_VANITY_CAMPAIGNS: ReadonlySet = new Set([ - 'touched_grass', - 'card_alpha', - 'festa_junina_2026', - 'irl_nomads', - 'offramp_user', - 'manicero', -]) - -export type CampaignClassification = { - /** Claimable from a bare link with no invite code (auto-claim + gate bypass). */ - isBareClaimCampaign: boolean - /** Subset of the above whose copy promises a card-waitlist skip. */ - isWaitlistSkip: boolean -} - -// Classify resolved campaigns for a visitor carrying the given invite code (if -// any). Campaigns are only "bare-claimable" when there is no invite code — with -// an invite code the normal invite-validation path owns the flow. With stacked -// campaigns, ANY claimable one makes the link claimable (the claim flow awards -// all of them; unknown tags 400 harmlessly on the backend whitelist), and ANY -// skip campaign in the stack earns the skip copy. Matching is case-insensitive -// (campaign codes arrive in any case from ?campaign= URLs). -export function classifyBareCampaigns( - campaigns: readonly string[], - inviteCode: string | undefined -): CampaignClassification { - const keys = inviteCode ? [] : campaigns.map((c) => c.toLowerCase()) - const isWaitlistSkip = keys.some((key) => WAITLIST_SKIP_CAMPAIGNS.has(key)) - const isVanity = keys.some((key) => BARE_VANITY_CAMPAIGNS.has(key)) - return { isBareClaimCampaign: isWaitlistSkip || isVanity, isWaitlistSkip } -} diff --git a/src/components/Profile/components/PublicProfile.tsx b/src/components/Profile/components/PublicProfile.tsx index f105e4d086..3374f3bac9 100644 --- a/src/components/Profile/components/PublicProfile.tsx +++ b/src/components/Profile/components/PublicProfile.tsx @@ -43,6 +43,7 @@ const PublicProfile: React.FC = ({ username, isLoggedIn = fa code: string name: string description: string | null + publicDescription?: string | null iconUrl: string | null earnedAt?: string | Date }> diff --git a/src/components/Setup/Views/JoinWaitlist.tsx b/src/components/Setup/Views/JoinWaitlist.tsx index 4bd9171774..ab8b0e6532 100644 --- a/src/components/Setup/Views/JoinWaitlist.tsx +++ b/src/components/Setup/Views/JoinWaitlist.tsx @@ -57,7 +57,7 @@ const JoinWaitlist = () => { setError('') setisLoading(true) const res = await invitesApi.validateInviteCode(inviteCode) - const isValid = res.success + const isValid = res.success && res.onboardingResolved posthog.capture(ANALYTICS_EVENTS.INVITE_CODE_VALIDATED, { valid: isValid, source: 'setup', diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx index 36a846b2b2..c0074c25a7 100644 --- a/src/context/authContext.tsx +++ b/src/context/authContext.tsx @@ -23,13 +23,14 @@ import { resetCrispProxySessions } from '@/utils/crisp' import { disableDemoMode } from '@/utils/demo' import posthog from 'posthog-js' import { useQueryClient } from '@tanstack/react-query' -import { useRouter } from 'next/navigation' import { createContext, type ReactNode, useContext, useState, useEffect, useMemo, useCallback } from 'react' import { captureException, setUser as setSentryUser } from '@sentry/nextjs' // import { PUBLIC_ROUTES_REGEX } from '@/constants/routes' import { USER_DATA_CACHE_PATTERNS } from '@/constants/cache.consts' import { purgeCaches } from '@/utils/cache.utils' import { clearStepUpToken } from '@/services/step-up' +import { claimAndSettlePendingBadgeCampaigns, isConfirmedBadgeCampaignClaim } from '@/services/badge-campaigns' +import { clearPendingBadgeCampaigns, getPendingBadgeCampaigns } from '@/components/Invites/badge-campaign-context' interface AuthContextType { user: IUserProfile | null @@ -66,7 +67,6 @@ const AuthContext = createContext(undefined) * adding accounts and logging out. It also provides hooks for child components to access user data and auth-related functions. */ export const AuthProvider = ({ children }: { children: ReactNode }) => { - const _router = useRouter() const dispatch = useAppDispatch() const toast = useToast() const tErrors = useTranslations('errors') @@ -143,6 +143,40 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { } }, [user]) + // Returning-user and app-restart recovery. Invite attribution is handled + // elsewhere; this only resumes opaque campaign identities after auth. The + // claim service de-dupes concurrent registration/page attempts and retains + // only retryable tags. + useEffect(() => { + const userId = user?.user.userId + if (!userId) return + + const badgeCampaigns = getPendingBadgeCampaigns() + if (badgeCampaigns.length === 0) return + + let cancelled = false + void claimAndSettlePendingBadgeCampaigns(badgeCampaigns).then(async (batch) => { + if (cancelled) return + if (batch.claims.some(isConfirmedBadgeCampaignClaim)) { + try { + await fetchUser() + } catch (error) { + captureException(error, { tags: { error_type: 'campaign_profile_refresh_failed' } }) + } + } + if (batch.pending.length > 0) { + captureException(new Error('authenticated campaign claim retained for retry'), { + tags: { error_type: 'campaign_claim_retryable' }, + extra: { userId, pendingCampaigns: batch.pending, claims: batch.claims }, + }) + } + }) + + return () => { + cancelled = true + } + }, [user?.user.userId, fetchUser]) + const legacy_fetchUser = useCallback(async () => { const { data: fetchedUser } = await fetchUser() return fetchedUser ?? null @@ -227,6 +261,12 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { // clear redirect url clearRedirectUrl() + // Pending badge campaigns are bearer acquisition intents. An explicit + // logout is an intentional account switch, so never let the next + // account on this browser inherit the previous account's award path. + // Passive auth expiry does not run this cleanup and retains retries. + clearPendingBadgeCampaigns() + // A cached step-up proof outliving the session would let the next user // of this device skip verification on card and withdrawal screens. clearStepUpToken() diff --git a/src/hooks/__tests__/post-auth-redirect-consumers.test.tsx b/src/hooks/__tests__/post-auth-redirect-consumers.test.tsx new file mode 100644 index 0000000000..634af52067 --- /dev/null +++ b/src/hooks/__tests__/post-auth-redirect-consumers.test.tsx @@ -0,0 +1,80 @@ +import { act, waitFor } from '@testing-library/react' +// These hooks localize their error copy now, so they need the intl provider. +import { renderHookWithIntl as renderHook } from '@/test-utils/intl' +import { getRedirectUrl, saveToLocalStorage } from '@/utils/general.utils' +import { useAccountSetup } from '../useAccountSetup' +import { useLogin } from '../useLogin' + +const mockRouterPush = jest.fn() +const mockHandleLogin = jest.fn() +const mockAddAccount = jest.fn() +const mockToastError = jest.fn() +let explicitRedirect: string | null = null + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockRouterPush }), + useSearchParams: () => ({ + get: (key: string) => (key === 'redirect_uri' ? explicitRedirect : null), + }), +})) + +jest.mock('@/context/authContext', () => ({ + useAuth: () => ({ + user: { user: { userId: 'user-1' } }, + addAccount: mockAddAccount, + }), +})) + +jest.mock('../useZeroDev', () => ({ + useZeroDev: () => ({ handleLogin: mockHandleLogin, isLoggingIn: false }), +})) + +jest.mock('@/components/0_Bruddle/Toast', () => ({ + useToast: () => ({ error: mockToastError }), +})) + +jest.mock('@/redux/hooks', () => ({ + useSetupStore: () => ({ telegramHandle: '' }), +})) + +jest.mock('@/utils/auth.utils', () => ({ clearAuthState: jest.fn() })) +jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn(), captureMessage: jest.fn() })) + +const FINANCIAL_REDIRECT = '/claim?step=claim&id=payment-1' +const CAMPAIGN_REDIRECT = '/add-money/crypto?network=EVM&source=offramp' + +describe('post-auth redirect consumers', () => { + beforeEach(() => { + jest.clearAllMocks() + localStorage.clear() + explicitRedirect = FINANCIAL_REDIRECT + mockHandleLogin.mockResolvedValue(undefined) + }) + + it('account setup consumes a superseded campaign redirect when the explicit financial route wins', () => { + saveToLocalStorage('redirect', CAMPAIGN_REDIRECT) + const { result } = renderHook(() => useAccountSetup()) + + act(() => expect(result.current.handleRedirect()).toBe(true)) + + expect(mockRouterPush).toHaveBeenCalledWith(FINANCIAL_REDIRECT) + expect(getRedirectUrl()).toBeNull() + }) + + it('login cannot resurrect a campaign redirect after an explicit financial route consumed it', async () => { + saveToLocalStorage('redirect', CAMPAIGN_REDIRECT) + const first = renderHook(() => useLogin()) + + await act(async () => first.result.current.handleLoginClick()) + await waitFor(() => expect(mockRouterPush).toHaveBeenCalledWith(FINANCIAL_REDIRECT)) + expect(getRedirectUrl()).toBeNull() + first.unmount() + + mockRouterPush.mockClear() + explicitRedirect = null + const later = renderHook(() => useLogin()) + + await act(async () => later.result.current.handleLoginClick()) + await waitFor(() => expect(mockRouterPush).toHaveBeenCalledWith('/home')) + }) +}) diff --git a/src/hooks/__tests__/useZeroDev-invite-onboarding.test.tsx b/src/hooks/__tests__/useZeroDev-invite-onboarding.test.tsx new file mode 100644 index 0000000000..25ece71f86 --- /dev/null +++ b/src/hooks/__tests__/useZeroDev-invite-onboarding.test.tsx @@ -0,0 +1,178 @@ +import { act, renderHook } from '@testing-library/react' +import { useZeroDev } from '../useZeroDev' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' + +const mockDispatch = jest.fn() +const mockAcceptInvite = jest.fn() +const mockRemoveFromCookie = jest.fn() +const mockSaveToCookie = jest.fn() +const mockSaveToLocalStorage = jest.fn() +const mockSetWebAuthnKey = jest.fn() +const mockSettleAcceptedInviteAcquisition = jest.fn() +const mockCapture = jest.fn() +const mockCaptureException = jest.fn() +const mockToWebAuthnKey = jest.fn() +const mockClaimAndSettlePendingBadgeCampaigns = jest.fn() +const mockIsConfirmedBadgeCampaignClaim = jest.fn() +const mockIsUnavailableBadgeCampaignClaim = jest.fn() +const mockPersistRegistrationBadgeCampaignDestination = jest.fn() +const mockSettleShhhhhCampaignContinuation = jest.fn() +let mockPendingBadgeCampaigns: string[] = [] + +jest.mock('@/context/authContext', () => ({ + useAuth: () => ({ user: null, logoutUser: jest.fn() }), +})) +jest.mock('@/context/kernelClient.context', () => ({ + useKernelClient: () => ({ + setWebAuthnKey: mockSetWebAuthnKey, + getClientForChain: jest.fn(), + ensureClientForChain: jest.fn(), + }), +})) +jest.mock('@/context/loadingStates.context', () => { + const React = jest.requireActual('react') + return { loadingStateContext: React.createContext({ setLoadingState: jest.fn() }) } +}) +jest.mock('@/redux/hooks', () => ({ + useAppDispatch: () => mockDispatch, + useSetupStore: () => ({ inviteCode: 'founderhaus', inviteType: 'PAYMENT_LINK' }), + useZerodevStore: () => ({ + isKernelClientReady: true, + isRegistering: false, + isLoggingIn: false, + isSendingUserOp: false, + address: undefined, + }), +})) +jest.mock('@/redux/slices/zerodev-slice', () => ({ + zerodevActions: { + resetZeroDevState: () => ({ type: 'zerodev/reset' }), + setIsRegistering: (payload: boolean) => ({ type: 'zerodev/registering', payload }), + setIsLoggingIn: (payload: boolean) => ({ type: 'zerodev/logging-in', payload }), + setIsSendingUserOp: (payload: boolean) => ({ type: 'zerodev/sending', payload }), + setAddress: (payload: string) => ({ type: 'zerodev/address', payload }), + }, +})) +jest.mock('@/redux/slices/setup-slice', () => ({ + setupActions: { + setInviteCode: (payload: string) => ({ type: 'setup/invite-code', payload }), + }, +})) +jest.mock('@/utils/general.utils', () => ({ + getFromCookie: (key: string) => (key === 'inviteCode' ? 'founderhaus' : null), + removeFromCookie: (...args: unknown[]) => mockRemoveFromCookie(...args), + saveToCookie: (...args: unknown[]) => mockSaveToCookie(...args), + saveToLocalStorage: (...args: unknown[]) => mockSaveToLocalStorage(...args), +})) +jest.mock('@zerodev/passkey-validator', () => ({ + toWebAuthnKey: (...args: unknown[]) => mockToWebAuthnKey(...args), + WebAuthnMode: { Register: 'Register', Login: 'Login' }, +})) +jest.mock('@/services/invites', () => ({ + invitesApi: { acceptInvite: (...args: unknown[]) => mockAcceptInvite(...args) }, +})) +jest.mock('@/services/invite-acquisition', () => ({ + settleAcceptedInviteAcquisition: (...args: unknown[]) => mockSettleAcceptedInviteAcquisition(...args), +})) +jest.mock('@/services/registration-acquisition', () => ({ + persistRegistrationBadgeCampaignDestination: (...args: unknown[]) => + mockPersistRegistrationBadgeCampaignDestination(...args), +})) +jest.mock('@/app/shhhhh/shhhhh-acquisition', () => ({ + settleShhhhhCampaignContinuation: (...args: unknown[]) => mockSettleShhhhhCampaignContinuation(...args), +})) +jest.mock('@/components/Invites/badge-campaign-context', () => ({ + getPendingBadgeCampaigns: () => mockPendingBadgeCampaigns, +})) +jest.mock('@/services/badge-campaigns', () => ({ + claimAndSettlePendingBadgeCampaigns: (...args: unknown[]) => mockClaimAndSettlePendingBadgeCampaigns(...args), + isConfirmedBadgeCampaignClaim: (...args: unknown[]) => mockIsConfirmedBadgeCampaignClaim(...args), + isUnavailableBadgeCampaignClaim: (...args: unknown[]) => mockIsUnavailableBadgeCampaignClaim(...args), +})) +jest.mock('@/services/consent', () => ({ signupConsentDocuments: () => [] })) +jest.mock('@/utils/auth.utils', () => ({ clearAuthState: jest.fn() })) +jest.mock('@/utils/walletCredential.utils', () => ({ + isStaleKeyError: () => false, + createStaleSessionError: () => new Error('stale'), +})) +jest.mock('@/utils/webauthn.utils', () => ({ + capturePasskeySignFailure: jest.fn(), + classifyPasskeyError: () => ({ code: 'UNKNOWN', message: 'unknown' }), +})) +jest.mock('@sentry/nextjs', () => ({ captureException: (...args: unknown[]) => mockCaptureException(...args) })) +jest.mock('posthog-js', () => ({ capture: (...args: unknown[]) => mockCapture(...args) })) +jest.mock('@/utils/capacitor', () => ({ isCapacitor: () => false, getNativeRpId: () => 'localhost' })) +jest.mock('@/utils/demo', () => ({ isDemoMode: () => false })) + +describe('useZeroDev registration invite boundary', () => { + beforeEach(() => { + jest.clearAllMocks() + mockPendingBadgeCampaigns = [] + mockToWebAuthnKey.mockResolvedValue({ id: 'new-passkey' }) + mockSettleAcceptedInviteAcquisition.mockReturnValue({ destination: '/home', pending: [] }) + mockSettleShhhhhCampaignContinuation.mockReturnValue(undefined) + mockIsConfirmedBadgeCampaignClaim.mockImplementation( + (claim: { outcome?: string }) => claim.outcome === 'awarded' || claim.outcome === 'already_owned' + ) + mockIsUnavailableBadgeCampaignClaim.mockImplementation((claim: { outcome?: string }) => + ['inactive', 'expired', 'unknown'].includes(claim.outcome ?? '') + ) + }) + + it.each(['awarded', 'inactive'] as const)( + 'settles a terminal %s campaign-only response without retaining an invite retry or reporting failure', + async (outcome) => { + mockAcceptInvite.mockResolvedValue({ + success: true, + attributionResolved: false, + onboardingResolved: false, + legacyAcquisition: { + campaignTag: 'founderhaus', + fallback: 'normal_app', + destination: 'normal_app', + }, + claims: [{ badgeCampaign: 'founderhaus', outcome }], + }) + const { result } = renderHook(() => useZeroDev()) + + await act(async () => result.current.handleRegister('new-user')) + + expect(mockSettleAcceptedInviteAcquisition).toHaveBeenCalledWith( + expect.objectContaining({ campaignTag: 'founderhaus' }), + [expect.objectContaining({ badgeCampaign: 'founderhaus', outcome })] + ) + expect(mockRemoveFromCookie).toHaveBeenCalledWith('inviteCode') + expect(mockDispatch).toHaveBeenCalledWith({ type: 'setup/invite-code', payload: '' }) + expect(mockSaveToCookie).not.toHaveBeenCalledWith('inviteCode', expect.anything(), expect.anything()) + expect(mockCapture).not.toHaveBeenCalledWith(ANALYTICS_EVENTS.INVITE_ACCEPTED, expect.anything()) + expect(mockCapture).not.toHaveBeenCalledWith(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, expect.anything()) + expect(mockCaptureException).not.toHaveBeenCalled() + expect(mockSetWebAuthnKey).toHaveBeenCalledWith({ id: 'new-passkey' }) + } + ) + + it('settles the signed-out Shhhhh continuation from the typed registration claim', async () => { + const claims = [{ badgeCampaign: 'skip', badgeCode: 'WAITLIST_SKIP', outcome: 'awarded' as const }] + mockPendingBadgeCampaigns = ['skip'] + mockAcceptInvite.mockResolvedValue({ + success: true, + attributionResolved: true, + onboardingResolved: true, + claims: [], + }) + mockClaimAndSettlePendingBadgeCampaigns.mockResolvedValue({ + claims, + pending: [], + transport: 'canonical', + }) + mockSettleShhhhhCampaignContinuation.mockReturnValue('/card') + const { result } = renderHook(() => useZeroDev()) + + await act(async () => result.current.handleRegister('new-user')) + + expect(mockClaimAndSettlePendingBadgeCampaigns).toHaveBeenCalledWith(['skip']) + expect(mockSettleShhhhhCampaignContinuation).toHaveBeenCalledWith(claims) + expect(mockPersistRegistrationBadgeCampaignDestination).not.toHaveBeenCalled() + expect(mockSetWebAuthnKey).toHaveBeenCalledWith({ id: 'new-passkey' }) + }) +}) diff --git a/src/hooks/useAccountSetup.ts b/src/hooks/useAccountSetup.ts index 7538f5eb84..2efec44743 100644 --- a/src/hooks/useAccountSetup.ts +++ b/src/hooks/useAccountSetup.ts @@ -3,10 +3,10 @@ import { useRouter, useSearchParams } from 'next/navigation' import * as Sentry from '@sentry/nextjs' import { useAuth } from '@/context/authContext' import { WalletProviderType } from '@/interfaces/wallet.interfaces' -import { getRedirectUrl, getValidRedirectUrl, clearRedirectUrl } from '@/utils/general.utils' import { clearAuthState } from '@/utils/auth.utils' import { POST_SIGNUP_ACTIONS } from '@/components/Global/PostSignupActionManager/post-signup-action.consts' import { useSetupStore } from '@/redux/hooks' +import { consumePostAuthRedirect } from '@/services/post-auth-redirect' /** * shared hook for finalizing account setup after test transaction succeeds @@ -22,31 +22,14 @@ export const useAccountSetup = () => { const [isProcessing, setIsProcessing] = useState(false) const handleRedirect = (): boolean => { - const redirect_uri = searchParams.get('redirect_uri') - if (redirect_uri) { - const validRedirectUrl = getValidRedirectUrl(redirect_uri, '/home') - console.log('[useAccountSetup] Redirecting to redirect_uri:', validRedirectUrl) - router.push(validRedirectUrl) - return true - } - - const localStorageRedirect = getRedirectUrl() - if (localStorageRedirect) { - const matchedAction = POST_SIGNUP_ACTIONS.find((action) => action.pathPattern.test(localStorageRedirect)) - if (matchedAction) { - console.log('[useAccountSetup] Matched post-signup action, redirecting to /home') - router.push('/home') - } else { - clearRedirectUrl() - const validRedirectUrl = getValidRedirectUrl(localStorageRedirect, '/home') - console.log('[useAccountSetup] Redirecting to localStorage redirect:', validRedirectUrl) - router.push(validRedirectUrl) - } - } else { - console.log('[useAccountSetup] No redirect found, going to /home') - router.push('/home') - } - return false + const redirect = consumePostAuthRedirect(searchParams.get('redirect_uri'), { + deferStoredRedirect: (destination) => + POST_SIGNUP_ACTIONS.some((action) => action.pathPattern.test(destination)), + }) + + console.log('[useAccountSetup] Resolved post-auth redirect:', redirect) + router.push(redirect.destination) + return redirect.source === 'explicit' } /** diff --git a/src/hooks/useHomeCarouselCTAs.tsx b/src/hooks/useHomeCarouselCTAs.tsx index 0688721ffc..4aca984e92 100644 --- a/src/hooks/useHomeCarouselCTAs.tsx +++ b/src/hooks/useHomeCarouselCTAs.tsx @@ -104,11 +104,7 @@ export const useHomeCarouselCTAs = () => { const { setIsQRScannerOpen } = useModalsContext() const { countryCode: userCountryCode } = useGeoLocation() - const { - isEligible: isCardPioneerEligible, - hasCardAccess: hasCardAccessGranted, - isLoading: isCardPioneerLoading, - } = useCardInfo() + const { hasCardAccess: hasCardAccessGranted } = useCardInfo() const { isActivated } = useActivationStatus() // Completion signals — used to hide educational CTAs from users who've already @@ -348,9 +344,7 @@ export const useHomeCarouselCTAs = () => { deviceType, isPwa, userCountryCode, - isCardPioneerEligible, hasCardAccessGranted, - isCardPioneerLoading, isActivated, hasMadeQrPayment, hasSentInvites, diff --git a/src/hooks/useLogin.tsx b/src/hooks/useLogin.tsx index 36d29aefc3..0fbc0d072b 100644 --- a/src/hooks/useLogin.tsx +++ b/src/hooks/useLogin.tsx @@ -4,8 +4,8 @@ import { useAuth } from '@/context/authContext' import { useZeroDev } from './useZeroDev' import { captureMessage } from '@sentry/nextjs' import { useEffect, useState } from 'react' -import { getRedirectUrl, getValidRedirectUrl, clearRedirectUrl } from '@/utils/general.utils' import { useRouter, useSearchParams } from 'next/navigation' +import { consumePostAuthRedirect } from '@/services/post-auth-redirect' // how long we wait for the user object after a successful passkey ceremony const POST_LOGIN_USER_TIMEOUT_MS = 15_000 @@ -42,19 +42,8 @@ export const useLogin = () => { useEffect(() => { // run only if login button is clicked to prevent un-intentional redirects if (isloginClicked && user) { - // redirect based on query params or saved redirect url - const localStorageRedirect = getRedirectUrl() - const redirect_uri = searchParams.get('redirect_uri') - if (redirect_uri) { - const validRedirectUrl = getValidRedirectUrl(redirect_uri, '/home') - router.push(validRedirectUrl) - } else if (localStorageRedirect) { - clearRedirectUrl() - const validRedirectUrl = getValidRedirectUrl(String(localStorageRedirect), '/home') - router.push(validRedirectUrl) - } else { - router.push('/home') - } + const redirect = consumePostAuthRedirect(searchParams.get('redirect_uri')) + router.push(redirect.destination) setIsloginClicked(false) setLoginResolved(false) } diff --git a/src/hooks/useZeroDev.ts b/src/hooks/useZeroDev.ts index 85d6b8a03d..fde3e9800c 100644 --- a/src/hooks/useZeroDev.ts +++ b/src/hooks/useZeroDev.ts @@ -5,8 +5,9 @@ import { loadingStateContext } from '@/context/loadingStates.context' import { useAuth } from '@/context/authContext' import { useKernelClient } from '@/context/kernelClient.context' import { useAppDispatch, useSetupStore, useZerodevStore } from '@/redux/hooks' +import { setupActions } from '@/redux/slices/setup-slice' import { zerodevActions } from '@/redux/slices/zerodev-slice' -import { getFromCookie, removeFromCookie, saveToCookie } from '@/utils/general.utils' +import { getFromCookie, removeFromCookie, saveToCookie, saveToLocalStorage } from '@/utils/general.utils' import { clearAuthState } from '@/utils/auth.utils' import { isStaleKeyError, createStaleSessionError } from '@/utils/walletCredential.utils' import { capturePasskeySignFailure, classifyPasskeyError } from '@/utils/webauthn.utils' @@ -15,6 +16,15 @@ import { useCallback, useContext } from 'react' import type { TransactionReceipt, Hex, Hash } from 'viem' import { captureException } from '@sentry/nextjs' import { invitesApi } from '@/services/invites' +import { + claimAndSettlePendingBadgeCampaigns, + isConfirmedBadgeCampaignClaim, + isUnavailableBadgeCampaignClaim, +} from '@/services/badge-campaigns' +import { settleAcceptedInviteAcquisition } from '@/services/invite-acquisition' +import { persistRegistrationBadgeCampaignDestination } from '@/services/registration-acquisition' +import { getPendingBadgeCampaigns } from '@/components/Invites/badge-campaign-context' +import { settleShhhhhCampaignContinuation } from '@/app/shhhhh/shhhhh-acquisition' import { signupConsentDocuments } from '@/services/consent' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' @@ -85,11 +95,7 @@ export const useZeroDev = () => { // invite code can also be store in cookies, so we need to check both const userInviteCode = inviteCode || inviteCodeFromCookie - // comma-separated for stacked campaigns (InvitesPage writes the CSV) - const campaignTags = String(getFromCookie('campaignTag') || '') - .split(',') - .map((tag) => tag.trim()) - .filter(Boolean) + const badgeCampaigns = getPendingBadgeCampaigns() if (userInviteCode?.trim().length > 0) { /* @@ -101,26 +107,63 @@ export const useZeroDev = () => { * The cookie is only cleared on confirmed success. */ const keepInviteCodeForRetry = () => saveToCookie('inviteCode', userInviteCode, 30) + const clearAcceptedInviteCode = () => { + removeFromCookie('inviteCode') + dispatch(setupActions.setInviteCode('')) + } try { - const result = await invitesApi.acceptInvite(userInviteCode, inviteType, campaignTags[0]) + const result = await invitesApi.acceptInvite(userInviteCode, inviteType) + let campaignOnlyProcessed = false if (result.success) { + if (result.legacyAcquisition) { + const acceptedBadgeCampaigns = [result.legacyAcquisition.campaignTag] + const { destination, pending } = settleAcceptedInviteAcquisition( + result.legacyAcquisition, + result.claims + ) + + // Keep an already-published migration continuation + // through setup only after the matching claim confirms. + if (destination !== '/home') saveToLocalStorage('redirect', destination) + if (pending.some((tag) => tag.toLowerCase() === acceptedBadgeCampaigns[0].toLowerCase())) { + captureException(new Error('accept-time legacy acquisition retained for retry'), { + tags: { error_type: 'invite_accept_campaign_retryable' }, + extra: { + inviteCode: userInviteCode, + pendingCampaigns: pending, + claims: result.claims, + }, + }) + } + if (!result.onboardingResolved) { + // A NONE adapter is not an invite retry. Its + // terminal claim is done; any retryable state is + // now carried solely by the versioned campaign queue. + campaignOnlyProcessed = true + clearAcceptedInviteCode() + } + } + } + + if (result.success && result.onboardingResolved) { posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPTED, { invite_code: userInviteCode, invite_type: inviteType, - // campaign_tag stays single-valued (what acceptInvite - // consumed); the full stack rides the plural CSV. - campaign_tag: campaignTags[0], - campaign_tags: campaignTags.join(',') || undefined, + campaign_tag: badgeCampaigns[0], + campaign_tags: badgeCampaigns, }) - if (inviteCodeFromCookie) { - removeFromCookie('inviteCode') - } + clearAcceptedInviteCode() + } else if (campaignOnlyProcessed) { + // Deliberately no onboarding-failed analytics/Sentry: + // campaign-only compatibility resolved as designed. } else { posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, { invite_code: userInviteCode, - error_message: 'API returned unsuccessful', + error_message: result.success + ? 'Invite did not resolve onboarding' + : 'API returned unsuccessful', }) - captureException(new Error('register-time invite accept returned unsuccessful'), { + captureException(new Error('register-time invite onboarding unresolved'), { tags: { error_type: 'invite_accept_failed' }, extra: { inviteCode: userInviteCode, result }, }) @@ -141,33 +184,43 @@ export const useZeroDev = () => { } } - // Award every campaign badge, with or without an invite code. - // /invites/accept only awards its own whitelisted badges, so a - // campaign like `skip` riding on an invite link - // (?code=alice&campaign=skip) was silently dropped here before this - // ran unconditionally. /badge/award is idempotent and whitelisted - // server-side — a tag acceptInvite already awarded no-ops, and - // anything unknown 400s harmlessly. awardBadge never throws (the - // service catches internally), so check each result: on any failure - // keep the cookie — a later signup retry (or a fixed backend) can - // still claim, and a Skip Pass must not be silently lost. - if (campaignTags.length > 0) { - const awarded: string[] = [] - for (const tag of campaignTags) { - const { success } = await invitesApi.awardBadge(tag) - if (success) awarded.push(tag) - else console.error('Error awarding campaign badge', tag) - } - if (awarded.length > 0 && !userInviteCode?.trim()) { - // the invite-code branch already fired INVITE_ACCEPTED + // Campaign acquisition is independent from invite attribution. It + // runs after authentication whether or not an invite was present, + // and per-tag settlement keeps only transport/configuration retries. + // Re-read after `/invites/accept`: a confirmed legacy adapter may + // have settled the same tag, while a malformed/missing result may + // have queued it for an immediate canonical retry. + const pendingBadgeCampaigns = getPendingBadgeCampaigns() + if (pendingBadgeCampaigns.length > 0) { + const batch = await claimAndSettlePendingBadgeCampaigns(pendingBadgeCampaigns) + const confirmed = batch.claims.filter(isConfirmedBadgeCampaignClaim) + const unavailable = batch.claims.filter(isUnavailableBadgeCampaignClaim) + + // Explicit/UTM badge campaigns do not pass through `/invites/accept`. + // Shhhhh owns one compatibility continuation: only a confirmed + // Skip Pass replaces its safe /home marker with /card. Every + // other entrypoint uses backend-owned acquisition navigation. + const shhhhhDestination = settleShhhhhCampaignContinuation(batch.claims) + if (shhhhhDestination === undefined) persistRegistrationBadgeCampaignDestination(batch.claims) + + if (confirmed.length > 0) { posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPTED, { - // single-valued for existing filters; full stack in the CSV - campaign_tag: awarded[0], - campaign_tags: awarded.join(','), + campaign_tag: confirmed[0]?.badgeCampaign, + campaign_tags: confirmed.map((claim) => claim.badgeCampaign), + badge_codes: confirmed.map((claim) => claim.badgeCode).filter(Boolean), }) } - if (awarded.length === campaignTags.length) { - removeFromCookie('campaignTag') + if (unavailable.length > 0) { + console.warn( + 'Campaign unavailable during registration', + unavailable.map(({ badgeCampaign, outcome }) => ({ badgeCampaign, outcome })) + ) + } + if (batch.pending.length > 0) { + captureException(new Error('register-time campaign claim retained for retry'), { + tags: { error_type: 'campaign_claim_retryable' }, + extra: { pendingCampaigns: batch.pending, claims: batch.claims }, + }) } } @@ -179,7 +232,8 @@ export const useZeroDev = () => { } const err = e as Error console.error('[useZeroDev] registration failed:', err.name, err.message, err, { - shimInstalled: (globalThis as { __capgoPasskeyShimInstalled?: unknown }).__capgoPasskeyShimInstalled, + shimInstalled: (globalThis as typeof globalThis & { __capgoPasskeyShimInstalled?: boolean }) + .__capgoPasskeyShimInstalled, }) dispatch(zerodevActions.setIsRegistering(false)) throw e diff --git a/src/interfaces/interfaces.ts b/src/interfaces/interfaces.ts index 4fa7e41149..966b3328ef 100644 --- a/src/interfaces/interfaces.ts +++ b/src/interfaces/interfaces.ts @@ -188,6 +188,7 @@ export interface User { code: string name: string description: string | null + publicDescription?: string | null iconUrl: string | null color: string | null earnedAt: string | Date diff --git a/src/services/__tests__/badge-campaigns.test.ts b/src/services/__tests__/badge-campaigns.test.ts new file mode 100644 index 0000000000..63a30e505c --- /dev/null +++ b/src/services/__tests__/badge-campaigns.test.ts @@ -0,0 +1,515 @@ +import { + claimAndSettlePendingBadgeCampaigns, + claimBadgeCampaigns, + destinationForConfirmedBadgeCampaignAcquisition, + pendingBadgeCampaignsAfterClaims, +} from '../badge-campaigns' +import { + PENDING_BADGE_CAMPAIGN_INTENT_EPOCH_STORAGE_KEY, + clearPendingBadgeCampaigns, + getPendingBadgeCampaigns, + queuePendingBadgeCampaigns, + savePendingBadgeCampaigns, +} from '@/components/Invites/badge-campaign-context' +import { serverFetch } from '@/utils/api-fetch' + +jest.mock('@/utils/api-fetch', () => ({ serverFetch: jest.fn() })) + +const mockServerFetch = serverFetch as jest.MockedFunction + +function response(status: number, body: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + json: jest.fn().mockResolvedValue(body), + } as unknown as Response +} + +function claim(badgeCampaign: string, outcome: string, badgeCode?: string) { + return { + badgeCampaign, + outcome, + ...(badgeCode ? { badgeCode } : {}), + } +} + +function legacyClaim(campaignTag: string, outcome: string, badgeCode?: string) { + return { + campaignTag, + outcome, + ...(badgeCode ? { badgeCode } : {}), + } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +describe('badge badge campaign claims contract', () => { + beforeEach(() => { + mockServerFetch.mockReset() + clearPendingBadgeCampaigns() + }) + + afterAll(() => clearPendingBadgeCampaigns()) + + it('trims, case-insensitively dedupes, and forwards opaque identities in one canonical request', async () => { + mockServerFetch.mockResolvedValue( + response(200, { + claims: [claim('NITA', 'awarded', 'NITA'), claim('Creator/Summer', 'already_owned')], + }) + ) + + const result = await claimBadgeCampaigns([' NITA ', 'nita', 'Creator/Summer']) + + expect(result.transport).toBe('canonical') + expect(result.claims.map(({ badgeCampaign }) => badgeCampaign)).toEqual(['NITA', 'Creator/Summer']) + expect(mockServerFetch).toHaveBeenCalledTimes(1) + expect(mockServerFetch).toHaveBeenCalledWith('/badge/claims', { + method: 'POST', + body: JSON.stringify({ badgeCampaigns: ['NITA', 'Creator/Summer'] }), + }) + expect(mockServerFetch.mock.calls[0][1]?.body).not.toContain('code') + }) + + it('normalizes a rolling-deploy legacy response echo at the runtime boundary', async () => { + mockServerFetch.mockResolvedValue( + response(200, { claims: [legacyClaim('published-legacy', 'already_owned', 'LEGACY_BADGE')] }) + ) + + const result = await claimBadgeCampaigns(['published-legacy']) + + expect(result.claims).toEqual([ + { + badgeCampaign: 'published-legacy', + badgeCode: 'LEGACY_BADGE', + outcome: 'already_owned', + }, + ]) + }) + + it('settles source-qualified UTM identities only from typed backend outcomes', async () => { + const qualifiedIdentities = [ + 'utm:token-nation-2026', + 'utm:touched-grass', + 'utm:festa-junina', + 'utm:card-alpha', + 'utm:irl-nomads', + ] + mockServerFetch.mockResolvedValue( + response(200, { + claims: qualifiedIdentities.map((badgeCampaign) => claim(badgeCampaign, 'awarded')), + }) + ) + + const result = await claimAndSettlePendingBadgeCampaigns(qualifiedIdentities) + + expect(JSON.parse(String(mockServerFetch.mock.calls[0][1]?.body))).toEqual({ + badgeCampaigns: qualifiedIdentities, + }) + expect(result.claims.map(({ badgeCampaign }) => badgeCampaign)).toEqual(qualifiedIdentities) + expect(result.pending).toEqual([]) + }) + + it('accepts a catalog badge whose optional artwork is null', async () => { + mockServerFetch.mockResolvedValue( + response(200, { + claims: [ + { + badgeCampaign: 'new-badge', + badgeCode: 'NEW_BADGE', + badge: { + code: 'NEW_BADGE', + name: 'New Badge', + description: null, + publicDescription: null, + iconUrl: null, + }, + outcome: 'awarded', + }, + ], + }) + ) + + const result = await claimAndSettlePendingBadgeCampaigns(['new-badge']) + + expect(result.claims[0]).toMatchObject({ outcome: 'awarded', badge: { iconUrl: null } }) + expect(result.pending).toEqual([]) + }) + + it('aligns backend echoes case-insensitively and retains only definition_missing in a mixed batch', async () => { + savePendingBadgeCampaigns(['NITA', 'Old', 'Mystery', 'Deploying']) + mockServerFetch.mockResolvedValue( + response(200, { + claims: [ + claim('nita', 'awarded', 'NITA'), + claim('old', 'expired'), + claim('MYSTERY', 'unknown'), + claim('deploying', 'definition_missing'), + ], + }) + ) + + const result = await claimAndSettlePendingBadgeCampaigns() + + expect(result.pending).toEqual(['Deploying']) + expect(getPendingBadgeCampaigns()).toEqual(['Deploying']) + }) + + it.each(['awarded', 'already_owned', 'inactive', 'expired', 'unknown'])( + 'consumes the terminal %s outcome', + async (outcome) => { + mockServerFetch.mockResolvedValue(response(200, { claims: [claim('campaign', outcome)] })) + + const result = await claimAndSettlePendingBadgeCampaigns(['campaign']) + + expect(result.pending).toEqual([]) + expect(getPendingBadgeCampaigns()).toEqual([]) + } + ) + + it('retains a requested identity omitted from an otherwise valid response', async () => { + mockServerFetch.mockResolvedValue(response(200, { claims: [claim('one', 'awarded')] })) + + const result = await claimAndSettlePendingBadgeCampaigns(['one', 'two']) + + expect(result.claims[1]).toMatchObject({ badgeCampaign: 'two', outcome: 'retryable_error' }) + expect(result.pending).toEqual(['two']) + expect(getPendingBadgeCampaigns()).toEqual(['two']) + }) + + it.each([ + {}, + { claims: null }, + { + claims: [ + { + badgeCampaign: 123, + campaignTag: 'one', + outcome: 'awarded', + }, + ], + }, + { + claims: [ + { + badgeCampaign: 'one', + outcome: 'awarded', + badgeCode: 123, + }, + ], + }, + { + claims: [ + { + badgeCampaign: 'one', + outcome: 'awarded', + badge: { code: 'ONE', name: 'One', description: null, iconUrl: 123 }, + }, + ], + }, + ])('retains the identity for a malformed canonical 200 body %#', async (body) => { + mockServerFetch.mockResolvedValue(response(200, body)) + + const result = await claimAndSettlePendingBadgeCampaigns(['one']) + + expect(result.claims[0]).toMatchObject({ badgeCampaign: 'one', outcome: 'retryable_error' }) + expect(result.pending).toEqual(['one']) + }) + + it('uses a present canonical response identity instead of a conflicting legacy echo', async () => { + mockServerFetch.mockResolvedValue( + response(200, { + claims: [{ badgeCampaign: 'one', campaignTag: 'other', outcome: 'awarded' }], + }) + ) + + const result = await claimAndSettlePendingBadgeCampaigns(['one']) + + expect(result.claims).toEqual([{ badgeCampaign: 'one', outcome: 'awarded' }]) + expect(result.pending).toEqual([]) + }) + + it('settles valid entries while retaining a malformed entry from the same 200 response', async () => { + mockServerFetch.mockResolvedValue( + response(200, { + claims: [ + claim('valid', 'awarded'), + { + badgeCampaign: 'partial', + outcome: 'awarded', + badgeCode: 123, + }, + ], + }) + ) + + const result = await claimAndSettlePendingBadgeCampaigns(['valid', 'partial']) + + expect(result.claims.map(({ outcome }) => outcome)).toEqual(['awarded', 'retryable_error']) + expect(result.pending).toEqual(['partial']) + }) + + it('does not drop a different pending campaign while settling a subset', async () => { + savePendingBadgeCampaigns(['Claimed-Now', 'Earlier-Retry']) + mockServerFetch.mockResolvedValue(response(200, { claims: [claim('claimed-now', 'awarded')] })) + + const result = await claimAndSettlePendingBadgeCampaigns(['Claimed-Now']) + + expect(result.pending).toEqual(['Earlier-Retry']) + expect(getPendingBadgeCampaigns()).toEqual(['Earlier-Retry']) + }) + + it.each(['older-first', 'newer-first'] as const)( + 'merge-safely settles concurrent cookie batches when %s completes', + async (completionOrder) => { + const olderResponse = deferred() + const newerResponse = deferred() + mockServerFetch.mockImplementation((_path, options) => { + const badgeCampaigns = JSON.parse(String(options?.body)).badgeCampaigns as string[] + return badgeCampaigns[0] === 'Older' ? olderResponse.promise : newerResponse.promise + }) + + savePendingBadgeCampaigns(['Older']) + const olderClaim = claimAndSettlePendingBadgeCampaigns(['Older']) + queuePendingBadgeCampaigns(['Newer']) + const newerClaim = claimAndSettlePendingBadgeCampaigns(['Newer']) + + if (completionOrder === 'older-first') { + olderResponse.resolve(response(200, { claims: [claim('Older', 'awarded')] })) + await olderClaim + expect(getPendingBadgeCampaigns()).toEqual(['Newer']) + newerResponse.resolve(response(200, { claims: [claim('Newer', 'awarded')] })) + await newerClaim + } else { + newerResponse.resolve(response(200, { claims: [claim('Newer', 'awarded')] })) + await newerClaim + expect(getPendingBadgeCampaigns()).toEqual(['Older']) + olderResponse.resolve(response(200, { claims: [claim('Older', 'awarded')] })) + await olderClaim + } + + expect(getPendingBadgeCampaigns()).toEqual([]) + } + ) + + it.each([401, 408, 425, 429, 500, 503])('retains badge campaigns after retryable HTTP %s', async (status) => { + mockServerFetch.mockResolvedValue(response(status, { error: 'retry later' })) + + const result = await claimAndSettlePendingBadgeCampaigns(['Retry-Me']) + + expect(result.claims).toEqual([ + expect.objectContaining({ badgeCampaign: 'Retry-Me', outcome: 'retryable_error', httpStatus: status }), + ]) + expect(result.pending).toEqual(['Retry-Me']) + }) + + it('does not resurrect retryable intent when explicit logout wins an in-flight claim race', async () => { + const pendingResponse = deferred() + mockServerFetch.mockReturnValue(pendingResponse.promise) + savePendingBadgeCampaigns(['First-Account']) + + const inFlightClaim = claimAndSettlePendingBadgeCampaigns() + clearPendingBadgeCampaigns() + pendingResponse.resolve(response(503, { error: 'retry later' })) + + const result = await inFlightClaim + expect(result.claims[0]).toMatchObject({ badgeCampaign: 'First-Account', outcome: 'retryable_error' }) + expect(result.pending).toEqual([]) + expect(getPendingBadgeCampaigns()).toEqual([]) + }) + + it('does not resurrect retryable intent after another tab advances the logout epoch', async () => { + const pendingResponse = deferred() + mockServerFetch.mockReturnValue(pendingResponse.promise) + savePendingBadgeCampaigns(['First-Account']) + + const inFlightClaim = claimAndSettlePendingBadgeCampaigns() + const currentEpoch = JSON.parse( + localStorage.getItem(PENDING_BADGE_CAMPAIGN_INTENT_EPOCH_STORAGE_KEY) ?? '0' + ) as number + localStorage.setItem(PENDING_BADGE_CAMPAIGN_INTENT_EPOCH_STORAGE_KEY, JSON.stringify(currentEpoch + 1)) + savePendingBadgeCampaigns([]) + pendingResponse.resolve(response(503, { error: 'retry later' })) + + const result = await inFlightClaim + expect(result.pending).toEqual([]) + expect(getPendingBadgeCampaigns()).toEqual([]) + }) + + it('never reuses an in-flight campaign response across an explicit account switch', async () => { + const firstAccountResponse = deferred() + const secondAccountResponse = deferred() + mockServerFetch + .mockReturnValueOnce(firstAccountResponse.promise) + .mockReturnValueOnce(secondAccountResponse.promise) + savePendingBadgeCampaigns(['Shared-Campaign']) + + const firstAccountClaim = claimAndSettlePendingBadgeCampaigns() + clearPendingBadgeCampaigns() + queuePendingBadgeCampaigns(['Shared-Campaign']) + const secondAccountClaim = claimAndSettlePendingBadgeCampaigns() + + expect(mockServerFetch).toHaveBeenCalledTimes(2) + firstAccountResponse.resolve( + response(200, { claims: [claim('Shared-Campaign', 'awarded', 'FIRST_ACCOUNT_BADGE')] }) + ) + const staleResult = await firstAccountClaim + expect(staleResult.pending).toEqual(['Shared-Campaign']) + expect(getPendingBadgeCampaigns()).toEqual(['Shared-Campaign']) + + secondAccountResponse.resolve(response(503, { error: 'retry later' })) + const currentResult = await secondAccountClaim + expect(currentResult.claims[0]).toMatchObject({ outcome: 'retryable_error', httpStatus: 503 }) + expect(currentResult.pending).toEqual(['Shared-Campaign']) + expect(getPendingBadgeCampaigns()).toEqual(['Shared-Campaign']) + }) + + it('retains badge campaigns after a network failure', async () => { + mockServerFetch.mockRejectedValue(new Error('offline')) + + const result = await claimAndSettlePendingBadgeCampaigns(['Retry-Me']) + + expect(result.claims[0].outcome).toBe('retryable_error') + expect(result.pending).toEqual(['Retry-Me']) + }) + + it.each([400, 403, 422])('consumes canonical terminal schema HTTP %s without legacy fallback', async (status) => { + mockServerFetch.mockResolvedValue(response(status, { error: 'invalid campaign input' })) + + const result = await claimAndSettlePendingBadgeCampaigns(['Bad']) + + expect(result.claims[0]).toMatchObject({ outcome: 'unknown', httpStatus: status }) + expect(result.pending).toEqual([]) + expect(mockServerFetch).toHaveBeenCalledTimes(1) + }) + + it.each([404, 405])('uses the compatibility endpoint only when canonical returns %s', async (status) => { + mockServerFetch + .mockResolvedValueOnce(response(status, { error: 'not deployed' })) + .mockResolvedValueOnce(response(200, { claim: legacyClaim('Legacy', 'awarded', 'LEGACY_BADGE') })) + + const result = await claimAndSettlePendingBadgeCampaigns(['Legacy']) + + expect(result.transport).toBe('legacy') + expect(result.claims[0]).toMatchObject({ outcome: 'awarded', badgeCode: 'LEGACY_BADGE' }) + expect(result.pending).toEqual([]) + expect(mockServerFetch).toHaveBeenNthCalledWith(2, '/badge/award', { + method: 'POST', + body: JSON.stringify({ campaignTag: 'Legacy' }), + }) + }) + + it('retains an unconfirmed legacy 200 instead of reporting false success', async () => { + mockServerFetch + .mockResolvedValueOnce(response(404, null)) + .mockResolvedValueOnce(response(200, { message: 'Badge awarded successfully' })) + + const result = await claimAndSettlePendingBadgeCampaigns(['Legacy']) + + expect(result.claims[0].outcome).toBe('legacy_response_unconfirmed') + expect(result.pending).toEqual(['Legacy']) + }) + + it.each([ + [400, 'unknown', false], + [404, 'retryable_error', true], + [405, 'retryable_error', true], + [408, 'retryable_error', true], + [425, 'retryable_error', true], + [500, 'retryable_error', true], + ] as const)('settles legacy HTTP %s as %s', async (legacyStatus, outcome, remainsPending) => { + mockServerFetch + .mockResolvedValueOnce(response(404, null)) + .mockResolvedValueOnce(response(legacyStatus, { error: 'legacy failure' })) + + const result = await claimAndSettlePendingBadgeCampaigns(['Legacy']) + + expect(result.claims[0].outcome).toBe(outcome) + expect(result.pending).toEqual(remainsPending ? ['Legacy'] : []) + }) + + it('computes pending identities without depending on backend echo casing', () => { + expect( + pendingBadgeCampaignsAfterClaims( + ['First', 'Second'], + [ + { badgeCampaign: 'first', outcome: 'already_owned' }, + { badgeCampaign: 'SECOND', outcome: 'definition_missing' }, + ] + ) + ).toEqual(['Second']) + }) + + it('keeps validated acquisition navigation while dropping unknown policy and reward fields', async () => { + mockServerFetch.mockResolvedValue( + response(200, { + claims: [ + { + badgeCampaign: 'public-link', + badgeCode: 'PERMANENT_BADGE', + outcome: 'awarded', + capabilities: [{ key: 'card.flow.bypass', lifecycle: { kind: 'one_shot' } }], + acquisition: { fallback: 'normal_app', destination: 'offramp_migration' }, + reward: { amount: 1000, currency: 'USD' }, + }, + ], + }) + ) + + const result = await claimAndSettlePendingBadgeCampaigns(['public-link']) + + expect(result.claims).toEqual([ + { + badgeCampaign: 'public-link', + badgeCode: 'PERMANENT_BADGE', + outcome: 'awarded', + acquisition: { fallback: 'normal_app', destination: 'offramp_migration' }, + }, + ]) + expect(result.pending).toEqual([]) + }) + + it('routes only a confirmed, validated acquisition destination', () => { + const acquisition = { fallback: 'normal_app' as const, destination: 'offramp_migration' as const } + + expect( + destinationForConfirmedBadgeCampaignAcquisition([ + { badgeCampaign: 'opaque', outcome: 'already_owned', acquisition }, + ]) + ).toBe('/add-money/crypto?network=EVM&source=offramp') + expect( + destinationForConfirmedBadgeCampaignAcquisition([ + { badgeCampaign: 'opaque', outcome: 'expired', acquisition }, + ]) + ).toBe('/home') + expect( + destinationForConfirmedBadgeCampaignAcquisition([{ badgeCampaign: 'offramp', outcome: 'awarded' }]) + ).toBe('/home') + }) + + it('drops a malformed destination while retaining the permanent award outcome', async () => { + mockServerFetch.mockResolvedValue( + response(200, { + claims: [ + { + badgeCampaign: 'offramp', + badgeCode: 'OFFRAMP_USER', + outcome: 'awarded', + acquisition: { fallback: 'normal_app', destination: 'https://attacker.example' }, + }, + ], + }) + ) + + const result = await claimAndSettlePendingBadgeCampaigns(['offramp']) + + expect(result.claims).toEqual([{ badgeCampaign: 'offramp', badgeCode: 'OFFRAMP_USER', outcome: 'awarded' }]) + expect(destinationForConfirmedBadgeCampaignAcquisition(result.claims)).toBe('/home') + expect(result.pending).toEqual([]) + }) +}) diff --git a/src/services/__tests__/invite-response.test.ts b/src/services/__tests__/invite-response.test.ts new file mode 100644 index 0000000000..95d3c4d918 --- /dev/null +++ b/src/services/__tests__/invite-response.test.ts @@ -0,0 +1,48 @@ +import { isTypedCampaignOnlyInviteResponse, resolveInviteResolutionFlags } from '../invite-response' + +describe('invite response rollout helpers', () => { + it('uses the legacy success signal only when both discriminators are absent', () => { + expect(resolveInviteResolutionFlags({ message: 'legacy success' }, true)).toEqual({ + attributionResolved: true, + onboardingResolved: true, + }) + expect(resolveInviteResolutionFlags({ attributionResolved: true }, true)).toEqual({ + attributionResolved: false, + onboardingResolved: false, + }) + }) + + it('lets either explicit false discriminator win over a legacy success signal', () => { + expect(resolveInviteResolutionFlags({ attributionResolved: false, onboardingResolved: true }, true)).toEqual({ + attributionResolved: false, + onboardingResolved: false, + }) + expect(resolveInviteResolutionFlags({ attributionResolved: true, onboardingResolved: false }, true)).toEqual({ + attributionResolved: false, + onboardingResolved: false, + }) + }) + + it('recognizes only a fully typed campaign-only response', () => { + const typed = { + message: 'Campaign processed without invite attribution', + attributionResolved: false, + onboardingResolved: false, + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + } + + expect(isTypedCampaignOnlyInviteResponse(typed)).toBe(true) + expect(isTypedCampaignOnlyInviteResponse({ ...typed, onboardingResolved: true })).toBe(false) + expect(isTypedCampaignOnlyInviteResponse({ ...typed, legacyAcquisition: undefined })).toBe(false) + expect( + isTypedCampaignOnlyInviteResponse({ + ...typed, + legacyAcquisition: { ...typed.legacyAcquisition, destination: 'future_product_flow' }, + }) + ).toBe(false) + }) +}) diff --git a/src/services/__tests__/invites-attribution.test.ts b/src/services/__tests__/invites-attribution.test.ts new file mode 100644 index 0000000000..0e780c044e --- /dev/null +++ b/src/services/__tests__/invites-attribution.test.ts @@ -0,0 +1,217 @@ +import { invitesApi } from '../invites' +import { EInviteType } from '../services.types' +import { serverFetch } from '@/utils/api-fetch' +import { validateInviteCode } from '@/app/actions/invites' +import { destinationForInviteAcquisition, settleAcceptedInviteAcquisition } from '../invite-acquisition' +import { clearPendingBadgeCampaigns, getPendingBadgeCampaigns } from '@/components/Invites/badge-campaign-context' + +jest.mock('@/utils/api-fetch', () => ({ serverFetch: jest.fn() })) +jest.mock('@/app/actions/invites', () => ({ validateInviteCode: jest.fn() })) + +const mockServerFetch = serverFetch as jest.MockedFunction +const mockValidateInviteCode = validateInviteCode as jest.MockedFunction + +function response(status: number, body: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + json: jest.fn().mockResolvedValue(body), + } as unknown as Response +} + +describe('invite attribution contract', () => { + beforeEach(() => { + jest.clearAllMocks() + clearPendingBadgeCampaigns() + }) + + afterAll(() => clearPendingBadgeCampaigns()) + + it('normalizes and submits inviter attribution without campaign acquisition fields', async () => { + mockServerFetch.mockResolvedValue( + response(200, { + message: 'Invite accepted', + attributionResolved: true, + onboardingResolved: true, + claims: [], + }) + ) + + await invitesApi.acceptInvite(' @Juanacervio ', EInviteType.PAYMENT_LINK) + + expect(mockServerFetch).toHaveBeenCalledWith('/invites/accept', { + method: 'POST', + body: JSON.stringify({ inviteCode: 'juanacervio', type: EInviteType.PAYMENT_LINK }), + }) + const body = JSON.parse(String(mockServerFetch.mock.calls[0][1]?.body)) + expect(body).not.toHaveProperty('campaignTag') + expect(body).not.toHaveProperty('campaignTags') + }) + + it('forwards a published send-link campaign tag as a field separate from inviter attribution', async () => { + mockServerFetch.mockResolvedValue( + response(200, { + message: 'Invite accepted', + attributionResolved: true, + onboardingResolved: true, + claims: [{ campaignTag: 'devconnect_ba_2025', outcome: 'awarded' }], + }) + ) + + await invitesApi.acceptInvite('alice', EInviteType.PAYMENT_LINK, 'devconnect_ba_2025') + + expect(JSON.parse(String(mockServerFetch.mock.calls[0][1]?.body))).toEqual({ + inviteCode: 'alice', + type: EInviteType.PAYMENT_LINK, + campaignTag: 'devconnect_ba_2025', + }) + }) + + it('consumes the typed legacy acquisition and matching accept-time claim', async () => { + mockServerFetch.mockResolvedValue( + response(409, { + message: 'Campaign processed without invite attribution', + attributionResolved: false, + onboardingResolved: false, + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + claims: [ + { + campaignTag: 'offramp', + badgeCode: 'OFFRAMP_USER', + outcome: 'already_owned', + capabilities: [], + }, + ], + }) + ) + + const result = await invitesApi.acceptInvite('offramp', EInviteType.PAYMENT_LINK) + + expect(result).toMatchObject({ + success: true, + attributionResolved: false, + onboardingResolved: false, + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + claims: [{ badgeCampaign: 'offramp', outcome: 'already_owned' }], + }) + expect(settleAcceptedInviteAcquisition(result.legacyAcquisition!, result.claims)).toEqual({ + destination: '/add-money/crypto?network=EVM&source=offramp', + pending: [], + }) + expect(getPendingBadgeCampaigns()).toEqual([]) + }) + + it('keeps an untyped HTTP 409 as a transport failure', async () => { + mockServerFetch.mockResolvedValue(response(409, { error: 'Invite code is not valid' })) + + await expect(invitesApi.acceptInvite('not-an-invite', EInviteType.PAYMENT_LINK)).resolves.toEqual({ + success: false, + attributionResolved: false, + onboardingResolved: false, + claims: [], + }) + }) + + it('supports a pre-discriminator HTTP 200 accept response', async () => { + mockServerFetch.mockResolvedValue(response(200, { message: 'Invite accepted', claims: [] })) + + await expect(invitesApi.acceptInvite('legacy-invite', EInviteType.PAYMENT_LINK)).resolves.toMatchObject({ + success: true, + attributionResolved: true, + onboardingResolved: true, + claims: [], + }) + }) + + it('falls back normally when an accept-time compatibility claim is missing', async () => { + mockServerFetch.mockResolvedValue( + response(200, { + message: 'Invite accepted', + attributionResolved: false, + onboardingResolved: false, + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + claims: [], + }) + ) + + const result = await invitesApi.acceptInvite('offramp', EInviteType.PAYMENT_LINK) + + expect(result.claims).toEqual([{ badgeCampaign: 'offramp', outcome: 'retryable_error' }]) + expect(destinationForInviteAcquisition(result.legacyAcquisition!, result.claims)).toBe('/home') + expect(settleAcceptedInviteAcquisition(result.legacyAcquisition!, result.claims)).toEqual({ + destination: '/home', + pending: ['offramp'], + }) + expect(getPendingBadgeCampaigns()).toEqual(['offramp']) + }) + + it('preserves the typed legacy campaign identity and destination from invite validation', async () => { + mockValidateInviteCode.mockResolvedValue({ + data: { + success: true, + attributionResolved: false, + onboardingResolved: false, + username: 'peanut', + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + }, + }) + + await expect(invitesApi.validateInviteCode('offramp')).resolves.toEqual({ + success: true, + attributionResolved: false, + onboardingResolved: false, + username: 'peanut', + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + }) + }) + + it('lets explicit false discriminators override a successful HTTP 200', async () => { + mockServerFetch.mockResolvedValue( + response(200, { + message: 'Campaign processed without invite attribution', + attributionResolved: false, + onboardingResolved: false, + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + claims: [ + { + campaignTag: 'offramp', + badgeCode: 'OFFRAMP_USER', + outcome: 'awarded', + capabilities: [], + }, + ], + }) + ) + + await expect(invitesApi.acceptInvite('offramp', EInviteType.PAYMENT_LINK)).resolves.toMatchObject({ + success: true, + attributionResolved: false, + onboardingResolved: false, + claims: [{ badgeCampaign: 'offramp', outcome: 'awarded' }], + }) + }) +}) diff --git a/src/services/__tests__/post-auth-redirect.test.ts b/src/services/__tests__/post-auth-redirect.test.ts new file mode 100644 index 0000000000..68b7ea5d44 --- /dev/null +++ b/src/services/__tests__/post-auth-redirect.test.ts @@ -0,0 +1,71 @@ +import { consumePostAuthRedirect } from '../post-auth-redirect' +import { getRedirectUrl, saveToLocalStorage } from '@/utils/general.utils' + +const FINANCIAL_REDIRECT = '/claim?step=claim&id=payment-1' +const CAMPAIGN_REDIRECT = '/add-money/crypto?network=EVM&source=offramp' + +describe('post-auth redirect consumption', () => { + beforeEach(() => localStorage.clear()) + + it('discards a lower-priority campaign destination when an explicit financial continuation wins', () => { + saveToLocalStorage('redirect', CAMPAIGN_REDIRECT) + + expect(consumePostAuthRedirect(FINANCIAL_REDIRECT)).toEqual({ + destination: FINANCIAL_REDIRECT, + source: 'explicit', + deferred: false, + }) + expect(getRedirectUrl()).toBeNull() + + // A later login cannot resurrect the superseded campaign journey. + expect(consumePostAuthRedirect(null)).toEqual({ + destination: '/home', + source: 'fallback', + deferred: false, + }) + }) + + it('also consumes stored state when a malformed explicit redirect falls back safely', () => { + saveToLocalStorage('redirect', CAMPAIGN_REDIRECT) + + expect(consumePostAuthRedirect('https://attacker.example/claim')).toEqual({ + destination: '/home', + source: 'explicit', + deferred: false, + }) + expect(getRedirectUrl()).toBeNull() + }) + + it('consumes a confirmed published campaign destination exactly once', () => { + saveToLocalStorage('redirect', CAMPAIGN_REDIRECT) + + expect(consumePostAuthRedirect(null)).toEqual({ + destination: CAMPAIGN_REDIRECT, + source: 'stored', + deferred: false, + }) + expect(getRedirectUrl()).toBeNull() + }) + + it('can deliberately retain a safe stored continuation for a post-signup action', () => { + saveToLocalStorage('redirect', FINANCIAL_REDIRECT) + + expect( + consumePostAuthRedirect(null, { + deferStoredRedirect: (destination) => destination.includes('/claim'), + }) + ).toEqual({ destination: '/home', source: 'stored', deferred: true }) + expect(getRedirectUrl()).toBe(FINANCIAL_REDIRECT) + }) + + it('never defers an unsafe stored URL merely because its text matches the predicate', () => { + saveToLocalStorage('redirect', 'https://attacker.example/claim') + + expect( + consumePostAuthRedirect(null, { + deferStoredRedirect: (destination) => destination.includes('/claim'), + }) + ).toEqual({ destination: '/home', source: 'stored', deferred: false }) + expect(getRedirectUrl()).toBeNull() + }) +}) diff --git a/src/services/__tests__/registration-acquisition.test.ts b/src/services/__tests__/registration-acquisition.test.ts new file mode 100644 index 0000000000..7a3cabc287 --- /dev/null +++ b/src/services/__tests__/registration-acquisition.test.ts @@ -0,0 +1,41 @@ +import { saveToLocalStorage } from '@/utils/general.utils' +import { persistRegistrationBadgeCampaignDestination } from '../registration-acquisition' + +jest.mock('@/utils/general.utils', () => ({ saveToLocalStorage: jest.fn() })) + +const mockSaveToLocalStorage = saveToLocalStorage as jest.MockedFunction +const acquisition = { fallback: 'normal_app' as const, destination: 'offramp_migration' as const } + +describe('new-registration campaign navigation', () => { + beforeEach(() => jest.clearAllMocks()) + + it.each(['awarded', 'already_owned'] as const)( + 'persists a bespoke destination after a confirmed %s canonical claim', + (outcome) => { + expect( + persistRegistrationBadgeCampaignDestination([{ badgeCampaign: 'offramp', outcome, acquisition }]) + ).toBe('/add-money/crypto?network=EVM&source=offramp') + expect(mockSaveToLocalStorage).toHaveBeenCalledWith( + 'redirect', + '/add-money/crypto?network=EVM&source=offramp' + ) + } + ) + + it.each(['inactive', 'expired', 'unknown', 'definition_missing', 'retryable_error'] as const)( + 'keeps the normal app destination after %s', + (outcome) => { + expect( + persistRegistrationBadgeCampaignDestination([{ badgeCampaign: 'offramp', outcome, acquisition }]) + ).toBe('/home') + expect(mockSaveToLocalStorage).not.toHaveBeenCalled() + } + ) + + it('does not invent a destination when a confirmed claim omits acquisition metadata', () => { + expect(persistRegistrationBadgeCampaignDestination([{ badgeCampaign: 'offramp', outcome: 'awarded' }])).toBe( + '/home' + ) + expect(mockSaveToLocalStorage).not.toHaveBeenCalled() + }) +}) diff --git a/src/services/acquisition-navigation.ts b/src/services/acquisition-navigation.ts new file mode 100644 index 0000000000..12f4b782e8 --- /dev/null +++ b/src/services/acquisition-navigation.ts @@ -0,0 +1,30 @@ +export type AcquisitionDestination = 'offramp_migration' | 'normal_app' + +export type AcquisitionNavigation = { + fallback: 'normal_app' + destination: AcquisitionDestination +} + +export const OFFRAMP_MIGRATION_ROUTE = '/add-money/crypto?network=EVM&source=offramp' + +const DESTINATION_ROUTES: Readonly> = { + offramp_migration: OFFRAMP_MIGRATION_ROUTE, + normal_app: '/home', +} + +export function parseAcquisitionNavigation(value: unknown): AcquisitionNavigation | undefined { + if (!value || typeof value !== 'object') return undefined + const candidate = value as Partial + if ( + candidate.fallback !== 'normal_app' || + (candidate.destination !== 'offramp_migration' && candidate.destination !== 'normal_app') + ) { + return undefined + } + return { fallback: candidate.fallback, destination: candidate.destination } +} + +/** Translate a backend-owned destination enum into a same-origin application path. */ +export function acquisitionDestinationRoute(destination: AcquisitionDestination): string { + return DESTINATION_ROUTES[destination] +} diff --git a/src/services/badge-campaigns.ts b/src/services/badge-campaigns.ts new file mode 100644 index 0000000000..bf8fd1d97d --- /dev/null +++ b/src/services/badge-campaigns.ts @@ -0,0 +1,370 @@ +import { + getPendingBadgeCampaigns, + getPendingBadgeCampaignIntentGeneration, + sanitizeBadgeCampaignIdentities, + savePendingBadgeCampaigns, +} from '@/components/Invites/badge-campaign-context' +import { serverFetch } from '@/utils/api-fetch' +import { acquisitionDestinationRoute, parseAcquisitionNavigation } from './acquisition-navigation' +import type { paths } from '@/types/api.generated' + +type GeneratedBadgeCampaignClaimRequest = paths['/badge/claims']['post']['requestBody']['content']['application/json'] +type GeneratedBadgeCampaignClaimBatch = paths['/badge/claims']['post']['responses'][200]['content']['application/json'] +type GeneratedBadgeCampaignClaim = GeneratedBadgeCampaignClaimBatch['claims'][number] +type GeneratedBadgePresentation = NonNullable + +export type BadgeCampaignClaimOutcome = + | GeneratedBadgeCampaignClaim['outcome'] + | 'retryable_error' + | 'legacy_response_unconfirmed' + +type CompatibleBadgePresentation = Omit & { + description: string | null + publicDescription?: string | null + iconUrl: string | null +} + +export type BadgeCampaignClaim = Omit & { + badge?: CompatibleBadgePresentation + outcome: BadgeCampaignClaimOutcome + httpStatus?: number +} + +export type BadgeCampaignClaimBatch = { + claims: BadgeCampaignClaim[] + transport: 'canonical' | 'legacy' +} + +type BackendBadgeCampaignClaim = Omit & { + outcome: GeneratedBadgeCampaignClaim['outcome'] +} + +const BACKEND_OUTCOMES = new Set([ + 'awarded', + 'already_owned', + 'inactive', + 'expired', + 'unknown', + 'definition_missing', +]) + +/** + * Outcomes that are fully resolved and should not loop on the next app open. + * `definition_missing` is deliberately retryable: it commonly means the API + * code deployed before its boot-seeded definition. Transport failures and an + * old endpoint's unconfirmed 200 also remain pending. + */ +const CONSUMED_OUTCOMES = new Set([ + 'awarded', + 'already_owned', + 'inactive', + 'expired', + 'unknown', +]) + +function uniqueBadgeCampaigns(badgeCampaigns: readonly string[]): string[] { + return sanitizeBadgeCampaignIdentities(badgeCampaigns) +} + +function retryableClaims(badgeCampaigns: readonly string[], httpStatus?: number): BadgeCampaignClaim[] { + return badgeCampaigns.map((badgeCampaign) => ({ + badgeCampaign, + outcome: 'retryable_error', + httpStatus, + })) +} + +function terminalUnknownClaims(badgeCampaigns: readonly string[], httpStatus: number): BadgeCampaignClaim[] { + return badgeCampaigns.map((badgeCampaign) => ({ + badgeCampaign, + outcome: 'unknown', + httpStatus, + })) +} + +function isRetryableHttpStatus(status: number): boolean { + return ( + status === 401 || + status === 404 || + status === 405 || + status === 408 || + status === 425 || + status === 429 || + status >= 500 + ) +} + +type BadgePresentation = NonNullable + +function parseBadgePresentation(value: unknown): BadgePresentation | undefined { + if (!value || typeof value !== 'object') return undefined + const badge = value as { + code?: unknown + name?: unknown + description?: unknown + publicDescription?: unknown + iconUrl?: unknown + } + if ( + typeof badge.code !== 'string' || + typeof badge.name !== 'string' || + (typeof badge.description !== 'string' && badge.description !== null) || + (badge.publicDescription !== undefined && + typeof badge.publicDescription !== 'string' && + badge.publicDescription !== null) || + (typeof badge.iconUrl !== 'string' && badge.iconUrl !== null) + ) { + return undefined + } + + return { + code: badge.code, + name: badge.name, + description: badge.description, + ...(badge.publicDescription !== undefined ? { publicDescription: badge.publicDescription } : {}), + iconUrl: badge.iconUrl, + } +} + +/** + * Project the public response onto acquisition state the client understands. + * Badge provenance is audit metadata, not a trust tier: confirmed public awards + * retain the badge's existing product semantics. Unknown policy/reward fields + * are ignored, while the small destination enum is validated before routing. + */ +function parseBackendBadgeCampaignClaim(value: unknown): BackendBadgeCampaignClaim | undefined { + if (!value || typeof value !== 'object') return undefined + const claim = value as { + badgeCampaign?: unknown + campaignTag?: unknown + badgeCode?: unknown + badge?: unknown + outcome?: unknown + acquisition?: unknown + } + // During rolling deploys, accept the published legacy echo only when the + // canonical field is absent. Presence of a malformed canonical value is a + // malformed response, never permission to reinterpret a second field. + const hasCanonicalBadgeCampaign = Object.prototype.hasOwnProperty.call(claim, 'badgeCampaign') + const badgeCampaign = hasCanonicalBadgeCampaign ? claim.badgeCampaign : claim.campaignTag + const badge = claim.badge === undefined ? undefined : parseBadgePresentation(claim.badge) + const acquisition = claim.acquisition === undefined ? undefined : parseAcquisitionNavigation(claim.acquisition) + if ( + typeof badgeCampaign !== 'string' || + badgeCampaign.length === 0 || + typeof claim.outcome !== 'string' || + !BACKEND_OUTCOMES.has(claim.outcome as BackendBadgeCampaignClaim['outcome']) || + (claim.badgeCode !== undefined && typeof claim.badgeCode !== 'string') || + (claim.badge !== undefined && !badge) + ) { + return undefined + } + + return { + badgeCampaign, + outcome: claim.outcome as BackendBadgeCampaignClaim['outcome'], + ...(typeof claim.badgeCode === 'string' ? { badgeCode: claim.badgeCode } : {}), + ...(badge ? { badge } : {}), + ...(acquisition ? { acquisition } : {}), + } +} + +async function responseJson(response: Response): Promise { + try { + return await response.json() + } catch { + return null + } +} + +function alignClaims( + requested: readonly string[], + received: readonly BackendBadgeCampaignClaim[] +): BadgeCampaignClaim[] { + return requested.map((badgeCampaign) => { + const claim = received.find( + (candidate) => candidate.badgeCampaign.toLowerCase() === badgeCampaign.toLowerCase() + ) + return ( + claim ?? { + badgeCampaign, + outcome: 'retryable_error', + } + ) + }) +} + +/** + * Runtime boundary shared by canonical acquisition and compatibility responses + * such as `/invites/accept`. Generated types catch compile-time drift; this + * validator prevents a partial rolling-deploy response from becoming a false + * success in the browser. + */ +export function badgeCampaignClaimsFromPayload(payload: unknown, requested: readonly string[]): BadgeCampaignClaim[] { + const received = + payload && typeof payload === 'object' && Array.isArray((payload as { claims?: unknown }).claims) + ? (payload as { claims: unknown[] }).claims.flatMap((claim) => { + const parsed = parseBackendBadgeCampaignClaim(claim) + return parsed ? [parsed] : [] + }) + : [] + return alignClaims(uniqueBadgeCampaigns(requested), received) +} + +async function claimThroughLegacyEndpoint(badgeCampaigns: readonly string[]): Promise { + const claims: BadgeCampaignClaim[] = [] + + for (const badgeCampaign of badgeCampaigns) { + try { + const response = await serverFetch('/badge/award', { + method: 'POST', + // Published compatibility field; do not rename. + body: JSON.stringify({ campaignTag: badgeCampaign }), + }) + const body = await responseJson(response) + const structured = + body && typeof body === 'object' + ? ((body as { claim?: unknown; claims?: unknown }).claim ?? + (Array.isArray((body as { claims?: unknown }).claims) + ? (body as { claims: unknown[] }).claims[0] + : undefined)) + : undefined + + const parsedClaim = parseBackendBadgeCampaignClaim(structured) + if (response.ok && parsedClaim?.badgeCampaign.toLowerCase() === badgeCampaign.toLowerCase()) { + claims.push(parsedClaim) + } else if (response.ok) { + // The old endpoint returned 200 even when awardBadge no-op'd. + // Preserve the campaign for the canonical endpoint to verify. + claims.push({ badgeCampaign, outcome: 'legacy_response_unconfirmed' }) + } else if (isRetryableHttpStatus(response.status)) { + // The historical endpoint used 400 for a non-claimable tag. + // Its 404/405 therefore means the compatibility route itself + // is absent during a rolling deploy, not a terminal outcome. + claims.push(...retryableClaims([badgeCampaign], response.status)) + } else { + // Legacy 400 meant "not claimable" but exposed no typed reason. + // Consume it as terminal unknown rather than looping forever. + claims.push(...terminalUnknownClaims([badgeCampaign], response.status)) + } + } catch { + claims.push(...retryableClaims([badgeCampaign])) + } + } + + return { claims, transport: 'legacy' } +} + +async function requestBadgeCampaignClaims(badgeCampaigns: readonly string[]): Promise { + try { + const response = await serverFetch('/badge/claims', { + method: 'POST', + body: JSON.stringify({ badgeCampaigns: [...badgeCampaigns] } satisfies GeneratedBadgeCampaignClaimRequest), + }) + + // Compatibility is deliberately narrow: only an API version that does + // not expose the canonical route may use the legacy endpoint. + if (response.status === 404 || response.status === 405) { + return claimThroughLegacyEndpoint(badgeCampaigns) + } + + if (!response.ok) { + return { + claims: isRetryableHttpStatus(response.status) + ? retryableClaims(badgeCampaigns, response.status) + : terminalUnknownClaims(badgeCampaigns, response.status), + transport: 'canonical', + } + } + + const body = await responseJson(response) + return { + claims: badgeCampaignClaimsFromPayload(body, badgeCampaigns), + transport: 'canonical', + } + } catch { + return { claims: retryableClaims(badgeCampaigns), transport: 'canonical' } + } +} + +const inFlight = new Map>() + +/** The sole frontend badge-acquisition call. Badge campaign identities stay opaque. */ +export function claimBadgeCampaigns(rawBadgeCampaigns: readonly string[]): Promise { + const badgeCampaigns = uniqueBadgeCampaigns(rawBadgeCampaigns) + if (badgeCampaigns.length === 0) return Promise.resolve({ claims: [], transport: 'canonical' }) + + // Deduplicate only within one account-intent generation. Reusing an old + // account's authenticated response after explicit logout could otherwise + // consume the next account's identical pending campaign without awarding it. + const key = JSON.stringify([getPendingBadgeCampaignIntentGeneration(), badgeCampaigns]) + const existing = inFlight.get(key) + if (existing) return existing + + const request = requestBadgeCampaignClaims(badgeCampaigns).finally(() => inFlight.delete(key)) + inFlight.set(key, request) + return request +} + +export function pendingBadgeCampaignsAfterClaims( + requested: readonly string[], + claims: readonly BadgeCampaignClaim[] +): string[] { + return uniqueBadgeCampaigns(requested).filter((badgeCampaign) => { + const claim = claims.find((candidate) => candidate.badgeCampaign.toLowerCase() === badgeCampaign.toLowerCase()) + return !claim || !CONSUMED_OUTCOMES.has(claim.outcome) + }) +} + +export function settlePendingBadgeCampaigns( + requested: readonly string[], + claims: readonly BadgeCampaignClaim[], + expectedIntentGeneration = getPendingBadgeCampaignIntentGeneration() +): string[] { + // Explicit logout is an account-boundary write and must win over a stale + // response. Passive auth expiry never advances this generation, so ordinary + // offline/configuration retries remain durable. + if (expectedIntentGeneration !== getPendingBadgeCampaignIntentGeneration()) return getPendingBadgeCampaigns() + + const requestedKeys = new Set(uniqueBadgeCampaigns(requested).map((badgeCampaign) => badgeCampaign.toLowerCase())) + const untouched = getPendingBadgeCampaigns().filter( + (badgeCampaign) => !requestedKeys.has(badgeCampaign.toLowerCase()) + ) + const pending = uniqueBadgeCampaigns([...untouched, ...pendingBadgeCampaignsAfterClaims(requested, claims)]) + savePendingBadgeCampaigns(pending, pending.length > 0 ? 30 : undefined) + return pending +} + +export async function claimAndSettlePendingBadgeCampaigns( + requested: readonly string[] = getPendingBadgeCampaigns() +): Promise { + const intentGeneration = getPendingBadgeCampaignIntentGeneration() + const batch = await claimBadgeCampaigns(requested) + return { ...batch, pending: settlePendingBadgeCampaigns(requested, batch.claims, intentGeneration) } +} + +export function isConfirmedBadgeCampaignClaim(claim: BadgeCampaignClaim): boolean { + return claim.outcome === 'awarded' || claim.outcome === 'already_owned' +} + +/** + * Badge campaign navigation comes only from a confirmed canonical claim. Missing, + * malformed, unavailable, and retryable claims use the declared normal-app + * fallback. Multiple different bespoke destinations fail closed as well. + */ +export function destinationForConfirmedBadgeCampaignAcquisition(claims: readonly BadgeCampaignClaim[]): string { + const nonDefaultDestinations = new Set( + claims.flatMap((claim) => { + if (!isConfirmedBadgeCampaignClaim(claim) || !claim.acquisition) return [] + const { destination, fallback } = claim.acquisition + return destination === fallback ? [] : [destination] + }) + ) + + if (nonDefaultDestinations.size !== 1) return acquisitionDestinationRoute('normal_app') + return acquisitionDestinationRoute([...nonDefaultDestinations][0]) +} + +export function isUnavailableBadgeCampaignClaim(claim: BadgeCampaignClaim): boolean { + return claim.outcome === 'inactive' || claim.outcome === 'expired' || claim.outcome === 'unknown' +} diff --git a/src/services/invite-acquisition.ts b/src/services/invite-acquisition.ts new file mode 100644 index 0000000000..7783572a74 --- /dev/null +++ b/src/services/invite-acquisition.ts @@ -0,0 +1,54 @@ +import { isConfirmedBadgeCampaignClaim, settlePendingBadgeCampaigns, type BadgeCampaignClaim } from './badge-campaigns' +import { + acquisitionDestinationRoute, + parseAcquisitionNavigation, + type AcquisitionNavigation, +} from './acquisition-navigation' + +export type LegacyInviteAcquisition = AcquisitionNavigation & { + campaignTag: string +} + +/** + * Compatibility adapter for already-published legacy invite links. The backend + * owns the opaque campaign identity and destination enum; this client never + * infers either from the invite code or from badge provenance. + */ +export function parseLegacyInviteAcquisition(value: unknown): LegacyInviteAcquisition | undefined { + if (!value || typeof value !== 'object') return undefined + const candidate = value as { campaignTag?: unknown } + const navigation = parseAcquisitionNavigation(value) + if (typeof candidate.campaignTag !== 'string' || candidate.campaignTag.trim().length === 0 || !navigation) + return undefined + return { + campaignTag: candidate.campaignTag.trim(), + ...navigation, + } +} + +/** + * A bespoke compatibility destination is usable only after the matching badge + * claim is confirmed. Every other outcome follows the descriptor's safe fallback. + */ +export function destinationForInviteAcquisition( + acquisition: LegacyInviteAcquisition, + claims: readonly BadgeCampaignClaim[] +): string { + const matchingClaim = claims.find( + (claim) => claim.badgeCampaign.toLowerCase() === acquisition.campaignTag.toLowerCase() + ) + return acquisitionDestinationRoute( + matchingClaim && isConfirmedBadgeCampaignClaim(matchingClaim) ? acquisition.destination : acquisition.fallback + ) +} + +/** Consume the claim batch returned by signed-out registration's invite accept. */ +export function settleAcceptedInviteAcquisition( + acquisition: LegacyInviteAcquisition, + claims: readonly BadgeCampaignClaim[] +): { destination: string; pending: string[] } { + return { + destination: destinationForInviteAcquisition(acquisition, claims), + pending: settlePendingBadgeCampaigns([acquisition.campaignTag], claims), + } +} diff --git a/src/services/invite-response.ts b/src/services/invite-response.ts new file mode 100644 index 0000000000..b154ef579c --- /dev/null +++ b/src/services/invite-response.ts @@ -0,0 +1,58 @@ +import { parseLegacyInviteAcquisition } from './invite-acquisition' + +export type InviteResolutionFlags = { + attributionResolved: boolean + onboardingResolved: boolean +} + +function hasLegacyAcquisitionDescriptor(value: unknown): boolean { + return !!parseLegacyInviteAcquisition(value) +} + +/** + * Resolve rollout discriminators without letting partial or explicit-false + * responses become access. A pre-discriminator 200 may use its historical + * success signal only when both new fields are completely absent. + */ +export function resolveInviteResolutionFlags(payload: unknown, legacyNoFlagsResolved: boolean): InviteResolutionFlags { + if (!payload || typeof payload !== 'object') { + return { attributionResolved: false, onboardingResolved: false } + } + + const body = payload as { attributionResolved?: unknown; onboardingResolved?: unknown } + const hasAttribution = Object.hasOwn(body, 'attributionResolved') + const hasOnboarding = Object.hasOwn(body, 'onboardingResolved') + + if (!hasAttribution && !hasOnboarding) { + return { + attributionResolved: legacyNoFlagsResolved, + onboardingResolved: legacyNoFlagsResolved, + } + } + + // Either explicit false wins over every legacy hint. A partial rollout + // shape (only one discriminator) also fails closed. + if (body.attributionResolved === false || body.onboardingResolved === false || !hasAttribution || !hasOnboarding) { + return { attributionResolved: false, onboardingResolved: false } + } + + const resolved = body.attributionResolved === true && body.onboardingResolved === true + return { attributionResolved: resolved, onboardingResolved: resolved } +} + +/** New APIs use a typed 409 so old clients safely stop at non-2xx. */ +export function isTypedCampaignOnlyInviteResponse(payload: unknown): boolean { + if (!payload || typeof payload !== 'object') return false + const body = payload as { + message?: unknown + attributionResolved?: unknown + onboardingResolved?: unknown + legacyAcquisition?: unknown + } + return ( + typeof body.message === 'string' && + body.attributionResolved === false && + body.onboardingResolved === false && + hasLegacyAcquisitionDescriptor(body.legacyAcquisition) + ) +} diff --git a/src/services/invites.ts b/src/services/invites.ts index ba11a41ef7..0357b70de5 100644 --- a/src/services/invites.ts +++ b/src/services/invites.ts @@ -4,27 +4,63 @@ import { toInviteCode } from '@/utils/general.utils' import { isCapacitor } from '@/utils/capacitor' import { enableDemoMode, isDemoInviteCode } from '@/utils/demo' import { EInviteType, type PointsInvitesResponse } from './services.types' +import { badgeCampaignClaimsFromPayload, type BadgeCampaignClaim } from './badge-campaigns' +import { parseLegacyInviteAcquisition, type LegacyInviteAcquisition } from './invite-acquisition' +import { isTypedCampaignOnlyInviteResponse, resolveInviteResolutionFlags } from './invite-response' + +export type AcceptInviteResult = { + success: boolean + attributionResolved: boolean + onboardingResolved: boolean + claims: BadgeCampaignClaim[] + legacyAcquisition?: LegacyInviteAcquisition +} + +export type ValidateInviteResult = { + success: boolean + attributionResolved: boolean + onboardingResolved: boolean + username: string + legacyAcquisition?: LegacyInviteAcquisition +} export const invitesApi = { - acceptInvite: async ( - inviteCode: string, - type: EInviteType, - campaignTag?: string - ): Promise<{ success: boolean }> => { + acceptInvite: async (inviteCode: string, type: EInviteType, campaignTag?: string): Promise => { try { const response = await serverFetch('/invites/accept', { method: 'POST', // Normalize here so hand-typed input (`@alice `, ` Alice`) works no // matter which screen collected it. Legacy ALICEINVITESYOU610 codes // pass through unchanged in meaning — the BE uppercases before parsing. + // Inviter attribution and campaign acquisition remain separate + // fields. Published send links still carry one opaque campaign + // tag through this compatibility call. body: JSON.stringify({ inviteCode: toInviteCode(inviteCode), type, campaignTag }), }) - if (!response.ok) { - return { success: false } + const body: unknown = await response.json() + const typedCampaignOnly = + response.status === 409 && + isTypedCampaignOnlyInviteResponse(body) && + !!body && + typeof body === 'object' && + Array.isArray((body as { claims?: unknown }).claims) + if (!response.ok && !typedCampaignOnly) { + return { success: false, attributionResolved: false, onboardingResolved: false, claims: [] } + } + const legacyAcquisition = parseLegacyInviteAcquisition( + body && typeof body === 'object' + ? (body as { legacyAcquisition?: unknown }).legacyAcquisition + : undefined + ) + const resolution = resolveInviteResolutionFlags(body, response.ok) + return { + success: true, + ...resolution, + claims: legacyAcquisition ? badgeCampaignClaimsFromPayload(body, [legacyAcquisition.campaignTag]) : [], + ...(legacyAcquisition ? { legacyAcquisition } : {}), } - return { success: true } } catch { - return { success: false } + return { success: false, attributionResolved: false, onboardingResolved: false, claims: [] } } }, @@ -44,7 +80,7 @@ export const invitesApi = { } }, - validateInviteCode: async (inviteCode: string): Promise<{ success: boolean; username: string }> => { + validateInviteCode: async (inviteCode: string): Promise => { try { const res = await validateInviteCode(toInviteCode(inviteCode)) // demo code enables demo mode. @@ -53,11 +89,14 @@ export const invitesApi = { } return { success: res.data?.success || false, + attributionResolved: res.data?.attributionResolved === true, + onboardingResolved: res.data?.onboardingResolved === true, username: res.data?.username || '', + legacyAcquisition: res.data?.legacyAcquisition, } } catch (e) { console.error('Error validating invite code:', e) - return { success: false, username: '' } + return { success: false, attributionResolved: false, onboardingResolved: false, username: '' } } }, @@ -78,20 +117,4 @@ export const invitesApi = { return { success: false, position: 0 } } }, - - awardBadge: async (campaignTag: string): Promise<{ success: boolean }> => { - try { - const response = await serverFetch('/badge/award', { - method: 'POST', - body: JSON.stringify({ campaignTag }), - }) - if (!response.ok) { - return { success: false } - } - return { success: true } - } catch (e) { - console.error('Error awarding badge:', e) - return { success: false } - } - }, } diff --git a/src/services/post-auth-redirect.ts b/src/services/post-auth-redirect.ts new file mode 100644 index 0000000000..a389cae05e --- /dev/null +++ b/src/services/post-auth-redirect.ts @@ -0,0 +1,59 @@ +import { clearRedirectUrl, getRedirectUrl, getValidRedirectUrl } from '@/utils/general.utils' + +export type PostAuthRedirectDecision = { + destination: string + source: 'explicit' | 'stored' | 'fallback' + deferred: boolean +} + +type PostAuthRedirectOptions = { + fallbackRoute?: string + /** + * Keep a safe stored intent for a later post-signup action (for example, + * showing the bank-claim continuation after identity verification). + */ + deferStoredRedirect?: (destination: string) => boolean +} + +/** + * Select and consume post-auth navigation in one place. + * + * An explicit `redirect_uri` always outranks generic stored state. Selecting + * it also discards that lower-priority state, including when the explicit URL + * is malformed and resolves to the safe fallback; otherwise an acquisition + * destination can unexpectedly resurrect during a later login. + * + * Stored state is one-shot by default. A caller may deliberately defer a safe + * stored destination for a later post-signup action, in which case it remains + * available and this function routes to the fallback for now. + */ +export function consumePostAuthRedirect( + explicitRedirectUri: string | null, + options: PostAuthRedirectOptions = {} +): PostAuthRedirectDecision { + const fallbackRoute = options.fallbackRoute ?? '/home' + + if (explicitRedirectUri !== null) { + clearRedirectUrl() + return { + destination: getValidRedirectUrl(explicitRedirectUri, fallbackRoute), + source: 'explicit', + deferred: false, + } + } + + const storedValue = getRedirectUrl() + if (typeof storedValue === 'string' && storedValue.length > 0) { + const destination = getValidRedirectUrl(storedValue, fallbackRoute) + if (destination !== fallbackRoute && options.deferStoredRedirect?.(destination)) { + return { destination: fallbackRoute, source: 'stored', deferred: true } + } + + clearRedirectUrl() + return { destination, source: 'stored', deferred: false } + } + + // Corrupt or blank generic state is no more reusable than an unsafe URL. + if (storedValue !== null && storedValue !== undefined) clearRedirectUrl() + return { destination: fallbackRoute, source: 'fallback', deferred: false } +} diff --git a/src/services/registration-acquisition.ts b/src/services/registration-acquisition.ts new file mode 100644 index 0000000000..6174f57ced --- /dev/null +++ b/src/services/registration-acquisition.ts @@ -0,0 +1,13 @@ +import { saveToLocalStorage } from '@/utils/general.utils' +import { destinationForConfirmedBadgeCampaignAcquisition, type BadgeCampaignClaim } from './badge-campaigns' + +/** + * Registration settles queued URL badge campaigns after authentication. Preserve a + * bespoke destination for the remaining setup flow only when the canonical + * claim is confirmed; every other outcome leaves normal navigation untouched. + */ +export function persistRegistrationBadgeCampaignDestination(claims: readonly BadgeCampaignClaim[]): string { + const destination = destinationForConfirmedBadgeCampaignAcquisition(claims) + if (destination !== '/home') saveToLocalStorage('redirect', destination) + return destination +} diff --git a/src/services/users.ts b/src/services/users.ts index 7dd18ee52a..683393d49b 100644 --- a/src/services/users.ts +++ b/src/services/users.ts @@ -37,6 +37,7 @@ export type ApiUser = { code: string name: string description: string | null + publicDescription?: string | null iconUrl: string | null color?: string | null earnedAt?: string diff --git a/src/types/api.generated.ts b/src/types/api.generated.ts index 61d760d396..9c8aed921f 100644 --- a/src/types/api.generated.ts +++ b/src/types/api.generated.ts @@ -226,7 +226,7 @@ export interface paths { patch?: never; trace?: never; }; - "/ens/{ensName}": { + "/ens/reverse/{address}": { parameters: { query?: never; header?: never; @@ -235,12 +235,10 @@ export interface paths { }; get: { parameters: { - query?: { - chainId?: number; - }; + query?: never; header?: never; path: { - ensName: string; + address: string; }; cookie?: never; }; @@ -263,7 +261,7 @@ export interface paths { patch?: never; trace?: never; }; - "/ens/reverse/{address}": { + "/ens/{ensName}": { parameters: { query?: never; header?: never; @@ -272,10 +270,12 @@ export interface paths { }; get: { parameters: { - query?: never; + query?: { + chainId?: number; + }; header?: never; path: { - address: string; + ensName: string; }; cookie?: never; }; @@ -1881,6 +1881,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -1892,6 +1893,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -1903,6 +1905,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -3089,7 +3092,23 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Default Response */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + code?: string; + }; + }; }; }; }; @@ -3400,6 +3419,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -3411,6 +3431,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -3422,6 +3443,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -3494,6 +3516,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -3505,6 +3528,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -3516,6 +3540,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -3527,6 +3552,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; retryAfterSec?: number; }; }; @@ -3539,6 +3565,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -3550,6 +3577,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -3605,6 +3633,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -3616,6 +3645,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -3679,6 +3709,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -3690,6 +3721,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -3714,6 +3746,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -3725,6 +3758,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -4486,7 +4520,94 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + message: string; + /** @enum {boolean} */ + attributionResolved: true; + /** @enum {boolean} */ + onboardingResolved: true; + attributionKind: "PERSONAL" | "SYSTEM" | "LEGACY_UNRESOLVED"; + claims: { + /** @description Opaque badge-acquisition campaign value. New integrations use the badge_campaign slug. */ + badgeCampaign: string; + /** @description Stable badge code. Clients must tolerate codes added after SDK generation. */ + badgeCode?: string; + badge?: { + /** @description Stable badge code. Clients must tolerate codes added after SDK generation. */ + code: string; + name: string; + description: string; + publicDescription: string; + iconUrl: string; + }; + outcome: "awarded" | "already_owned" | "inactive" | "expired" | "unknown" | "definition_missing"; + acquisition?: { + /** @enum {string} */ + fallback: "normal_app"; + destination: "offramp_migration" | "normal_app"; + }; + }[]; + legacyAcquisition?: { + campaignTag: string; + /** @enum {string} */ + fallback: "normal_app"; + destination: "offramp_migration" | "normal_app"; + }; + }; + }; + }; + /** @description Default Response */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Default Response */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + message: string; + /** @enum {boolean} */ + attributionResolved: false; + /** @enum {boolean} */ + onboardingResolved: false; + claims: { + /** @description Opaque badge-acquisition campaign value. New integrations use the badge_campaign slug. */ + badgeCampaign: string; + /** @description Stable badge code. Clients must tolerate codes added after SDK generation. */ + badgeCode?: string; + badge?: { + /** @description Stable badge code. Clients must tolerate codes added after SDK generation. */ + code: string; + name: string; + description: string; + publicDescription: string; + iconUrl: string; + }; + outcome: "awarded" | "already_owned" | "inactive" | "expired" | "unknown" | "definition_missing"; + acquisition?: { + /** @enum {string} */ + fallback: "normal_app"; + destination: "offramp_migration" | "normal_app"; + }; + }[]; + legacyAcquisition?: { + campaignTag: string; + /** @enum {string} */ + fallback: "normal_app"; + destination: "offramp_migration" | "normal_app"; + }; + }; + }; }; }; }; @@ -4558,7 +4679,55 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + message: string; + /** @enum {boolean} */ + attributionResolved: true; + /** @enum {boolean} */ + onboardingResolved: true; + attributionKind: "PERSONAL" | "SYSTEM" | "LEGACY_UNRESOLVED"; + username: string; + legacyAcquisition?: { + campaignTag: string; + /** @enum {string} */ + fallback: "normal_app"; + destination: "offramp_migration" | "normal_app"; + }; + }; + }; + }; + /** @description Default Response */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Default Response */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + message: string; + /** @enum {boolean} */ + attributionResolved: false; + /** @enum {boolean} */ + onboardingResolved: false; + legacyAcquisition?: { + campaignTag: string; + /** @enum {string} */ + fallback: "normal_app"; + destination: "offramp_migration" | "normal_app"; + }; + }; + }; }; }; }; @@ -5014,6 +5183,70 @@ export interface paths { patch?: never; trace?: never; }; + "/badge/claims": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header: { + Authorization: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + badgeCampaigns: string[]; + }; + }; + }; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + claims: { + /** @description Opaque badge-acquisition campaign value. New integrations use the badge_campaign slug. */ + badgeCampaign: string; + /** @description Stable badge code. Clients must tolerate codes added after SDK generation. */ + badgeCode?: string; + badge?: { + /** @description Stable badge code. Clients must tolerate codes added after SDK generation. */ + code: string; + name: string; + description: string; + publicDescription: string; + iconUrl: string; + }; + outcome: "awarded" | "already_owned" | "inactive" | "expired" | "unknown" | "definition_missing"; + acquisition?: { + /** @enum {string} */ + fallback: "normal_app"; + destination: "offramp_migration" | "normal_app"; + }; + }[]; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/badge/award": { parameters: { query?: never; @@ -5035,6 +5268,7 @@ export interface paths { requestBody: { content: { "application/json": { + /** @description Opaque badge-acquisition campaign value. New integrations use the badge_campaign slug. */ campaignTag: string; }; }; @@ -5045,7 +5279,31 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + message: string; + claim: { + /** @description Opaque badge-acquisition campaign value. New integrations use the badge_campaign slug. */ + badgeCampaign: string; + /** @description Stable badge code. Clients must tolerate codes added after SDK generation. */ + badgeCode?: string; + badge?: { + /** @description Stable badge code. Clients must tolerate codes added after SDK generation. */ + code: string; + name: string; + description: string; + publicDescription: string; + iconUrl: string; + }; + outcome: "awarded" | "already_owned" | "inactive" | "expired" | "unknown" | "definition_missing"; + acquisition?: { + /** @enum {string} */ + fallback: "normal_app"; + destination: "offramp_migration" | "normal_app"; + }; + }; + }; + }; }; }; }; @@ -6105,7 +6363,6 @@ export interface paths { hasCardAccess: boolean; isEligible: boolean; eligibilityReason?: string; - geoProhibited?: boolean; flowEarlyAccess: boolean; isPublicLaunched: boolean; waitlistJoinedAt: string | null; @@ -6505,10 +6762,6 @@ export interface paths { addressCountry: string | null; idDocumentCountry: string | null; }; - } | { - /** @enum {string} */ - status: "geo-blocked"; - message: string; } | { status: string; rainUserId?: string; @@ -6789,6 +7042,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -6800,6 +7054,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -6865,6 +7120,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -6876,6 +7132,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -6921,6 +7178,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -6958,6 +7216,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -7009,6 +7268,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -7020,6 +7280,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -7072,6 +7333,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -7083,6 +7345,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -7137,6 +7400,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -7148,6 +7412,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -7159,6 +7424,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -7228,6 +7494,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -7239,6 +7506,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -7250,6 +7518,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -7261,6 +7530,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -7307,6 +7577,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -7364,6 +7635,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -7375,6 +7647,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -7429,6 +7702,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -7440,6 +7714,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -7451,6 +7726,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -7501,6 +7777,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -7512,6 +7789,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -7523,6 +7801,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -7564,6 +7843,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -7575,6 +7855,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -7586,6 +7867,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -7597,6 +7879,7 @@ export interface paths { content: { "application/json": { error: string; + code?: string; }; }; }; @@ -8777,6 +9060,39 @@ export interface paths { patch?: never; trace?: never; }; + "/crisp/webhooks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/dev/cheats/approve-kyc": { parameters: { query?: never; @@ -9021,7 +9337,8 @@ export interface paths { content: { "application/json": { userId: string; - code: "BETA_TESTER" | "DEVCONNECT_BA_2025" | "PRODUCT_HUNT" | "OG_2025_10_12" | "SEEDLING_DEVCONNECT_BA_2025" | "ARBIVERSE_DEVCONNECT_BA_2025" | "CARD_PIONEER" | "FOUNDER_HOUSE" | "BUG_WHISPERER" | "SHHHHH" | "NOT_SO_SHHHH" | "CARD_FIRST_SWIPE" | "CARD_SPENT_1K" | "CARD_ALPHA" | "TOKEN_NATION_SP_2026" | "ETHFLORIPA_HUB" | "IRL_NOMADS" | "EVENT_ALUMNI" | "TOUCHED_GRASS" | "OFFRAMP_USER" | "PSYOPS_DIVISION" | "WAITLIST_SKIP" | "FESTA_JUNINA_2026" | "MANICERO" | "NITA" | "NAIJA" | "TERERE"; + /** @description Stable badge code. Clients must tolerate codes added after SDK generation. */ + code: string; revoke?: boolean; }; }; diff --git a/src/types/api.openapi.json b/src/types/api.openapi.json index 904a674de7..70e155aa49 100644 --- a/src/types/api.openapi.json +++ b/src/types/api.openapi.json @@ -1,14904 +1,18006 @@ { - "openapi": "3.0.3", - "info": { - "title": "peanut-api", - "version": "1.0.0" - }, - "components": { - "schemas": {} - }, - "paths": { - "/": { - "get": { - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/test-error": { - "get": { - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/apple-app-site-association": { - "get": { - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/assetLinks.json": { - "get": { - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/claim": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "chainId": { - "pattern": "^[0-9]+$", - "type": "string" - }, - "version": { - "default": "v4.3", - "anyOf": [ - { - "type": "string", - "enum": ["v4.3"] - }, - { - "type": "string", - "enum": ["v4.4"] - } - ] - }, - "claimParams": { - "minItems": 3, - "type": "array", - "items": {} - }, - "withMFA": { - "default": false, - "type": "boolean" - }, - "depositDetails": { - "type": "object", - "properties": { - "pubKey20": { - "type": "string" - }, - "amount": { - "type": "string" - }, - "tokenAddress": { - "type": "string" - }, - "contractType": { - "type": "number" - }, - "claimed": { - "type": "boolean" - }, - "requiresMFA": { - "type": "boolean" - }, - "timestamp": { - "type": "number" - }, - "tokenId": { - "type": "string" - }, - "senderAddress": { - "type": "string" - } - }, - "required": [ - "pubKey20", - "amount", - "tokenAddress", - "contractType", - "claimed", - "requiresMFA", - "timestamp", - "tokenId", - "senderAddress" - ] - }, - "optimisticReturn": { - "type": "boolean" - }, - "campaignTag": { - "type": "string" - } - }, - "required": ["chainId", "claimParams"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/healthz": { - "get": { - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/ens/{ensName}": { - "get": { - "parameters": [ - { - "schema": { - "type": "integer", - "minimum": 1 - }, - "in": "query", - "name": "chainId", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "ensName", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/ens/reverse/{address}": { - "get": { - "parameters": [ - { - "schema": { - "pattern": "^0x[a-fA-F0-9]{40}$", - "type": "string" - }, - "in": "path", - "name": "address", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/add-account": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "accountIdentifier": { - "type": "string" - }, - "accountType": { - "anyOf": [ - { - "type": "string", - "enum": ["iban"] - }, - { - "type": "string", - "enum": ["us"] - }, - { - "type": "string", - "enum": ["evm-address"] - }, - { - "type": "string", - "enum": ["peanut-wallet"] - }, - { - "type": "string", - "enum": ["bridgeBankAccount"] - }, - { - "type": "string", - "enum": ["manteca"] - }, - { - "type": "string", - "enum": ["clabe"] - }, - { - "type": "string", - "enum": ["cbu"] - }, - { - "type": "string", - "enum": ["cvu"] - }, - { - "type": "string", - "enum": ["pix"] - } - ] - }, - "userId": { - "type": "string" - }, - "bridgeAccountIdentifier": { - "type": "string" - }, - "chainId": { - "type": "string" - }, - "telegramHandle": { - "type": "string" - } - }, - "required": ["accountIdentifier", "accountType", "userId"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/users/username/{username}": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "username", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/users/me": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object", - "properties": { - "capabilities": { - "type": "object", - "properties": { - "rails": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "description": "`${provider}.${method}` e.g. \"bridge.ach_us\"", - "type": "string" - }, - "provider": { - "anyOf": [ - { - "type": "string", - "enum": ["bridge"] - }, - { - "type": "string", - "enum": ["manteca"] - }, - { - "type": "string", - "enum": ["rain"] - } - ] - }, - "method": { - "type": "string" - }, - "channel": { - "anyOf": [ - { - "type": "string", - "enum": ["bank"] - }, - { - "type": "string", - "enum": ["card"] - }, - { - "type": "string", - "enum": ["qr-only"] - } - ] - }, - "country": { - "type": "string" - }, - "currency": { - "type": "string" - }, - "status": { - "anyOf": [ - { - "type": "string", - "enum": ["enabled"] - }, - { - "type": "string", - "enum": ["pending"] - }, - { - "type": "string", - "enum": ["requires-info"] - }, - { - "type": "string", - "enum": ["blocked"] - } - ] - }, - "operations": { - "type": "object", - "properties": { - "pay": { - "anyOf": [ - { - "type": "string", - "enum": ["enabled"] - }, - { - "type": "string", - "enum": ["pending"] - }, - { - "type": "string", - "enum": ["requires-info"] - }, - { - "type": "string", - "enum": ["blocked"] - } - ] - }, - "deposit": { - "anyOf": [ - { - "type": "string", - "enum": ["enabled"] - }, - { - "type": "string", - "enum": ["pending"] - }, - { - "type": "string", - "enum": ["requires-info"] - }, - { - "type": "string", - "enum": ["blocked"] - } - ] - }, - "withdraw": { - "anyOf": [ - { - "type": "string", - "enum": ["enabled"] - }, - { - "type": "string", - "enum": ["pending"] - }, - { - "type": "string", - "enum": ["requires-info"] - }, - { - "type": "string", - "enum": ["blocked"] - } - ] - } - } - }, - "blockingActions": { - "type": "array", - "items": { - "type": "string" - } - }, - "hintActions": { - "type": "array", - "items": { - "type": "string" - } - }, - "reason": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "userMessage": { - "type": "string" - }, - "details": { - "type": "string" - } - }, - "required": ["code", "userMessage"] - }, - "resolved": { - "type": "object", - "properties": { - "status": { - "anyOf": [ - { - "type": "string", - "enum": ["enabled"] - }, - { - "type": "string", - "enum": ["pending"] - }, - { - "type": "string", - "enum": ["fixable"] - }, - { - "type": "string", - "enum": ["blocked"] - } - ] - }, - "blocking": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "userMessage": { - "type": "string" - }, - "selfHealable": { - "type": "boolean" - }, - "selfHealKind": { - "anyOf": [ - { - "type": "string", - "enum": ["document-resubmit"] - }, - { - "type": "string", - "enum": ["restart-identity"] - }, - { - "type": "string", - "enum": ["provide-email"] - }, - { - "type": "string", - "enum": ["contact-support"] - } - ] - }, - "details": { - "type": "string" - } - }, - "required": [ - "code", - "userMessage", - "selfHealable" - ] - }, - "nextAction": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "kind": { - "anyOf": [ - { - "type": "string", - "enum": ["sumsub"] - }, - { - "type": "string", - "enum": ["accept-tos"] - }, - { - "type": "string", - "enum": ["wait"] - }, - { - "type": "string", - "enum": ["contact-support"] - }, - { - "type": "string", - "enum": ["provide-email"] - }, - { - "type": "string", - "enum": ["bridge-hosted"] - } - ] - }, - "purpose": { - "type": "string" - }, - "levelKey": { - "type": "string" - }, - "tosUrl": { - "type": "string" - }, - "effectiveDate": { - "type": "string" - }, - "requirementKey": { - "type": "string" - } - }, - "required": ["key", "kind", "purpose"] - } - }, - "required": ["status"] - } - }, - "required": [ - "id", - "provider", - "method", - "channel", - "country", - "currency", - "status" - ] - } - }, - "nextActions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "kind": { - "anyOf": [ - { - "type": "string", - "enum": ["sumsub"] - }, - { - "type": "string", - "enum": ["accept-tos"] - }, - { - "type": "string", - "enum": ["wait"] - }, - { - "type": "string", - "enum": ["contact-support"] - }, - { - "type": "string", - "enum": ["provide-email"] - }, - { - "type": "string", - "enum": ["bridge-hosted"] - } - ] - }, - "purpose": { - "type": "string" - }, - "levelKey": { - "type": "string" - }, - "tosUrl": { - "type": "string" - }, - "effectiveDate": { - "type": "string" - }, - "requirementKey": { - "type": "string" - } - }, - "required": ["key", "kind", "purpose"] - } - }, - "restrictions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "affectedRailIds": { - "type": "array", - "items": { - "type": "string" - } - }, - "userMessage": { - "type": "string" - } - }, - "required": ["code", "affectedRailIds", "userMessage"] - } - } - }, - "required": ["rails", "nextActions", "restrictions"] - }, - "identityVerification": { - "type": "object", - "properties": { - "status": { - "anyOf": [ - { - "type": "string", - "enum": ["not_started"] - }, - { - "type": "string", - "enum": ["processing"] - }, - { - "type": "string", - "enum": ["verified"] - }, - { - "type": "string", - "enum": ["action_required"] - }, - { - "type": "string", - "enum": ["failed"] - } - ] - }, - "actionMessage": { - "type": "string" - }, - "rejectLabels": { - "type": "array", - "items": { - "type": "string" - } - }, - "submittedAt": { - "type": "string" - }, - "reviewedAt": { - "type": "string" - } - }, - "required": ["status"] - } - }, - "required": ["capabilities", "identityVerification"] - } - } - } - } - } - } - }, - "/get-user-id": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "accountIdentifier": { - "type": "string" - } - }, - "required": ["accountIdentifier"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/update-user": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "userId": { - "type": "string" - }, - "username": { - "type": "string" - }, - "email": { - "type": "string" - }, - "fullName": { - "type": "string" - }, - "bridge_customer_id": { - "type": "string" - }, - "telegramUsername": { - "type": "string" - }, - "offrampHandle": { - "maxLength": 320, - "type": "string" - }, - "pushSubscriptionId": { - "type": "string" - }, - "showFullName": { - "type": "boolean" - }, - "hasSeenEarlyUserModal": { - "type": "boolean" - }, - "bridgeKycStatus": { - "anyOf": [ - { - "type": "string", - "enum": ["not_started"] - }, - { - "type": "string", - "enum": ["incomplete"] - }, - { - "type": "string", - "enum": ["under_review"] - }, - { - "type": "string", - "enum": ["approved"] - }, - { - "type": "string", - "enum": ["rejected"] - } - ] - }, - "dismissActivationCelebration": { - "type": "boolean" - } - }, - "required": ["userId"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/users/me/delete": { - "post": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/users/logout": { - "post": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/users/contacts": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "limit", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "offset", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "search", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/users/history": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "cursor", - "required": false - }, - { - "schema": { - "type": "number" - }, - "in": "query", - "name": "limit", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "targetUsername", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/history/{entryId}": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "kind", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "entryId", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/users/search": { - "get": { - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/users/initiate-kyc": { - "post": { - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/users/accounts": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "accountType": { - "anyOf": [ - { - "type": "string", - "enum": ["iban"] - }, - { - "type": "string", - "enum": ["us"] - }, - { - "type": "string", - "enum": ["clabe"] - }, - { - "type": "string", - "enum": ["gb"] - } - ] - }, - "accountNumber": { - "type": "string" - }, - "countryCode": { - "type": "string" - }, - "countryName": { - "type": "string" - }, - "accountOwnerType": { - "anyOf": [ - { - "type": "string", - "enum": ["individual"] - }, - { - "type": "string", - "enum": ["business"] - } - ] - }, - "accountOwnerName": { - "type": "object", - "properties": { - "firstName": { - "type": "string" - }, - "lastName": { - "type": "string" - }, - "businessName": { - "type": "string" - } - } - }, - "address": { - "type": "object", - "properties": { - "street": { - "type": "string" - }, - "city": { - "type": "string" - }, - "country": { - "type": "string" - }, - "state": { - "type": "string" - }, - "postalCode": { - "type": "string" - } - }, - "required": ["street", "city", "country", "postalCode"] - }, - "bic": { - "type": "string" - }, - "routingNumber": { - "type": "string" - }, - "sortCode": { - "type": "string" - } - }, - "required": [ - "accountType", - "accountNumber", - "countryCode", - "countryName", - "accountOwnerType", - "accountOwnerName", - "address" - ] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/users/{userId}": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "userId", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "type": "object", - "properties": { - "userId": { - "type": "string" - }, - "fullName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "username": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "showFullName": { - "type": "boolean" - }, - "canReceiveBankOfframp": { - "type": "boolean" - }, - "isVerified": { - "type": "boolean" - } - }, - "required": [ - "userId", - "fullName", - "username", - "showFullName", - "canReceiveBankOfframp", - "isVerified" - ] - } - } - } - } - } - } - }, - "/users/interaction-status": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "userIds": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["userIds"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/users/limits": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/users/increase-limits": { - "post": { - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/users/rails": { - "post": { - "responses": { - "200": { - "description": "Default Response" - } - } - }, - "get": { - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/users/capabilities": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "capabilities": { - "type": "object", - "properties": { - "rails": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "description": "`${provider}.${method}` e.g. \"bridge.ach_us\"", - "type": "string" - }, - "provider": { - "anyOf": [ - { - "type": "string", - "enum": ["bridge"] - }, - { - "type": "string", - "enum": ["manteca"] - }, - { - "type": "string", - "enum": ["rain"] - } - ] - }, - "method": { - "type": "string" - }, - "channel": { - "anyOf": [ - { - "type": "string", - "enum": ["bank"] - }, - { - "type": "string", - "enum": ["card"] - }, - { - "type": "string", - "enum": ["qr-only"] - } - ] - }, - "country": { - "type": "string" - }, - "currency": { - "type": "string" - }, - "status": { - "anyOf": [ - { - "type": "string", - "enum": ["enabled"] - }, - { - "type": "string", - "enum": ["pending"] - }, - { - "type": "string", - "enum": ["requires-info"] - }, - { - "type": "string", - "enum": ["blocked"] - } - ] - }, - "operations": { - "type": "object", - "properties": { - "pay": { - "anyOf": [ - { - "type": "string", - "enum": ["enabled"] - }, - { - "type": "string", - "enum": ["pending"] - }, - { - "type": "string", - "enum": ["requires-info"] - }, - { - "type": "string", - "enum": ["blocked"] - } - ] - }, - "deposit": { - "anyOf": [ - { - "type": "string", - "enum": ["enabled"] - }, - { - "type": "string", - "enum": ["pending"] - }, - { - "type": "string", - "enum": ["requires-info"] - }, - { - "type": "string", - "enum": ["blocked"] - } - ] - }, - "withdraw": { - "anyOf": [ - { - "type": "string", - "enum": ["enabled"] - }, - { - "type": "string", - "enum": ["pending"] - }, - { - "type": "string", - "enum": ["requires-info"] - }, - { - "type": "string", - "enum": ["blocked"] - } - ] - } - } - }, - "blockingActions": { - "type": "array", - "items": { - "type": "string" - } - }, - "hintActions": { - "type": "array", - "items": { - "type": "string" - } - }, - "reason": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "userMessage": { - "type": "string" - }, - "details": { - "type": "string" - } - }, - "required": ["code", "userMessage"] - }, - "resolved": { - "type": "object", - "properties": { - "status": { - "anyOf": [ - { - "type": "string", - "enum": ["enabled"] - }, - { - "type": "string", - "enum": ["pending"] - }, - { - "type": "string", - "enum": ["fixable"] - }, - { - "type": "string", - "enum": ["blocked"] - } - ] - }, - "blocking": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "userMessage": { - "type": "string" - }, - "selfHealable": { - "type": "boolean" - }, - "selfHealKind": { - "anyOf": [ - { - "type": "string", - "enum": ["document-resubmit"] - }, - { - "type": "string", - "enum": ["restart-identity"] - }, - { - "type": "string", - "enum": ["provide-email"] - }, - { - "type": "string", - "enum": ["contact-support"] - } - ] - }, - "details": { - "type": "string" - } - }, - "required": [ - "code", - "userMessage", - "selfHealable" - ] - }, - "nextAction": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "kind": { - "anyOf": [ - { - "type": "string", - "enum": ["sumsub"] - }, - { - "type": "string", - "enum": ["accept-tos"] - }, - { - "type": "string", - "enum": ["wait"] - }, - { - "type": "string", - "enum": ["contact-support"] - }, - { - "type": "string", - "enum": ["provide-email"] - }, - { - "type": "string", - "enum": ["bridge-hosted"] - } - ] - }, - "purpose": { - "type": "string" - }, - "levelKey": { - "type": "string" - }, - "tosUrl": { - "type": "string" - }, - "effectiveDate": { - "type": "string" - }, - "requirementKey": { - "type": "string" - } - }, - "required": ["key", "kind", "purpose"] - } - }, - "required": ["status"] - } - }, - "required": [ - "id", - "provider", - "method", - "channel", - "country", - "currency", - "status" - ] - } - }, - "nextActions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "kind": { - "anyOf": [ - { - "type": "string", - "enum": ["sumsub"] - }, - { - "type": "string", - "enum": ["accept-tos"] - }, - { - "type": "string", - "enum": ["wait"] - }, - { - "type": "string", - "enum": ["contact-support"] - }, - { - "type": "string", - "enum": ["provide-email"] - }, - { - "type": "string", - "enum": ["bridge-hosted"] - } - ] - }, - "purpose": { - "type": "string" - }, - "levelKey": { - "type": "string" - }, - "tosUrl": { - "type": "string" - }, - "effectiveDate": { - "type": "string" - }, - "requirementKey": { - "type": "string" - } - }, - "required": ["key", "kind", "purpose"] - } - }, - "restrictions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "affectedRailIds": { - "type": "array", - "items": { - "type": "string" - } - }, - "userMessage": { - "type": "string" - } - }, - "required": ["code", "affectedRailIds", "userMessage"] - } - } - }, - "required": ["rails", "nextActions", "restrictions"] - } - }, - "required": ["capabilities"] - } - } - } - } - } - } - }, - "/users/kyc/start-action": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "key": { - "minLength": 1, - "description": "A capability nextAction key", - "type": "string" - } - }, - "required": ["key"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "sumsubAccessToken": { - "type": "string" - }, - "levelName": { - "type": "string" - }, - "externalActionId": { - "type": "string" - }, - "verificationUrl": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/users/bridge-tos-link": { - "get": { - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "tosLink": { - "type": "string" - }, - "endorsement": { - "type": "string" - } - }, - "required": ["tosLink", "endorsement"] - } - } - } - } - } - } - }, - "/users/bridge-tos-confirm": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/users/consent/status": { - "get": { - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "documents": { - "type": "array", - "items": { - "type": "object", - "properties": { - "slug": { - "type": "string" - }, - "currentVersion": { - "type": "string" - }, - "acceptedVersion": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "acceptedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "needsAcceptance": { - "type": "boolean" - } - }, - "required": [ - "slug", - "currentVersion", - "acceptedVersion", - "acceptedAt", - "needsAcceptance" - ] - } - }, - "needsReConsent": { - "type": "boolean" - } - }, - "required": ["documents", "needsReConsent"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/users/consent/accept": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "documents": { - "minItems": 1, - "maxItems": 8, - "type": "array", - "items": { - "type": "object", - "properties": { - "slug": { - "type": "string" - }, - "version": { - "type": "string" - }, - "hash": { - "type": "string" - } - }, - "required": ["slug", "version"] - } - } - }, - "required": ["documents"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "recorded": { - "type": "number" - } - }, - "required": ["recorded"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/validate-bank-account-number": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "bankAccountNumber": { - "type": "string" - } - }, - "required": ["bankAccountNumber"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/validate-bank-account": { - "post": { - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/validate-bank-number": { - "post": { - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/validate-bic": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "bic": { - "type": "string" - } - }, - "required": ["bic"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/is-valid-bic": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "bic": { - "type": "string" - } - }, - "required": ["bic"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/passkeys/register/options": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "username": { - "type": "string" - }, - "rpID": { - "type": "string" - } - }, - "required": ["username"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/passkeys/register/verify": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "userId": { - "type": "string" - }, - "username": { - "type": "string" - }, - "cred": {}, - "rpID": { - "type": "string" - }, - "acceptedLegal": { - "maxItems": 4, - "type": "array", - "items": { - "type": "object", - "properties": { - "slug": { - "type": "string" - }, - "version": { - "type": "string" - }, - "hash": { - "type": "string" - } - }, - "required": ["slug", "version"] - } - } - }, - "required": ["userId", "username", "cred"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/passkeys/login/options": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "rpID": { - "type": "string" - } - } - } - } - } - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/passkeys/login/verify": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "cred": {}, - "rpID": { - "type": "string" - } - }, - "required": ["cred"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/auth/step-up/options": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "rpID": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/auth/step-up/verify": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "cred": {}, - "rpID": { - "type": "string" - } - }, - "required": ["cred"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "token": { - "type": "string" - }, - "expiresIn": { - "type": "number" - } - }, - "required": ["token", "expiresIn"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "401": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/requests": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "additionalProperties": false, - "type": "object", - "properties": { - "chainId": { - "type": "string" - }, - "tokenAmount": { - "type": "string" - }, - "recipientAddress": { - "type": "string" - }, - "trackId": { - "type": "string" - }, - "reference": { - "type": "string" - }, - "tokenType": { - "not": {} - }, - "tokenAddress": { - "not": {} - }, - "tokenDecimals": { - "not": {} - }, - "tokenSymbol": { - "not": {} - } - }, - "required": ["recipientAddress"] - }, - { - "additionalProperties": false, - "type": "object", - "properties": { - "chainId": { - "type": "string" - }, - "tokenAmount": { - "type": "string" - }, - "recipientAddress": { - "type": "string" - }, - "trackId": { - "type": "string" - }, - "reference": { - "type": "string" - }, - "tokenType": { - "type": "string" - }, - "tokenAddress": { - "pattern": "^(0x[a-fA-F0-9]{40}|[1-9A-HJ-NP-Za-km-z]{32,44})$", - "type": "string" - }, - "tokenDecimals": { - "type": "string" - }, - "tokenSymbol": { - "type": "string" - } - }, - "required": [ - "chainId", - "recipientAddress", - "tokenType", - "tokenAddress", - "tokenDecimals", - "tokenSymbol" - ] - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "Default Response" - } - } - }, - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "recipient", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "tokenAmount", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "tokenAddress", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "chainId", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/requests/{uuid}": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "uuid", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - }, - "patch": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "type": "object", - "properties": { - "chainId": { - "type": "string" - }, - "tokenAmount": { - "type": "string" - }, - "recipientAddress": { - "type": "string" - }, - "trackId": { - "type": "string" - }, - "reference": { - "type": "string" - }, - "tokenAddress": { - "type": "string", - "pattern": "^0x[a-fA-F0-9]{40}$" - }, - "tokenDecimals": { - "type": "string" - }, - "tokenType": { - "type": "string" - }, - "tokenSymbol": { - "type": "string" - } - } - } - } - } - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "uuid", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - }, - "delete": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "uuid", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/charges": { - "post": { - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "anyOf": [ - { - "type": "object", - "allOf": [ - { - "type": "object", - "properties": { - "pricing_type": { - "anyOf": [ - { - "type": "string", - "enum": ["fixed_price"] - }, - { - "type": "string", - "enum": ["no_price"] - } - ] - }, - "local_price": { - "type": "object", - "properties": { - "amount": { - "type": "string" - }, - "currency": { - "type": "string" - } - }, - "required": ["amount"] - }, - "baseUrl": { - "type": "string" - }, - "reference": { - "type": "string" - }, - "transactionType": { - "anyOf": [ - { - "type": "string", - "enum": ["REQUEST"] - }, - { - "type": "string", - "enum": ["DIRECT_SEND"] - }, - { - "type": "string", - "enum": ["SEND_LINK"] - }, - { - "type": "string", - "enum": ["DEPOSIT"] - }, - { - "type": "string", - "enum": ["WITHDRAW"] - } - ] - }, - "attachment": {}, - "mimetype": { - "type": "string" - }, - "filename": { - "type": "string" - } - }, - "required": ["pricing_type", "local_price"] - }, - { - "type": "object", - "properties": { - "checkout_id": { - "type": "string" - }, - "requestId": { - "not": {} - }, - "requestProps": { - "type": "object", - "properties": { - "chainId": { - "type": "string" - }, - "tokenAddress": { - "type": "string" - }, - "tokenType": { - "type": "string" - }, - "tokenSymbol": { - "type": "string" - }, - "tokenDecimals": { - "type": "integer" - }, - "recipientAddress": { - "type": "string" - }, - "requesteeUsername": { - "type": "string" - }, - "tokenAmount": { - "type": "string" - } - } - } - }, - "required": ["checkout_id"] - } - ] - }, - { - "type": "object", - "allOf": [ - { - "type": "object", - "properties": { - "pricing_type": { - "anyOf": [ - { - "type": "string", - "enum": ["fixed_price"] - }, - { - "type": "string", - "enum": ["no_price"] - } - ] - }, - "local_price": { - "type": "object", - "properties": { - "amount": { - "type": "string" - }, - "currency": { - "type": "string" - } - }, - "required": ["amount"] - }, - "baseUrl": { - "type": "string" - }, - "reference": { - "type": "string" - }, - "transactionType": { - "anyOf": [ - { - "type": "string", - "enum": ["REQUEST"] - }, - { - "type": "string", - "enum": ["DIRECT_SEND"] - }, - { - "type": "string", - "enum": ["SEND_LINK"] - }, - { - "type": "string", - "enum": ["DEPOSIT"] - }, - { - "type": "string", - "enum": ["WITHDRAW"] - } - ] - }, - "attachment": {}, - "mimetype": { - "type": "string" - }, - "filename": { - "type": "string" - } - }, - "required": ["pricing_type", "local_price"] - }, - { - "type": "object", - "properties": { - "checkout_id": { - "not": {} - }, - "requestId": { - "type": "string" - }, - "requestProps": { - "type": "object", - "properties": { - "chainId": { - "type": "string" - }, - "tokenAddress": { - "type": "string" - }, - "tokenType": { - "type": "string" - }, - "tokenSymbol": { - "type": "string" - }, - "tokenDecimals": { - "type": "integer" - }, - "recipientAddress": { - "type": "string" - }, - "requesteeUsername": { - "type": "string" - }, - "tokenAmount": { - "type": "string" - } - } - } - }, - "required": ["requestId"] - } - ] - }, - { - "type": "object", - "allOf": [ - { - "type": "object", - "properties": { - "pricing_type": { - "anyOf": [ - { - "type": "string", - "enum": ["fixed_price"] - }, - { - "type": "string", - "enum": ["no_price"] - } - ] - }, - "local_price": { - "type": "object", - "properties": { - "amount": { - "type": "string" - }, - "currency": { - "type": "string" - } - }, - "required": ["amount"] - }, - "baseUrl": { - "type": "string" - }, - "reference": { - "type": "string" - }, - "transactionType": { - "anyOf": [ - { - "type": "string", - "enum": ["REQUEST"] - }, - { - "type": "string", - "enum": ["DIRECT_SEND"] - }, - { - "type": "string", - "enum": ["SEND_LINK"] - }, - { - "type": "string", - "enum": ["DEPOSIT"] - }, - { - "type": "string", - "enum": ["WITHDRAW"] - } - ] - }, - "attachment": {}, - "mimetype": { - "type": "string" - }, - "filename": { - "type": "string" - } - }, - "required": ["pricing_type", "local_price"] - }, - { - "type": "object", - "properties": { - "checkout_id": { - "not": {} - }, - "requestId": { - "not": {} - }, - "requestProps": { - "type": "object", - "properties": { - "chainId": { - "type": "string" - }, - "tokenAddress": { - "type": "string" - }, - "tokenType": { - "type": "string" - }, - "tokenSymbol": { - "type": "string" - }, - "tokenDecimals": { - "type": "integer" - }, - "recipientAddress": { - "type": "string" - }, - "requesteeUsername": { - "type": "string" - }, - "tokenAmount": { - "type": "string" - } - }, - "required": [ - "chainId", - "tokenAddress", - "tokenType", - "tokenSymbol", - "tokenDecimals", - "recipientAddress" - ] - } - }, - "required": ["requestProps"] - } - ] - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "Default Response" - } - } - }, - "get": { - "parameters": [ - { - "schema": { - "minimum": 1, - "maximum": 100, - "default": 25, - "type": "number" - }, - "in": "query", - "name": "limit", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "starting_after", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "ending_before", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "status", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/charges/{uuid}/payments": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "hash": { - "type": "string" - }, - "chainId": { - "type": "string" - }, - "tokenAddress": { - "type": "string" - }, - "payerAddress": { - "type": "string" - }, - "sourceChainId": { - "type": "string" - }, - "sourceTokenAddress": { - "type": "string" - }, - "sourceTokenSymbol": { - "type": "string" - } - }, - "required": ["hash", "chainId", "tokenAddress", "payerAddress"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "uuid", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/charges/{chargeId}": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "chargeId", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - }, - "delete": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "chargeId", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/request-charges/{uuid}": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "uuid", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/direct-send": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "recipientAddress": { - "pattern": "^0x[a-fA-F0-9]{40}$", - "type": "string" - }, - "chainId": { - "type": "string" - }, - "tokenAddress": { - "pattern": "^0x[a-fA-F0-9]{40}$", - "type": "string" - }, - "tokenAmount": { - "pattern": "^(0|[1-9]\\d*)(\\.\\d+)?$", - "type": "string" - }, - "tokenDecimals": { - "minimum": 0, - "maximum": 36, - "type": "integer" - }, - "tokenSymbol": { - "type": "string" - }, - "tokenType": { - "type": "string" - }, - "hash": { - "pattern": "^0x[a-fA-F0-9]{64}$", - "type": "string" - }, - "payerAddress": { - "pattern": "^0x[a-fA-F0-9]{40}$", - "type": "string" - }, - "memo": { - "type": "string" - } - }, - "required": [ - "recipientAddress", - "chainId", - "tokenAddress", - "tokenAmount", - "tokenDecimals", - "hash", - "payerAddress" - ] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/send-links": { - "post": { - "responses": { - "200": { - "description": "Default Response" - } - } - }, - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "c", - "required": true - }, - { - "schema": { - "type": "number" - }, - "in": "query", - "name": "i", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "v", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/send-links/{pubKey}": { - "patch": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "pubKey", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - }, - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "c", - "required": false - }, - { - "schema": { - "type": "number" - }, - "in": "query", - "name": "i", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "v", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "pubKey", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/send-links/claim/{txHash}/associate-user": { - "patch": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "txHash", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/bridge/webhooks": { - "post": { - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/bridge/customers/{uuid}": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "uuid", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/bridge/customers/{uuid}/external-accounts": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "accountNumber": { - "type": "string" - }, - "bic": { - "type": "string" - }, - "country": { - "type": "string" - }, - "address": { - "type": "object", - "properties": { - "street": { - "type": "string" - }, - "city": { - "type": "string" - }, - "country": { - "type": "string" - }, - "state": { - "type": "string" - }, - "postalCode": { - "type": "string" - } - }, - "required": ["street", "city", "country", "postalCode"] - }, - "accountOwnerName": { - "type": "object", - "properties": { - "firstName": { - "type": "string" - }, - "lastName": { - "type": "string" - }, - "businessName": { - "type": "string" - } - } - }, - "accountOwnerType": { - "anyOf": [ - { - "type": "string", - "enum": ["business"] - }, - { - "type": "string", - "enum": ["individual"] - } - ] - }, - "routingNumber": { - "type": "string" - }, - "sortCode": { - "type": "string" - }, - "accountType": { - "anyOf": [ - { - "type": "string", - "enum": ["iban"] - }, - { - "type": "string", - "enum": ["us"] - }, - { - "type": "string", - "enum": ["clabe"] - }, - { - "type": "string", - "enum": ["gb"] - } - ] - }, - "reuseOnError": { - "type": "boolean" - } - }, - "required": [ - "accountNumber", - "country", - "accountOwnerName", - "accountOwnerType", - "accountType" - ] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "uuid", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - }, - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "uuid", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/bridge/customers/{customerId}/external-accounts/{externalAccountId}": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "customerId", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "externalAccountId", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - }, - "delete": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "customerId", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "externalAccountId", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/bridge/customers/{customerId}/external-accounts/{externalAccountId}/reactivate": { - "post": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "customerId", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "externalAccountId", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/bridge/customers/{uuid}/kyc-links": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "uuid", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/bridge/offramp/create": { - "post": { - "summary": "Initiate an off-ramp transfer", - "tags": ["offramp"], - "description": "This endpoint initiates a new off-ramp transfer. It uses the configured off-ramp provider (e.g., Bridge) to create a transfer and returns deposit instructions for the user.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "onBehalfOf": { - "type": "string" - }, - "provider": { - "type": "string" - }, - "amount": { - "type": "string" - }, - "sendLinkPubKey": { - "type": "string" - }, - "source": { - "type": "object", - "properties": { - "currency": { - "anyOf": [ - { - "type": "string", - "enum": ["usdc"] - }, - { - "type": "string", - "enum": ["eurc"] - }, - { - "type": "string", - "enum": ["usdt"] - }, - { - "type": "string", - "enum": ["dai"] - } - ] - }, - "paymentRail": { - "anyOf": [ - { - "type": "string", - "enum": ["ethereum"] - }, - { - "type": "string", - "enum": ["polygon"] - }, - { - "type": "string", - "enum": ["base"] - }, - { - "type": "string", - "enum": ["optimism"] - }, - { - "type": "string", - "enum": ["solana"] - }, - { - "type": "string", - "enum": ["stellar"] - }, - { - "type": "string", - "enum": ["arbitrum"] - }, - { - "type": "string", - "enum": ["avalance_c_chain"] - } - ] - }, - "fromAddress": { - "type": "string" - } - }, - "required": ["currency", "paymentRail"] - }, - "destination": { - "type": "object", - "properties": { - "currency": { - "anyOf": [ - { - "type": "string", - "enum": ["usd"] - }, - { - "type": "string", - "enum": ["eur"] - }, - { - "type": "string", - "enum": ["mxn"] - }, - { - "type": "string", - "enum": ["gbp"] - } - ] - }, - "paymentRail": { - "anyOf": [ - { - "type": "string", - "enum": ["ach"] - }, - { - "type": "string", - "enum": ["ach_push"] - }, - { - "type": "string", - "enum": ["ach_same_day"] - }, - { - "type": "string", - "enum": ["wire"] - }, - { - "type": "string", - "enum": ["sepa"] - }, - { - "type": "string", - "enum": ["swift"] - }, - { - "type": "string", - "enum": ["spei"] - }, - { - "type": "string", - "enum": ["faster_payments"] - } - ] - }, - "externalAccountId": { - "type": "string" - }, - "wireMessage": { - "type": "string" - }, - "sepaReference": { - "type": "string" - }, - "achReference": { - "type": "string" - }, - "fasterPaymentsReference": { - "type": "string" - } - }, - "required": ["currency", "paymentRail", "externalAccountId"] - }, - "features": { - "type": "object", - "properties": { - "flexibleAmount": { - "type": "boolean" - }, - "staticTemplate": { - "type": "boolean" - }, - "allowAnyFromAddress": { - "type": "boolean" - } - } - }, - "developerFee": { - "type": "string" - }, - "developerFeePercent": { - "type": "string" - } - }, - "required": ["onBehalfOf", "source", "destination"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "transferId": { - "type": "string" - }, - "depositInstructions": { - "type": "object", - "properties": { - "toAddress": { - "type": "string" - }, - "blockchainMemo": { - "type": "string" - } - }, - "required": ["toAddress"] - } - }, - "required": ["transferId", "depositInstructions"] - } - } - } - }, - "401": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/bridge/offramp/create-for-guest": { - "post": { - "summary": "Initiate an off-ramp transfer for a guest", - "tags": ["offramp"], - "description": "This endpoint initiates a new off-ramp transfer for a guest user. It uses the configured off-ramp provider (e.g., Bridge) to create a transfer and returns deposit instructions for the user. This is intended for server-to-server use where the user is not authenticated.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "userId": { - "type": "string" - }, - "provider": { - "type": "string" - }, - "amount": { - "type": "string" - }, - "sendLinkPubKey": { - "type": "string" - }, - "source": { - "type": "object", - "properties": { - "currency": { - "anyOf": [ - { - "type": "string", - "enum": ["usdc"] - }, - { - "type": "string", - "enum": ["eurc"] - }, - { - "type": "string", - "enum": ["usdt"] - }, - { - "type": "string", - "enum": ["dai"] - } - ] - }, - "paymentRail": { - "anyOf": [ - { - "type": "string", - "enum": ["ethereum"] - }, - { - "type": "string", - "enum": ["polygon"] - }, - { - "type": "string", - "enum": ["base"] - }, - { - "type": "string", - "enum": ["optimism"] - }, - { - "type": "string", - "enum": ["solana"] - }, - { - "type": "string", - "enum": ["stellar"] - }, - { - "type": "string", - "enum": ["arbitrum"] - }, - { - "type": "string", - "enum": ["avalance_c_chain"] - } - ] - }, - "fromAddress": { - "type": "string" - } - }, - "required": ["currency", "paymentRail"] - }, - "destination": { - "type": "object", - "properties": { - "currency": { - "anyOf": [ - { - "type": "string", - "enum": ["usd"] - }, - { - "type": "string", - "enum": ["eur"] - }, - { - "type": "string", - "enum": ["mxn"] - }, - { - "type": "string", - "enum": ["gbp"] - } - ] - }, - "paymentRail": { - "anyOf": [ - { - "type": "string", - "enum": ["ach"] - }, - { - "type": "string", - "enum": ["ach_push"] - }, - { - "type": "string", - "enum": ["ach_same_day"] - }, - { - "type": "string", - "enum": ["wire"] - }, - { - "type": "string", - "enum": ["sepa"] - }, - { - "type": "string", - "enum": ["swift"] - }, - { - "type": "string", - "enum": ["spei"] - }, - { - "type": "string", - "enum": ["faster_payments"] - } - ] - }, - "externalAccountId": { - "type": "string" - }, - "wireMessage": { - "type": "string" - }, - "sepaReference": { - "type": "string" - }, - "achReference": { - "type": "string" - } - }, - "required": ["currency", "paymentRail", "externalAccountId"] - }, - "beneficiaryName": { - "minLength": 1, - "type": "string" - }, - "beneficiaryAddress": { - "type": "object", - "properties": { - "street": { - "type": "string" - }, - "city": { - "type": "string" - }, - "country": { - "type": "string" - }, - "state": { - "type": "string" - }, - "postalCode": { - "type": "string" - } - }, - "required": ["street", "city", "country"] - } - }, - "required": ["userId", "source", "destination"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "transferId": { - "type": "string" - }, - "depositInstructions": { - "type": "object", - "properties": { - "toAddress": { - "type": "string" - }, - "blockchainMemo": { - "type": "string" - } - }, - "required": ["toAddress"] - }, - "quote": { - "type": "object", - "properties": { - "initial_amount": { - "type": "string" - }, - "developer_fee": { - "type": "string" - }, - "exchange_fee": { - "type": "string" - }, - "subtotal_amount": { - "type": "string" - }, - "remaining_prefunded_amount": { - "type": "string" - }, - "gas_fee": { - "type": "string" - }, - "final_amount": { - "type": "string" - }, - "source_tx_hash": { - "type": "string" - }, - "destination_tx_hash": { - "type": "string" - }, - "exchange_rate": { - "type": "string" - }, - "url": { - "type": "string" - } - }, - "required": [ - "initial_amount", - "developer_fee", - "exchange_fee", - "subtotal_amount" - ] - } - }, - "required": ["transferId", "depositInstructions"] - } - } - } - }, - "401": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "503": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/bridge/transfers/{transferId}/confirm": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["txHash"], - "properties": { - "txHash": { - "type": "string", - "minLength": 66, - "maxLength": 66 - } - } - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "transferId", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/bridge/onramp/create": { - "post": { - "summary": "Initiate an on-ramp transfer", - "tags": ["onramp"], - "description": "This endpoint initiates a new on-ramp transfer (fiat to crypto). It creates a transfer from fiat to USDC on Arbitrum to the user's peanut wallet and returns bank deposit instructions.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "type": "string" - }, - "chargeId": { - "type": "string" - }, - "recipientAddress": { - "type": "string" - }, - "source": { - "type": "object", - "properties": { - "currency": { - "anyOf": [ - { - "type": "string", - "enum": ["usd"] - }, - { - "type": "string", - "enum": ["eur"] - }, - { - "type": "string", - "enum": ["mxn"] - }, - { - "type": "string", - "enum": ["gbp"] - } - ] - }, - "paymentRail": { - "anyOf": [ - { - "type": "string", - "enum": ["ach"] - }, - { - "type": "string", - "enum": ["ach_push"] - }, - { - "type": "string", - "enum": ["ach_same_day"] - }, - { - "type": "string", - "enum": ["wire"] - }, - { - "type": "string", - "enum": ["sepa"] - }, - { - "type": "string", - "enum": ["swift"] - }, - { - "type": "string", - "enum": ["spei"] - }, - { - "type": "string", - "enum": ["faster_payments"] - } - ] - } - }, - "required": ["currency", "paymentRail"] - } - }, - "required": ["amount", "source"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "transferId": { - "type": "string" - }, - "depositInstructions": { - "type": "object", - "properties": { - "amount": { - "type": "string" - }, - "currency": { - "type": "string" - }, - "depositMessage": { - "type": "string" - }, - "bankName": { - "type": "string" - }, - "bankAddress": { - "type": "string" - }, - "bankRoutingNumber": { - "type": "string" - }, - "bankAccountNumber": { - "type": "string" - }, - "bankBeneficiaryName": { - "type": "string" - }, - "bankBeneficiaryAddress": { - "type": "string" - }, - "iban": { - "type": "string" - }, - "bic": { - "type": "string" - }, - "accountHolderName": { - "type": "string" - }, - "clabe": { - "type": "string" - }, - "sortCode": { - "type": "string" - }, - "accountNumber": { - "type": "string" - }, - "reference": { - "type": "string" - } - }, - "required": ["amount", "currency", "depositMessage"] - } - }, - "required": ["transferId", "depositInstructions"] - } - } - } - }, - "401": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/bridge/onramp/{transferId}/cancel": { - "delete": { - "summary": "Cancel an on-ramp transfer", - "tags": ["onramp"], - "description": "This endpoint cancels an on-ramp transfer. The transfer must be in AWAITING_FUNDS/PENDING state. It updates the ledger and calls Bridge API to cancel the transfer.", - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "transferId", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "message": { - "type": "string" - } - }, - "required": ["success", "message"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "401": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "403": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/bridge/onramp/quote": { - "get": { - "parameters": [ - { - "schema": { - "type": "string", - "enum": ["iban", "us", "clabe", "gb"] - }, - "in": "query", - "name": "accountType", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "sourceAmount", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/bridge/exchange-rate": { - "get": { - "parameters": [ - { - "schema": { - "type": "string", - "enum": ["iban", "us", "clabe", "gb"] - }, - "in": "query", - "name": "accountType", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/rain/cards/withdraw/session-approve": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "serializedApproval": { - "minLength": 1, - "type": "string" - } - }, - "required": ["serializedApproval"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean" - } - }, - "required": ["ok"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/rain/cards/withdraw/prepare": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "minLength": 1, - "type": "string" - }, - "recipientAddress": { - "pattern": "^0x[0-9a-fA-F]{40}$", - "type": "string" - }, - "directTransfer": { - "type": "boolean" - }, - "kind": { - "anyOf": [ - { - "type": "string", - "enum": ["P2P_SEND"] - }, - { - "type": "string", - "enum": ["QR_PAY"] - }, - { - "type": "string", - "enum": ["LINK_CREATE"] - }, - { - "type": "string", - "enum": ["CRYPTO_WITHDRAW"] - }, - { - "type": "string", - "enum": ["FIAT_OFFRAMP"] - }, - { - "type": "string", - "enum": ["FIAT_ONRAMP"] - }, - { - "type": "string", - "enum": ["REQUEST_PAY"] - }, - { - "type": "string", - "enum": ["AUTO_REBALANCE"] - }, - { - "type": "string", - "enum": ["CARD_SPEND"] - }, - { - "type": "string", - "enum": ["DEPOSIT_EXTERNAL"] - }, - { - "type": "string", - "enum": ["OTHER"] - } - ] - }, - "totalAmountCents": { - "minLength": 1, - "type": "string" - }, - "chargeId": { - "minLength": 1, - "type": "string" - } - }, - "required": ["amount", "recipientAddress", "directTransfer", "kind"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "preparationId": { - "type": "string" - }, - "coordinatorAddress": { - "type": "string" - }, - "collateralProxy": { - "type": "string" - }, - "adminAddress": { - "type": "string" - }, - "chainId": { - "type": "string" - }, - "tokenAddress": { - "type": "string" - }, - "amount": { - "type": "string" - }, - "recipientAddress": { - "type": "string" - }, - "directTransfer": { - "type": "boolean" - }, - "adminSalt": { - "type": "string" - }, - "adminNonce": { - "type": "string" - }, - "executorSignature": { - "type": "string" - }, - "executorSalt": { - "type": "string" - }, - "expiresAt": { - "type": "number" - } - }, - "required": [ - "preparationId", - "coordinatorAddress", - "collateralProxy", - "adminAddress", - "chainId", - "tokenAddress", - "amount", - "recipientAddress", - "directTransfer", - "adminSalt", - "adminNonce", - "executorSignature", - "executorSalt", - "expiresAt" - ] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "422": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "425": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "retryAfterSec": { - "minimum": 1, - "maximum": 600, - "type": "integer" - } - }, - "required": ["error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "502": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/rain/cards/withdraw/stamp": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "preparationId": { - "minLength": 1, - "type": "string" - }, - "txHash": { - "pattern": "^0x[0-9a-fA-F]{64}$", - "type": "string" - } - }, - "required": ["preparationId", "txHash"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean" - } - }, - "required": ["ok"] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "409": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/rain/cards/withdraw/submit": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "preparationId": { - "minLength": 1, - "type": "string" - }, - "amount": { - "minLength": 1, - "type": "string" - }, - "recipientAddress": { - "pattern": "^0x[0-9a-fA-F]{40}$", - "type": "string" - }, - "directTransfer": { - "type": "boolean" - }, - "adminSalt": { - "pattern": "^0x[0-9a-fA-F]{64}$", - "type": "string" - }, - "adminNonce": { - "minLength": 1, - "type": "string" - }, - "adminSignature": { - "pattern": "^0x[0-9a-fA-F]+$", - "type": "string" - }, - "executorSignature": { - "type": "string" - }, - "executorSalt": { - "type": "string" - }, - "expiresAt": { - "type": "number" - } - }, - "required": [ - "preparationId", - "amount", - "recipientAddress", - "directTransfer", - "adminSalt", - "adminNonce", - "adminSignature", - "executorSignature", - "executorSalt", - "expiresAt" - ] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "txHash": { - "type": "string" - } - }, - "required": ["txHash"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "409": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "code": { - "type": "string", - "enum": ["STALE_CARD_APPROVAL"] - } - }, - "required": ["error"] - } - } - } - }, - "410": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "502": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/manteca/qr-payment/init": { - "post": { - "summary": "Process QR payment", - "tags": ["manteca"], - "description": "Process a QR payment through Manteca. Supports PIX, QR 3.0, and CODI payments.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "qrCode": { - "description": "The QR code string to process for payment", - "type": "string" - }, - "amount": { - "description": "Amount for static QR codes (optional for dynamic QR codes)", - "type": "string" - }, - "qrType": { - "description": "Type of QR code (e.g. PIX, QR30, CODI). Used to select the correct fallback user for non-Manteca users.", - "type": "string" - } - }, - "required": ["qrCode"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/manteca/qr-payment/complete-with-signed-tx": { - "post": { - "summary": "Complete QR payment with signed transaction", - "tags": ["manteca"], - "description": "Completes Manteca payment first, then either broadcasts the signed UserOp or submits the signed Rain withdrawal via the session key. This prevents funds from being stuck if Manteca fails.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "type": "object", - "properties": { - "kind": { - "type": "string", - "enum": ["userOp"] - }, - "paymentLockCode": { - "description": "The payment lock code from init", - "type": "string" - }, - "qrType": { - "description": "Type of QR code (e.g. PIX, QR30, CODI). Used to select the correct fallback user for non-Manteca users.", - "type": "string" - }, - "signedUserOp": { - "type": "object", - "properties": { - "sender": { - "type": "string" - }, - "nonce": {}, - "callData": { - "type": "string" - }, - "signature": { - "type": "string" - }, - "factory": { - "type": "string" - }, - "factoryData": { - "type": "string" - }, - "callGasLimit": {}, - "verificationGasLimit": {}, - "preVerificationGas": {}, - "maxFeePerGas": {}, - "maxPriorityFeePerGas": {}, - "paymaster": { - "type": "string" - }, - "paymasterData": { - "type": "string" - }, - "paymasterVerificationGasLimit": {}, - "paymasterPostOpGasLimit": {} - }, - "required": [ - "sender", - "nonce", - "callData", - "signature", - "callGasLimit", - "verificationGasLimit", - "preVerificationGas", - "maxFeePerGas", - "maxPriorityFeePerGas", - "paymasterVerificationGasLimit", - "paymasterPostOpGasLimit" - ] - }, - "chainId": { - "type": "string" - }, - "entryPointAddress": { - "type": "string" - }, - "rainPreparationId": { - "minLength": 1, - "type": "string" - } - }, - "required": [ - "kind", - "paymentLockCode", - "signedUserOp", - "chainId", - "entryPointAddress" - ] - }, - { - "type": "object", - "properties": { - "kind": { - "type": "string", - "enum": ["rainWithdrawal"] - }, - "paymentLockCode": { - "description": "The payment lock code from init", - "type": "string" - }, - "qrType": { - "type": "string" - }, - "signedRainWithdrawal": { - "type": "object", - "properties": { - "preparationId": { - "minLength": 1, - "type": "string" - }, - "amount": { - "minLength": 1, - "type": "string" - }, - "recipientAddress": { - "pattern": "^0x[0-9a-fA-F]{40}$", - "type": "string" - }, - "directTransfer": { - "type": "boolean" - }, - "adminSalt": { - "pattern": "^0x[0-9a-fA-F]{64}$", - "type": "string" - }, - "adminNonce": { - "minLength": 1, - "type": "string" - }, - "adminSignature": { - "pattern": "^0x[0-9a-fA-F]+$", - "type": "string" - }, - "executorSignature": { - "type": "string" - }, - "executorSalt": { - "type": "string" - }, - "expiresAt": { - "type": "number" - } - }, - "required": [ - "preparationId", - "amount", - "recipientAddress", - "directTransfer", - "adminSalt", - "adminNonce", - "adminSignature", - "executorSignature", - "executorSalt", - "expiresAt" - ] - }, - "chainId": { - "type": "string" - } - }, - "required": ["kind", "paymentLockCode", "signedRainWithdrawal", "chainId"] - } - ] - } - } - } - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/manteca/deposit": { - "post": { - "summary": "Create deposit order (onramp)", - "tags": ["manteca"], - "description": "Create a deposit order to convert fiat to crypto via Manteca. Returns deposit instructions.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "type": "string" - }, - "isUsdDenominated": { - "type": "boolean" - }, - "currency": { - "anyOf": [ - { - "type": "string", - "enum": ["ARS"] - }, - { - "type": "string", - "enum": ["BRL"] - }, - { - "type": "string", - "enum": ["CLP"] - }, - { - "type": "string", - "enum": ["COP"] - }, - { - "type": "string", - "enum": ["PUSD"] - }, - { - "type": "string", - "enum": ["CRC"] - }, - { - "type": "string", - "enum": ["GTQ"] - }, - { - "type": "string", - "enum": ["MXN"] - }, - { - "type": "string", - "enum": ["PHP"] - }, - { - "type": "string", - "enum": ["BOB"] - } - ] - }, - "chargeId": { - "type": "string" - } - }, - "required": ["amount", "currency"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/manteca/deposit/{depositId}/cancel": { - "patch": { - "summary": "Cancel deposit order", - "tags": ["manteca"], - "description": "Cancel a deposit order created via Manteca.", - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "depositId", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/manteca/deposit/{depositId}/status": { - "get": { - "summary": "Get deposit order status", - "tags": ["manteca"], - "description": "Poll the status of a deposit order by its Manteca synthetic id. Used by the BRL PIX QR flow to detect completion (intent.status === COMPLETED) without leaving the user on a static QR. Read-only — the webhook/poller remain the authoritative completion trigger.", - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "depositId", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/manteca/withdraw/init": { - "post": { - "summary": "Initialize withdraw with locked price", - "tags": ["manteca"], - "description": "Creates a price lock for withdraw. Returns the locked exchange rate valid for ~120 seconds. Use this before showing the user the final amount.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "description": "Amount to withdraw in USD (USDC)", - "type": "string" - }, - "currency": { - "description": "Target fiat currency (e.g. ARS, BRL)", - "anyOf": [ - { - "type": "string", - "enum": ["ARS"] - }, - { - "type": "string", - "enum": ["BRL"] - }, - { - "type": "string", - "enum": ["CLP"] - }, - { - "type": "string", - "enum": ["COP"] - }, - { - "type": "string", - "enum": ["PUSD"] - }, - { - "type": "string", - "enum": ["CRC"] - }, - { - "type": "string", - "enum": ["GTQ"] - }, - { - "type": "string", - "enum": ["MXN"] - }, - { - "type": "string", - "enum": ["PHP"] - }, - { - "type": "string", - "enum": ["BOB"] - } - ] - } - }, - "required": ["amount", "currency"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/manteca/withdraw": { - "post": { - "summary": "Create withdraw order (offramp)", - "tags": ["manteca"], - "description": "Create a withdraw order to convert crypto to fiat via Manteca. Optionally use a pre-locked price from /withdraw/init.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "description": "Amount to withdraw in crypto asset", - "type": "string" - }, - "txHash": { - "description": "Transaction hash of the withdrawal", - "type": "string" - }, - "destinationAddress": { - "description": "Destination address to withdraw to", - "type": "string" - }, - "bankCode": { - "type": "string" - }, - "accountType": { - "anyOf": [ - { - "type": "string", - "enum": ["SAVINGS"] - }, - { - "type": "string", - "enum": ["CHECKING"] - }, - { - "type": "string", - "enum": ["DEBIT"] - }, - { - "type": "string", - "enum": ["PHONE"] - }, - { - "type": "string", - "enum": ["VISTA"] - }, - { - "type": "string", - "enum": ["RUT"] - } - ] - }, - "currency": { - "anyOf": [ - { - "type": "string", - "enum": ["ARS"] - }, - { - "type": "string", - "enum": ["BRL"] - }, - { - "type": "string", - "enum": ["CLP"] - }, - { - "type": "string", - "enum": ["COP"] - }, - { - "type": "string", - "enum": ["PUSD"] - }, - { - "type": "string", - "enum": ["CRC"] - }, - { - "type": "string", - "enum": ["GTQ"] - }, - { - "type": "string", - "enum": ["MXN"] - }, - { - "type": "string", - "enum": ["PHP"] - }, - { - "type": "string", - "enum": ["BOB"] - } - ] - }, - "priceLockCode": { - "description": "Price lock code from /withdraw/init. If not provided, a new price lock is created.", - "type": "string" - } - }, - "required": ["amount", "txHash", "destinationAddress"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/manteca/withdraw/complete-with-signed-tx": { - "post": { - "summary": "Complete withdraw with signed transaction (sign-then-broadcast)", - "tags": ["manteca"], - "description": "Creates Manteca ramp-off order FIRST, then either broadcasts the signed UserOp or submits the signed Rain withdrawal via the session key. Prevents funds from being stuck if Manteca fails.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "type": "object", - "properties": { - "kind": { - "type": "string", - "enum": ["userOp"] - }, - "priceLockCode": { - "description": "The price lock code from /withdraw/init", - "type": "string" - }, - "amount": { - "description": "Amount to withdraw in USD (USDC)", - "type": "string" - }, - "destinationAddress": { - "description": "Destination bank account address", - "type": "string" - }, - "bankCode": { - "type": "string" - }, - "accountType": { - "anyOf": [ - { - "type": "string", - "enum": ["SAVINGS"] - }, - { - "type": "string", - "enum": ["CHECKING"] - }, - { - "type": "string", - "enum": ["DEBIT"] - }, - { - "type": "string", - "enum": ["PHONE"] - }, - { - "type": "string", - "enum": ["VISTA"] - }, - { - "type": "string", - "enum": ["RUT"] - } - ] - }, - "currency": { - "description": "Target fiat currency (must match price lock)", - "anyOf": [ - { - "type": "string", - "enum": ["ARS"] - }, - { - "type": "string", - "enum": ["BRL"] - }, - { - "type": "string", - "enum": ["CLP"] - }, - { - "type": "string", - "enum": ["COP"] - }, - { - "type": "string", - "enum": ["PUSD"] - }, - { - "type": "string", - "enum": ["CRC"] - }, - { - "type": "string", - "enum": ["GTQ"] - }, - { - "type": "string", - "enum": ["MXN"] - }, - { - "type": "string", - "enum": ["PHP"] - }, - { - "type": "string", - "enum": ["BOB"] - } - ] - }, - "signedUserOp": { - "type": "object", - "properties": { - "sender": { - "type": "string" - }, - "nonce": {}, - "callData": { - "type": "string" - }, - "signature": { - "type": "string" - }, - "factory": { - "type": "string" - }, - "factoryData": { - "type": "string" - }, - "callGasLimit": {}, - "verificationGasLimit": {}, - "preVerificationGas": {}, - "maxFeePerGas": {}, - "maxPriorityFeePerGas": {}, - "paymaster": { - "type": "string" - }, - "paymasterData": { - "type": "string" - }, - "paymasterVerificationGasLimit": {}, - "paymasterPostOpGasLimit": {} - }, - "required": [ - "sender", - "nonce", - "callData", - "signature", - "callGasLimit", - "verificationGasLimit", - "preVerificationGas", - "maxFeePerGas", - "maxPriorityFeePerGas", - "paymasterVerificationGasLimit", - "paymasterPostOpGasLimit" - ] - }, - "chainId": { - "type": "string" - }, - "entryPointAddress": { - "type": "string" - }, - "rainPreparationId": { - "minLength": 1, - "type": "string" - } - }, - "required": [ - "kind", - "priceLockCode", - "amount", - "destinationAddress", - "currency", - "signedUserOp", - "chainId", - "entryPointAddress" - ] - }, - { - "type": "object", - "properties": { - "kind": { - "type": "string", - "enum": ["rainWithdrawal"] - }, - "priceLockCode": { - "description": "The price lock code from /withdraw/init", - "type": "string" - }, - "amount": { - "description": "Amount to withdraw in USD (USDC)", - "type": "string" - }, - "destinationAddress": { - "description": "Destination bank account address", - "type": "string" - }, - "bankCode": { - "type": "string" - }, - "accountType": { - "anyOf": [ - { - "type": "string", - "enum": ["SAVINGS"] - }, - { - "type": "string", - "enum": ["CHECKING"] - }, - { - "type": "string", - "enum": ["DEBIT"] - }, - { - "type": "string", - "enum": ["PHONE"] - }, - { - "type": "string", - "enum": ["VISTA"] - }, - { - "type": "string", - "enum": ["RUT"] - } - ] - }, - "currency": { - "description": "Target fiat currency (must match price lock)", - "anyOf": [ - { - "type": "string", - "enum": ["ARS"] - }, - { - "type": "string", - "enum": ["BRL"] - }, - { - "type": "string", - "enum": ["CLP"] - }, - { - "type": "string", - "enum": ["COP"] - }, - { - "type": "string", - "enum": ["PUSD"] - }, - { - "type": "string", - "enum": ["CRC"] - }, - { - "type": "string", - "enum": ["GTQ"] - }, - { - "type": "string", - "enum": ["MXN"] - }, - { - "type": "string", - "enum": ["PHP"] - }, - { - "type": "string", - "enum": ["BOB"] - } - ] - }, - "signedRainWithdrawal": { - "type": "object", - "properties": { - "preparationId": { - "minLength": 1, - "type": "string" - }, - "amount": { - "minLength": 1, - "type": "string" - }, - "recipientAddress": { - "pattern": "^0x[0-9a-fA-F]{40}$", - "type": "string" - }, - "directTransfer": { - "type": "boolean" - }, - "adminSalt": { - "pattern": "^0x[0-9a-fA-F]{64}$", - "type": "string" - }, - "adminNonce": { - "minLength": 1, - "type": "string" - }, - "adminSignature": { - "pattern": "^0x[0-9a-fA-F]+$", - "type": "string" - }, - "executorSignature": { - "type": "string" - }, - "executorSalt": { - "type": "string" - }, - "expiresAt": { - "type": "number" - } - }, - "required": [ - "preparationId", - "amount", - "recipientAddress", - "directTransfer", - "adminSalt", - "adminNonce", - "adminSignature", - "executorSignature", - "executorSalt", - "expiresAt" - ] - }, - "chainId": { - "type": "string" - } - }, - "required": [ - "kind", - "priceLockCode", - "amount", - "destinationAddress", - "currency", - "signedRainWithdrawal", - "chainId" - ] - } - ] - } - } - } - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/manteca/webhook": { - "post": { - "summary": "Manteca webhook handler", - "tags": ["manteca"], - "description": "Handle webhook notifications from Manteca about synthetic status updates", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "event": { - "description": "Event type from Manteca", - "type": "string" - }, - "data": { - "description": "Event payload from Manteca" - } - }, - "required": ["event", "data"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "md-webhook-signature", - "required": false, - "description": "HMAC signature for webhook verification" - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/manteca/initiate-onboarding": { - "post": { - "summary": "Initiate Manteca onboarding", - "tags": ["manteca"], - "description": "Creates an onboarding widget URL for the current user using userId as userExternalId and returns the URL", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "returnUrl": { - "type": "string" - }, - "failureUrl": { - "type": "string" - }, - "exchange": { - "type": "string" - } - }, - "required": ["returnUrl"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/manteca/prices": { - "get": { - "parameters": [ - { - "schema": { - "anyOf": [ - { - "type": "string", - "enum": ["USDT"] - }, - { - "type": "string", - "enum": ["USDC"] - }, - { - "type": "string", - "enum": ["ETH"] - }, - { - "type": "string", - "enum": ["BTC"] - }, - { - "type": "string", - "enum": ["ARS"] - }, - { - "type": "string", - "enum": ["USD"] - }, - { - "type": "string", - "enum": ["BRL"] - }, - { - "type": "string", - "enum": ["CLP"] - } - ] - }, - "in": "query", - "name": "asset", - "required": true - }, - { - "schema": { - "anyOf": [ - { - "type": "string", - "enum": ["USDT"] - }, - { - "type": "string", - "enum": ["USDC"] - }, - { - "type": "string", - "enum": ["ETH"] - }, - { - "type": "string", - "enum": ["BTC"] - }, - { - "type": "string", - "enum": ["ARS"] - }, - { - "type": "string", - "enum": ["USD"] - }, - { - "type": "string", - "enum": ["BRL"] - }, - { - "type": "string", - "enum": ["CLP"] - } - ] - }, - "in": "query", - "name": "against", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/perks/claim": { - "post": { - "summary": "Claim a perk", - "tags": ["perks"], - "description": "User-triggered action to claim USDC sponsorship for an eligible perk", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "usageId": { - "description": "PerkUsage id to claim", - "type": "string" - } - }, - "required": ["usageId"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/perks/pending": { - "get": { - "summary": "Get pending perks", - "tags": ["perks"], - "description": "Returns all PENDING_CLAIM perks for the authenticated user", - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "perks": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "amountUsd": { - "type": "number" - }, - "createdAt": { - "type": "string" - }, - "inviteeName": { - "type": "string" - } - }, - "required": ["id", "amountUsd", "createdAt"] - } - } - }, - "required": ["perks"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["error", "message"] - } - } - } - } - } - } - }, - "/invites/accept": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "inviteCode": { - "type": "string" - }, - "type": { - "anyOf": [ - { - "type": "string", - "enum": ["DIRECT"] - }, - { - "type": "string", - "enum": ["PAYMENT_LINK"] - } - ] - }, - "campaignTag": { - "type": "string" - } - }, - "required": ["inviteCode", "type"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/invites": { - "get": { - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/invites/validate": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "inviteCode": { - "type": "string" - } - }, - "required": ["inviteCode"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/invites/waitlist-position": { - "get": { - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/invites/graph": { - "get": { - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/invites/graph/external": { - "get": { - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/invites/user-graph": { - "get": { - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/notifications/send": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "externalUserIds": { - "minItems": 1, - "maxItems": 10, - "type": "array", - "items": { - "type": "string" - } - }, - "title": { - "type": "string" - }, - "message": { - "type": "string" - }, - "url": { - "type": "string" - }, - "data": { - "type": "object", - "additionalProperties": {} - }, - "templateId": { - "type": "string" - }, - "idempotencyKey": { - "type": "string" - }, - "channel": { - "anyOf": [ - { - "type": "string", - "enum": ["push"] - }, - { - "type": "string", - "enum": ["email"] - } - ] - } - }, - "required": ["externalUserIds"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "x-admin-token", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/notifications/admin/recent": { - "get": { - "summary": "Recent notification rows for a user (all channels + statuses)", - "tags": ["admin"], - "parameters": [ - { - "schema": { - "minLength": 1, - "type": "string" - }, - "in": "query", - "name": "userId", - "required": true - }, - { - "schema": { - "minimum": 1, - "maximum": 100, - "default": 20, - "type": "integer" - }, - "in": "query", - "name": "limit", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "x-admin-token", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "eventType": { - "type": "string" - }, - "channel": { - "type": "string" - }, - "status": { - "anyOf": [ - { - "type": "string", - "enum": ["PENDING"] - }, - { - "type": "string", - "enum": ["SENT"] - }, - { - "type": "string", - "enum": ["FAILED"] - }, - { - "type": "string", - "enum": ["SKIPPED"] - } - ] - }, - "skipReason": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "providerId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "error": { - "anyOf": [ - { - "type": "object", - "properties": { - "reason": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": ["reason", "name"] - }, - { - "type": "null" - } - ] - }, - "createdAt": { - "type": "string" - }, - "sentAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "eventType", - "channel", - "status", - "skipReason", - "providerId", - "error", - "createdAt", - "sentAt" - ] - } - } - }, - "required": ["items"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "401": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/notifications": { - "get": { - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/notifications/unread-count": { - "get": { - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/notifications/mark-read": { - "post": { - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/unsubscribe": { - "get": { - "tags": ["notifications"], - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "token", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - }, - "post": { - "tags": ["notifications"], - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "token", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/webhooks/onesignal": { - "post": { - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/badge/award": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "campaignTag": { - "type": "string" - } - }, - "required": ["campaignTag"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/admin/support/grant": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "username": { - "minLength": 1, - "maxLength": 64, - "type": "string" - }, - "override": { - "type": "boolean" - }, - "amountUsd": { - "minimum": 0.01, - "maximum": 50, - "type": "number" - } - }, - "required": ["username"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "x-admin-token", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/admin/card-waitlist/release": { - "post": { - "summary": "Release users from the card waitlist", - "tags": ["admin"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "userIds": { - "minItems": 1, - "maxItems": 100, - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["userIds"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "x-admin-token", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "released": { - "type": "array", - "items": { - "type": "string" - } - }, - "skipped": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["released", "skipped"] - } - } - } - }, - "401": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["error", "message"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["error", "message"] - } - } - } - } - } - } - }, - "/points": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/points/history": { - "get": { - "parameters": [ - { - "schema": { - "minimum": 1, - "maximum": 100, - "type": "number" - }, - "in": "query", - "name": "limit", - "required": false - }, - { - "schema": { - "minimum": 0, - "type": "number" - }, - "in": "query", - "name": "offset", - "required": false - }, - { - "schema": { - "anyOf": [ - { - "type": "string", - "enum": ["BRIDGE_FEE"] - }, - { - "type": "string", - "enum": ["MANTECA_FEE"] - }, - { - "type": "string", - "enum": ["RAIN_CARD_SPEND"] - }, - { - "type": "string", - "enum": ["CHARGE_FEE"] - }, - { - "type": "string", - "enum": ["P2P_SEND_LINK"] - }, - { - "type": "string", - "enum": ["P2P_REQUEST_PAYMENT"] - }, - { - "type": "string", - "enum": ["CRYPTO_WITHDRAW"] - }, - { - "type": "string", - "enum": ["SIGNUP"] - }, - { - "type": "string", - "enum": ["KYC_VERIFIED"] - }, - { - "type": "string", - "enum": ["TRANSITIVE_UPDATE"] - }, - { - "type": "string", - "enum": ["HANDSHAKE_BONUS"] - }, - { - "type": "string", - "enum": ["ADMIN_ADJUSTMENT"] - }, - { - "type": "string", - "enum": ["PERK_REDEMPTION"] - }, - { - "type": "string", - "enum": ["MIGRATION_FROM_OLD_SYSTEM"] - }, - { - "type": "string", - "enum": ["REFERRAL_REWARD_ACCRUAL"] - } - ] - }, - "in": "query", - "name": "type", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/points/leaderboard": { - "get": { - "parameters": [ - { - "schema": { - "minimum": 1, - "maximum": 500, - "type": "number" - }, - "in": "query", - "name": "limit", - "required": false - }, - { - "schema": { - "type": "boolean" - }, - "in": "query", - "name": "includeMe", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/points/time-leaderboard": { - "get": { - "parameters": [ - { - "schema": { - "minimum": 1, - "maximum": 100, - "type": "number" - }, - "in": "query", - "name": "limit", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "since", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/points/invites": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/points/cash-status": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/points/calculate": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "actionType": { - "anyOf": [ - { - "type": "string", - "enum": ["BRIDGE_TRANSFER"] - }, - { - "type": "string", - "enum": ["MANTECA_TRANSFER"] - }, - { - "type": "string", - "enum": ["MANTECA_QR_PAYMENT"] - }, - { - "type": "string", - "enum": ["P2P_SEND_LINK"] - }, - { - "type": "string", - "enum": ["P2P_REQUEST_PAYMENT"] - }, - { - "type": "string", - "enum": ["KYC_VERIFIED"] - } - ] - }, - "usdAmount": { - "minimum": 0, - "type": "number" - }, - "otherUserId": { - "type": "string" - } - }, - "required": ["actionType"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/points/admin/adjust": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "userId": { - "type": "string" - }, - "pointsChange": { - "type": "number" - }, - "reason": { - "type": "string" - } - }, - "required": ["userId", "pointsChange", "reason"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/quests/leaderboards": { - "get": { - "parameters": [ - { - "schema": { - "minimum": 1, - "maximum": 10, - "default": 3, - "type": "number" - }, - "in": "query", - "name": "limit", - "required": false - }, - { - "schema": { - "default": false, - "type": "boolean" - }, - "in": "query", - "name": "useTestTimePeriod", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/quests/{questId}/leaderboard": { - "get": { - "parameters": [ - { - "schema": { - "minimum": 1, - "maximum": 10, - "default": 10, - "type": "number" - }, - "in": "query", - "name": "limit", - "required": false - }, - { - "schema": { - "default": false, - "type": "boolean" - }, - "in": "query", - "name": "useTestTimePeriod", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "questId", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/qr/{code}": { - "get": { - "summary": "Get QR redirect or status", - "tags": ["redirects"], - "description": "Returns redirect URL if claimed, or indicates QR is available to claim", - "parameters": [ - { - "schema": { - "minLength": 16, - "maxLength": 16, - "type": "string" - }, - "in": "path", - "name": "code", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/qr/{code}/claim": { - "post": { - "summary": "Claim a QR code", - "tags": ["redirects"], - "description": "Claim an unclaimed QR code and tie it to your invite link", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "targetUrl": { - "format": "uri", - "type": "string" - } - } - } - } - } - }, - "parameters": [ - { - "schema": { - "minLength": 16, - "maxLength": 16, - "type": "string" - }, - "in": "path", - "name": "code", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/rhino/deposit": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "destinationAddress": { - "type": "string" - }, - "type": { - "anyOf": [ - { - "type": "string", - "enum": ["EVM"] - }, - { - "type": "string", - "enum": ["SOL"] - }, - { - "type": "string", - "enum": ["TRON"] - } - ] - } - }, - "required": ["destinationAddress", "type"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/rhino/request-fulfilment": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "type": { - "anyOf": [ - { - "type": "string", - "enum": ["EVM"] - }, - { - "type": "string", - "enum": ["SOL"] - }, - { - "type": "string", - "enum": ["TRON"] - } - ] - }, - "chargeId": { - "type": "string" - }, - "senderPeanutWalletAddress": { - "type": "string" - } - }, - "required": ["type", "chargeId"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/rhino/rhinofi-event": { - "post": { - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/rhino/status/{depositAddress}": { - "get": { - "tags": ["rhino"], - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "depositAddress", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/rhino/reset-status/{depositAddress}": { - "post": { - "tags": ["rhino"], - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "depositAddress", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/rhino/sda-transfer/preview": { - "post": { - "tags": ["rhino"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "chainIn": { - "type": "string" - }, - "chainOut": { - "type": "string" - }, - "token": { - "minLength": 1, - "type": "string" - }, - "amount": { - "type": "string" - }, - "mode": { - "anyOf": [ - { - "type": "string", - "enum": ["pay"] - }, - { - "type": "string", - "enum": ["receive"] - } - ] - } - }, - "required": ["chainIn", "chainOut", "token", "amount", "mode"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/rhino/sda-transfer": { - "post": { - "tags": ["rhino"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "context": { - "anyOf": [ - { - "type": "string", - "enum": ["withdraw"] - }, - { - "type": "string", - "enum": ["pay-request"] - }, - { - "type": "string", - "enum": ["claim-xchain"] - } - ] - }, - "contextId": { - "type": "string" - }, - "depositChain": { - "type": "string" - }, - "destinationChain": { - "type": "string" - }, - "destinationAddress": { - "type": "string" - }, - "tokenOut": { - "minLength": 1, - "type": "string" - }, - "senderPeanutWalletAddress": { - "type": "string" - }, - "feeUsd": { - "type": "number" - }, - "payAmount": { - "type": "string" - }, - "receiveAmount": { - "type": "string" - } - }, - "required": [ - "context", - "contextId", - "depositChain", - "destinationChain", - "destinationAddress", - "tokenOut" - ] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/rhino/bridge/quote": { - "post": { - "tags": ["rhino"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "minLength": 1, - "type": "string" - }, - "tokenIn": { - "minLength": 1, - "type": "string" - }, - "tokenOut": { - "minLength": 1, - "type": "string" - }, - "chainOut": { - "minLength": 1, - "type": "string" - }, - "recipient": { - "minLength": 1, - "type": "string" - }, - "depositor": { - "minLength": 1, - "type": "string" - }, - "mode": { - "anyOf": [ - { - "type": "string", - "enum": ["pay"] - }, - { - "type": "string", - "enum": ["receive"] - } - ] - } - }, - "required": [ - "amount", - "tokenIn", - "tokenOut", - "chainOut", - "recipient", - "depositor", - "mode" - ] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/rhino/bridge/commit": { - "post": { - "tags": ["rhino"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "quoteId": { - "minLength": 1, - "type": "string" - }, - "isSwap": { - "type": "boolean" - }, - "isSameChainSwap": { - "type": "boolean" - } - }, - "required": ["quoteId", "isSwap", "isSameChainSwap"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/rhino/bridge/status/{bridgeId}": { - "get": { - "tags": ["rhino"], - "parameters": [ - { - "schema": { - "minLength": 1, - "type": "string" - }, - "in": "path", - "name": "bridgeId", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/rhino/bridge/chains": { - "get": { - "tags": ["rhino"], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/card": { - "get": { - "summary": "Get card info + waitlist state", - "tags": ["card"], - "description": "Returns the authenticated user's card flow access, eligibility, waitlist state, and skip-badge holdings.", - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "hasCardAccess": { - "type": "boolean" - }, - "isEligible": { - "type": "boolean" - }, - "eligibilityReason": { - "type": "string" - }, - "geoProhibited": { - "type": "boolean" - }, - "flowEarlyAccess": { - "type": "boolean" - }, - "isPublicLaunched": { - "type": "boolean" - }, - "waitlistJoinedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "waitlistPosition": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "waitlistReleasedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "skipBadges": { - "type": "array", - "items": { - "type": "string" - } - }, - "waitlistTotal": { - "type": "number" - }, - "admittedTotal": { - "type": "number" - } - }, - "required": [ - "hasCardAccess", - "isEligible", - "flowEarlyAccess", - "isPublicLaunched", - "waitlistJoinedAt", - "waitlistPosition", - "waitlistReleasedAt", - "skipBadges", - "waitlistTotal", - "admittedTotal" - ] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["error", "message"] - } - } - } - } - } - } - }, - "/card/waitlist/join": { - "post": { - "summary": "Join the virtual-card waitlist", - "tags": ["card"], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "joinedAt": { - "type": "string" - }, - "position": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - } - }, - "required": ["joinedAt", "position"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["error", "message"] - } - } - } - } - } - } - }, - "/card/waitlist/state": { - "get": { - "summary": "Get the user’s current waitlist state", - "tags": ["card"], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "joinedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "position": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "releasedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": ["joinedAt", "position", "releasedAt"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["error", "message"] - } - } - } - } - } - } - }, - "/card/flow-early-access": { - "post": { - "summary": "Grant the user early access to the /card flow (via /shhhhh)", - "tags": ["card"], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "grantedAt": { - "type": "string" - } - }, - "required": ["grantedAt"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["error", "message"] - } - } - } - } - } - } - }, - "/sumsub/webhooks": { - "post": { - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/users/identity": { - "post": { - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/users/identity/resubmit": { - "post": { - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/rain/cards": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "termsAccepted": { - "type": "boolean" - }, - "serializedApproval": { - "minLength": 1, - "type": "string" - }, - "confirmedResidenceCountry": { - "minLength": 2, - "maxLength": 2, - "type": "string" - }, - "acceptedDocuments": { - "maxItems": 8, - "type": "array", - "items": { - "type": "object", - "properties": { - "slug": { - "type": "string" - }, - "version": { - "type": "string" - }, - "hash": { - "type": "string" - } - }, - "required": ["slug", "version"] - } - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["pending"] - }, - "rainUserId": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["status", "rainUserId", "message"] - }, - { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["incomplete"] - }, - "missing": { - "type": "array", - "items": { - "type": "string" - } - }, - "questionnaireComplete": { - "type": "boolean" - }, - "sumsubAccessToken": { - "type": "string" - } - }, - "required": [ - "status", - "missing", - "questionnaireComplete", - "sumsubAccessToken" - ] - }, - { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["main-kyc-required"] - }, - "missingDocTypes": { - "type": "array", - "items": { - "type": "string" - } - }, - "sumsubAccessToken": { - "type": "string" - } - }, - "required": ["status", "missingDocTypes", "sumsubAccessToken"] - }, - { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["terms-required"] - }, - "isUsResident": { - "type": "boolean" - }, - "termsVersion": { - "type": "string" - } - }, - "required": ["status", "isUsResident", "termsVersion"] - }, - { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["country-confirmation-required"] - }, - "candidates": { - "type": "array", - "items": { - "type": "string" - } - }, - "evidence": { - "type": "object", - "properties": { - "addressCountry": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "idDocumentCountry": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": ["addressCountry", "idDocumentCountry"] - } - }, - "required": ["status", "candidates", "evidence"] - }, - { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["geo-blocked"] - }, - "message": { - "type": "string" - } - }, - "required": ["status", "message"] - }, - { - "type": "object", - "properties": { - "status": { - "type": "string" - }, - "rainUserId": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["status", "message"] - } - ] - } - } - } - } - } - }, - "get": { - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "status": { - "type": "object", - "properties": { - "hasApplication": { - "type": "boolean" - }, - "railStatus": { - "type": "string" - }, - "applicationStatus": { - "type": "string" - }, - "rainUserId": { - "type": "string" - }, - "contractAddress": { - "type": "string" - }, - "coordinatorAddress": { - "type": "string" - } - }, - "required": ["hasApplication"] - }, - "balance": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "object", - "properties": { - "creditLimit": { - "type": "number" - }, - "pendingCharges": { - "type": "number" - }, - "postedCharges": { - "type": "number" - }, - "balanceDue": { - "type": "number" - }, - "spendingPower": { - "type": "number" - }, - "inTransitToCollateralCents": { - "type": "number" - } - }, - "required": [ - "creditLimit", - "pendingCharges", - "postedCharges", - "balanceDue", - "spendingPower", - "inTransitToCollateralCents" - ] - } - ] - }, - "balanceUnavailable": { - "type": "boolean" - }, - "cards": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "rainCardId": { - "type": "string" - }, - "last4": { - "type": "string" - }, - "expiryMonth": { - "type": "number" - }, - "expiryYear": { - "type": "number" - }, - "status": { - "type": "string" - }, - "network": { - "type": "string" - }, - "issuedAt": { - "type": "string" - }, - "hasWithdrawApproval": { - "type": "boolean" - } - }, - "required": [ - "id", - "rainCardId", - "last4", - "expiryMonth", - "expiryYear", - "status", - "network", - "issuedAt", - "hasWithdrawApproval" - ] - } - } - }, - "required": ["status", "balance", "balanceUnavailable", "cards"] - } - } - } - } - } - } - }, - "/rain/cards/status": { - "get": { - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "hasApplication": { - "type": "boolean" - }, - "status": { - "type": "string" - }, - "applicationStatus": { - "type": "string" - }, - "rainUserId": { - "type": "string" - }, - "contractAddress": { - "type": "string" - } - }, - "required": ["hasApplication"] - } - } - } - } - } - } - }, - "/rain/cards/readiness": { - "get": { - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ready": { - "type": "boolean" - }, - "hasApplication": { - "type": "boolean" - }, - "readyAt": { - "type": "string" - } - }, - "required": ["ready", "hasApplication"] - } - } - } - } - } - } - }, - "/rain/cards/{cardId}/activate": { - "post": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "cardId", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/rain/cards/{cardId}/lock": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "verifiedWithdrawal": { - "type": "object", - "properties": { - "preparationId": { - "minLength": 1, - "type": "string" - }, - "amount": { - "minLength": 1, - "type": "string" - }, - "recipientAddress": { - "pattern": "^0x[0-9a-fA-F]{40}$", - "type": "string" - }, - "directTransfer": { - "type": "boolean" - }, - "adminSalt": { - "pattern": "^0x[0-9a-fA-F]{64}$", - "type": "string" - }, - "adminNonce": { - "minLength": 1, - "type": "string" - }, - "adminSignature": { - "pattern": "^0x[0-9a-fA-F]+$", - "type": "string" - }, - "executorSignature": { - "type": "string" - }, - "executorSalt": { - "type": "string" - }, - "expiresAt": { - "type": "number" - } - }, - "required": [ - "preparationId", - "amount", - "recipientAddress", - "directTransfer", - "adminSalt", - "adminNonce", - "adminSignature", - "executorSignature", - "executorSalt", - "expiresAt" - ] - } - } - } - } - } - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "cardId", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/rain/cards/{cardId}/cancel": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "feedback": { - "maxLength": 2000, - "type": "string" - }, - "verifiedWithdrawal": { - "type": "object", - "properties": { - "preparationId": { - "minLength": 1, - "type": "string" - }, - "amount": { - "minLength": 1, - "type": "string" - }, - "recipientAddress": { - "pattern": "^0x[0-9a-fA-F]{40}$", - "type": "string" - }, - "directTransfer": { - "type": "boolean" - }, - "adminSalt": { - "pattern": "^0x[0-9a-fA-F]{64}$", - "type": "string" - }, - "adminNonce": { - "minLength": 1, - "type": "string" - }, - "adminSignature": { - "pattern": "^0x[0-9a-fA-F]+$", - "type": "string" - }, - "executorSignature": { - "type": "string" - }, - "executorSalt": { - "type": "string" - }, - "expiresAt": { - "type": "number" - } - }, - "required": [ - "preparationId", - "amount", - "recipientAddress", - "directTransfer", - "adminSalt", - "adminNonce", - "adminSignature", - "executorSignature", - "executorSalt", - "expiresAt" - ] - } - } - } - } - } - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "cardId", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/rain/cards/{cardId}/cancellation-feedback": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "feedback": { - "minLength": 1, - "maxLength": 2000, - "type": "string" - } - }, - "required": ["feedback"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "cardId", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean" - } - }, - "required": ["ok"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/rain/cards/{cardId}": { - "patch": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "cardLimit": { - "minimum": 0, - "type": "number" - }, - "limits": { - "type": "array", - "items": { - "type": "object", - "properties": { - "amount": { - "minimum": 0, - "type": "number" - }, - "frequency": { - "anyOf": [ - { - "type": "string", - "enum": ["perAuthorization"] - }, - { - "type": "string", - "enum": ["per24HourPeriod"] - }, - { - "type": "string", - "enum": ["per30DayPeriod"] - }, - { - "type": "string", - "enum": ["perAllTime"] - } - ] - } - }, - "required": ["amount", "frequency"] - } - }, - "autoBalanceEnabled": { - "type": "boolean" - } - } - } - } - } - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "cardId", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean" - } - }, - "required": ["ok"] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "502": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/rain/cards/{cardId}/physical-waitlist": { - "post": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "cardId", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "joinedAt": { - "type": "string" - }, - "position": { - "type": "number" - } - }, - "required": ["joinedAt", "position"] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - }, - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "cardId", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "joinedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "position": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - } - }, - "required": ["joinedAt", "position"] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/rain/cards/{cardId}/limits": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "cardId", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "limits": { - "type": "array", - "items": { - "type": "object", - "properties": { - "amount": { - "type": "number" - }, - "frequency": { - "anyOf": [ - { - "type": "string", - "enum": ["perAuthorization"] - }, - { - "type": "string", - "enum": ["per24HourPeriod"] - }, - { - "type": "string", - "enum": ["per30DayPeriod"] - }, - { - "type": "string", - "enum": ["perAllTime"] - } - ] - } - }, - "required": ["amount", "frequency"] - } - } - }, - "required": ["limits"] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "502": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/rain/cards/balance": { - "get": { - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "creditLimit": { - "type": "number" - }, - "pendingCharges": { - "type": "number" - }, - "postedCharges": { - "type": "number" - }, - "balanceDue": { - "type": "number" - }, - "spendingPower": { - "type": "number" - } - }, - "required": [ - "creditLimit", - "pendingCharges", - "postedCharges", - "balanceDue", - "spendingPower" - ] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/rain/cards/recover-funds/preview": { - "get": { - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "collateralProxy": { - "type": "string" - }, - "recipient": { - "type": "string" - }, - "amountWei": { - "type": "string" - }, - "amountCents": { - "type": "string" - }, - "dustWei": { - "type": "string" - }, - "autoBalanceEnabled": { - "type": "boolean" - }, - "hasRecoverableCard": { - "type": "boolean" - } - }, - "required": [ - "collateralProxy", - "recipient", - "amountWei", - "amountCents", - "dustWei", - "autoBalanceEnabled", - "hasRecoverableCard" - ] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "502": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/rain/cards/recover-funds/prepare": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "preparationId": { - "type": "string" - }, - "coordinatorAddress": { - "type": "string" - }, - "collateralProxy": { - "type": "string" - }, - "adminAddress": { - "type": "string" - }, - "chainId": { - "type": "string" - }, - "tokenAddress": { - "type": "string" - }, - "amount": { - "type": "string" - }, - "recipientAddress": { - "type": "string" - }, - "directTransfer": { - "type": "boolean" - }, - "adminSalt": { - "type": "string" - }, - "adminNonce": { - "type": "string" - }, - "executorSignature": { - "type": "string" - }, - "executorSalt": { - "type": "string" - }, - "expiresAt": { - "type": "number" - }, - "amountCents": { - "type": "string" - }, - "dustWei": { - "type": "string" - } - }, - "required": [ - "preparationId", - "coordinatorAddress", - "collateralProxy", - "adminAddress", - "chainId", - "tokenAddress", - "amount", - "recipientAddress", - "directTransfer", - "adminSalt", - "adminNonce", - "executorSignature", - "executorSalt", - "expiresAt", - "amountCents", - "dustWei" - ] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "422": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "502": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/rain/cards/session-key-address": { - "get": { - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "address": { - "type": "string" - } - }, - "required": ["address"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/rain/cards/auto-balance/approve": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "serializedApproval": { - "minLength": 1, - "type": "string" - }, - "cardLimit": { - "minimum": 0, - "type": "number" - } - }, - "required": ["serializedApproval"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean" - } - }, - "required": ["ok"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/rain/cards/{cardId}/details": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "cardId", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "pan": { - "type": "string" - }, - "cvv": { - "type": "string" - }, - "expiryMonth": { - "type": "number" - }, - "expiryYear": { - "type": "number" - }, - "last4": { - "type": "string" - }, - "network": { - "type": "string" - }, - "cardholderName": { - "type": "string" - } - }, - "required": ["pan", "cvv", "expiryMonth", "expiryYear", "last4", "network"] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "429": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/rain/cards/{cardId}/pin": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "cardId", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "pin": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": ["pin"] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "429": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - }, - "put": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "pin": { - "minLength": 4, - "maxLength": 4, - "type": "string" - } - }, - "required": ["pin"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "cardId", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean" - } - }, - "required": ["ok"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "429": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/rain/webhooks": { - "post": { - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/tokens/price": { - "get": { - "tags": ["tokens"], - "parameters": [ - { - "schema": { - "minLength": 1, - "type": "string" - }, - "in": "query", - "name": "address", - "required": true - }, - { - "schema": { - "minLength": 1, - "type": "string" - }, - "in": "query", - "name": "chainId", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/tokens/wallet-portfolio": { - "get": { - "tags": ["tokens"], - "parameters": [ - { - "schema": { - "minLength": 1, - "type": "string" - }, - "in": "query", - "name": "address", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/dev/test-session": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "email": { - "minLength": 5, - "description": "Email. Finds or creates the user.", - "type": "string" - }, - "userId": { - "type": "string" - }, - "country": { - "minLength": 2, - "maxLength": 3, - "type": "string" - }, - "kyc": { - "anyOf": [ - { - "type": "string", - "enum": ["verified"] - }, - { - "type": "string", - "enum": ["pending"] - }, - { - "type": "string", - "enum": ["rejected"] - }, - { - "type": "string", - "enum": ["none"] - } - ] - }, - "provider": { - "anyOf": [ - { - "type": "string", - "enum": ["sumsub"] - }, - { - "type": "string", - "enum": ["bridge"] - }, - { - "type": "string", - "enum": ["manteca"] - } - ] - }, - "username": { - "type": "string" - }, - "harnessLabel": { - "type": "string" - } - }, - "required": ["email"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "x-test-harness-secret", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "token": { - "type": "string" - }, - "user": { - "type": "object", - "properties": { - "userId": { - "type": "string" - }, - "email": { - "type": "string" - }, - "username": { - "type": "string" - }, - "harnessLabel": { - "type": "string" - } - }, - "required": ["userId", "email", "harnessLabel"] - } - }, - "required": ["token", "user"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/dev/seed-scenario": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "scenario": { - "anyOf": [ - { - "type": "string", - "enum": ["send-link-pending"] - }, - { - "type": "string", - "enum": ["send-link-claimed"] - }, - { - "type": "string", - "enum": ["request-pot-open"] - }, - { - "type": "string", - "enum": ["kyc-matrix"] - }, - { - "type": "string", - "enum": ["user-with-bank-accounts"] - }, - { - "type": "string", - "enum": ["withdraw-ready"] - }, - { - "type": "string", - "enum": ["manteca-qr-payment"] - }, - { - "type": "string", - "enum": ["multi-user-send"] - }, - { - "type": "string", - "enum": ["points-and-perks"] - } - ] - }, - "harnessLabel": { - "type": "string" - } - }, - "required": ["scenario"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "x-test-harness-secret", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "scenario": { - "type": "string" - }, - "data": {} - }, - "required": ["scenario", "data"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/dev/reproduce": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "scenario": { - "description": "qa scenario name for logging", - "type": "string" - }, - "entry": { - "type": "object", - "properties": { - "route": { - "default": "/home", - "type": "string" - }, - "userId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": ["route"] - }, - "localStorage": { - "type": "object", - "additionalProperties": {} - }, - "stepSnapshot": { - "type": "object", - "properties": { - "userIds": { - "type": "array", - "items": { - "type": "string" - } - }, - "tables": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "type": "object", - "patternProperties": { - "^(.*)$": {} - } - } - } - }, - "capturedAt": { - "type": "string" - } - }, - "required": ["userIds", "tables"] - }, - "stepActions": { - "type": "array", - "items": {} - }, - "notes": { - "type": "string" - } - }, - "required": ["scenario", "entry"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "x-test-harness-secret", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "sessionId": { - "type": "string" - }, - "userId": { - "type": "string" - }, - "token": { - "type": "string" - }, - "localStorage": { - "type": "object", - "additionalProperties": {} - }, - "restored": { - "type": "boolean" - } - }, - "required": ["url", "sessionId", "userId", "token", "localStorage"] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/dev/reproduce/{sessionId}": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "sessionId", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "scenario": { - "type": "string" - }, - "entry": { - "type": "object", - "properties": { - "route": { - "type": "string" - }, - "userId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": ["route", "userId"] - }, - "localStorage": { - "type": "object", - "additionalProperties": {} - }, - "stepActions": { - "type": "array", - "items": {} - }, - "userId": { - "type": "string" - }, - "token": { - "type": "string" - } - }, - "required": ["scenario", "entry", "localStorage", "userId", "token"] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/dev/poll": { - "post": { - "summary": "Run one polling cycle synchronously", - "tags": ["dev"], - "description": "Triggers runPollingCycle() on demand — used by the QA harness to observe provider state transitions without waiting for the 5-minute interval.", - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean" - }, - "ms": { - "type": "number" - } - }, - "required": ["ok", "ms"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean" - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/register-address": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "address": { - "description": "0x-prefixed hex address (lowercased server-side)", - "type": "string" - }, - "userId": { - "description": "Peanut user_id to associate with this address", - "type": "string" - } - }, - "required": ["address", "userId"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean" - }, - "registered": { - "type": "string" - } - }, - "required": ["ok", "registered"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean" - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/reset-deposit-tracker": { - "post": { - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean" - }, - "registeredAddresses": { - "type": "number" - }, - "skippedInvalid": { - "type": "number" - }, - "lastProcessedBlock": { - "type": "string" - } - }, - "required": ["ok", "registeredAddresses", "skippedInvalid", "lastProcessedBlock"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean" - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/reset-harness-data/{label}": { - "post": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "label", - "required": true, - "description": "harnessLabel value to wipe" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean" - }, - "deleted": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": ["ok", "deleted", "label"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean" - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/submit-to-providers": { - "post": { - "summary": "Run KYC provider submission synchronously for the given user/applicant", - "tags": ["dev"], - "description": "Drives Peanut's submitToProviders(userId, applicantId) synchronously, the same function the Sumsub status-processor calls after GREEN. Used by QA harness to test routing (AR user → Manteca, US user → Bridge) end-to-end.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "userId": { - "description": "Peanut user_id whose rails to submit", - "type": "string" - }, - "applicantId": { - "description": "Sumsub applicant id with approved docs + extracted data", - "type": "string" - } - }, - "required": ["userId", "applicantId"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean" - }, - "ms": { - "type": "number" - } - }, - "required": ["ok", "ms"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean" - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/trigger-sumsub-webhook": { - "post": { - "summary": "Synthesise a Sumsub webhook and run the status-processor", - "tags": ["dev"], - "description": "Invokes processSumsubKycUpdate({ applicantId, externalUserId, reviewAnswer, reviewStatus, source: 'webhook' }) — same entry point as the real /sumsub/webhooks route, minus HMAC. Used by the QA harness to exercise the full Sumsub → routing chain without real webhook delivery.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "applicantId": { - "description": "Sumsub applicant id", - "type": "string" - }, - "externalUserId": { - "description": "Peanut user_id (applicant.externalUserId)", - "type": "string" - }, - "reviewAnswer": { - "default": "GREEN", - "anyOf": [ - { - "type": "string", - "enum": ["GREEN"] - }, - { - "type": "string", - "enum": ["RED"] - }, - { - "type": "string", - "enum": ["ERROR"] - } - ] - }, - "reviewStatus": { - "description": "Sumsub reviewStatus (default: \"completed\")", - "type": "string" - }, - "levelName": { - "type": "string" - }, - "webhookType": { - "description": "e.g. \"applicantReviewed\"", - "type": "string" - } - }, - "required": ["applicantId", "externalUserId"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean" - }, - "ms": { - "type": "number" - } - }, - "required": ["ok", "ms"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean" - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/ledger/history": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "userId", - "required": true, - "description": "Peanut user_id (varchar)" - }, - { - "schema": { - "minimum": 1, - "maximum": 500, - "default": 50, - "type": "integer" - }, - "in": "query", - "name": "limit", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean" - }, - "userId": { - "type": "string" - }, - "count": { - "type": "number" - }, - "intents": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "kind": { - "type": "string" - }, - "provider": { - "type": "string" - }, - "status": { - "type": "string" - }, - "requestedAmount": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "requestedAsset": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "settledAmount": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "settledAsset": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "fxRate": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "createdAt": { - "type": "string" - }, - "completedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "entryCount": { - "type": "number" - } - }, - "required": [ - "id", - "kind", - "provider", - "status", - "requestedAmount", - "requestedAsset", - "settledAmount", - "settledAsset", - "fxRate", - "createdAt", - "completedAt", - "entryCount" - ] - } - } - }, - "required": ["ok", "userId", "count", "intents"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean" - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/invite-code": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "ensureUsername", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "inviteCode": { - "type": "string" - }, - "inviterUsername": { - "type": "string" - } - }, - "required": ["ok", "inviteCode", "inviterUsername"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/whoami": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "userId", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "userId": { - "type": "string" - }, - "username": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "email": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "hasMantecaUserId": { - "type": "boolean" - }, - "hasBridgeCustomerId": { - "type": "boolean" - }, - "bridgeKycStatus": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "walletAddresses": { - "type": "array", - "items": { - "type": "string" - } - }, - "kycVerifications": { - "type": "array", - "items": { - "type": "object", - "properties": { - "provider": { - "type": "string" - }, - "status": { - "type": "string" - }, - "mantecaGeo": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": ["provider", "status", "mantecaGeo"] - } - } - }, - "required": [ - "ok", - "userId", - "username", - "email", - "hasMantecaUserId", - "hasBridgeCustomerId", - "bridgeKycStatus", - "walletAddresses", - "kycVerifications" - ] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/fund-sa": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "userId": { - "type": "string" - }, - "usdc": { - "description": "6-decimal USDC, default 10000000 = $10", - "type": "string" - }, - "ethWei": { - "type": "string" - } - }, - "required": ["userId"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "txHash": { - "type": "string" - }, - "saAddress": { - "type": "string" - }, - "usdcSent": { - "type": "string" - }, - "ethSent": { - "type": "string" - } - }, - "required": ["ok", "txHash", "saAddress", "usdcSent", "ethSent"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/sweep-perk-to-kernel": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "description": "6-decimal USDC base units; defaults to entire EOA balance", - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "txHash": { - "type": "string" - }, - "eoaAddress": { - "type": "string" - }, - "kernelAddress": { - "type": "string" - }, - "usdcSwept": { - "type": "string" - } - }, - "required": ["ok", "txHash", "eoaAddress", "kernelAddress", "usdcSwept"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/peanut-make-deposit": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "type": "string" - }, - "recipientAddress": { - "pattern": "^0x[0-9a-fA-F]{40}$", - "description": "Address that the recipient will withdraw to", - "type": "string" - } - }, - "required": ["recipientAddress"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "vaultAddress": { - "type": "string" - }, - "depositIdx": { - "type": "number" - }, - "password": { - "type": "string" - }, - "pubKey20": { - "type": "string" - }, - "claimParams": { - "type": "array", - "items": {} - }, - "makeDepositTxHash": { - "type": "string" - }, - "amount": { - "type": "string" - } - }, - "required": [ - "ok", - "vaultAddress", - "depositIdx", - "password", - "pubKey20", - "claimParams", - "makeDepositTxHash", - "amount" - ] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/approve-kyc": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "userId": { - "type": "string" - }, - "provider": { - "anyOf": [ - { - "type": "string", - "enum": ["bridge"] - }, - { - "type": "string", - "enum": ["manteca"] - }, - { - "type": "string", - "enum": ["sumsub"] - } - ] - }, - "country": { - "description": "ISO-2 (AR/BR/US/GB/DE/MX)", - "type": "string" - } - }, - "required": ["userId", "provider"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "updated": { - "type": "string" - }, - "details": {} - }, - "required": ["ok", "updated", "details"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/fund-rain-collateral": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "userId": { - "type": "string" - }, - "amountMicros": { - "type": "string" - } - }, - "required": ["userId"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "saAddress": { - "type": "string" - }, - "tokenAddress": { - "type": "string" - }, - "amountMicros": { - "type": "string" - }, - "txHash": { - "type": "string" - } - }, - "required": ["ok", "saAddress", "tokenAddress", "amountMicros", "txHash"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/grant-card-access": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "userId": { - "type": "string" - }, - "revoke": { - "type": "boolean" - } - }, - "required": ["userId"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "userId": { - "type": "string" - }, - "username": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "cardAccessGrantedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": ["ok", "userId", "username", "cardAccessGrantedAt"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/grant-badge": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "userId": { - "type": "string" - }, - "code": { - "anyOf": [ - { - "type": "string", - "enum": ["BETA_TESTER"] - }, - { - "type": "string", - "enum": ["DEVCONNECT_BA_2025"] - }, - { - "type": "string", - "enum": ["PRODUCT_HUNT"] - }, - { - "type": "string", - "enum": ["OG_2025_10_12"] - }, - { - "type": "string", - "enum": ["SEEDLING_DEVCONNECT_BA_2025"] - }, - { - "type": "string", - "enum": ["ARBIVERSE_DEVCONNECT_BA_2025"] - }, - { - "type": "string", - "enum": ["CARD_PIONEER"] - }, - { - "type": "string", - "enum": ["FOUNDER_HOUSE"] - }, - { - "type": "string", - "enum": ["BUG_WHISPERER"] - }, - { - "type": "string", - "enum": ["SHHHHH"] - }, - { - "type": "string", - "enum": ["NOT_SO_SHHHH"] - }, - { - "type": "string", - "enum": ["CARD_FIRST_SWIPE"] - }, - { - "type": "string", - "enum": ["CARD_SPENT_1K"] - }, - { - "type": "string", - "enum": ["CARD_ALPHA"] - }, - { - "type": "string", - "enum": ["TOKEN_NATION_SP_2026"] - }, - { - "type": "string", - "enum": ["ETHFLORIPA_HUB"] - }, - { - "type": "string", - "enum": ["IRL_NOMADS"] - }, - { - "type": "string", - "enum": ["EVENT_ALUMNI"] - }, - { - "type": "string", - "enum": ["TOUCHED_GRASS"] - }, - { - "type": "string", - "enum": ["OFFRAMP_USER"] - }, - { - "type": "string", - "enum": ["PSYOPS_DIVISION"] - }, - { - "type": "string", - "enum": ["WAITLIST_SKIP"] - }, - { - "type": "string", - "enum": ["FESTA_JUNINA_2026"] - }, - { - "type": "string", - "enum": ["MANICERO"] - }, - { - "type": "string", - "enum": ["NITA"] - }, - { - "type": "string", - "enum": ["NAIJA"] - }, - { - "type": "string", - "enum": ["TERERE"] - } - ] - }, - "revoke": { - "type": "boolean" - } - }, - "required": ["userId", "code"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "userId": { - "type": "string" - }, - "code": { - "type": "string" - }, - "granted": { - "type": "boolean" - }, - "earnedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": ["ok", "userId", "code", "granted", "earnedAt"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/reset-card": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "userId": { - "type": "string" - }, - "keepCardAccess": { - "description": "If true, leave card_access_granted_at intact; only clear the card rows.", - "type": "boolean" - } - }, - "required": ["userId"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "deletedCards": { - "type": "number" - }, - "cardAccessGrantedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": ["ok", "deletedCards", "cardAccessGrantedAt"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/simulate-bridge-deposit": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "userId": { - "type": "string" - }, - "amountUsd": { - "type": "string" - } - }, - "required": ["userId", "amountUsd"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "virtualAccountId": { - "type": "string" - }, - "simulateResponse": {} - }, - "required": ["ok", "virtualAccountId", "simulateResponse"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/reset-user": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "userId": { - "type": "string" - } - }, - "required": ["userId"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "cleared": { - "type": "object", - "properties": { - "kycVerifications": { - "type": "number" - }, - "ledgerIntents": { - "type": "number" - } - }, - "required": ["kycVerifications", "ledgerIntents"] - } - }, - "required": ["ok", "cleared"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/complete-bridge-onramp": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "intentOrTransferId": { - "description": "Either a TransactionIntent.id or a Bridge transfer id.", - "type": "string" - }, - "exchangeRate": { - "type": "number" - }, - "developerFee": { - "type": "string" - } - }, - "required": ["intentOrTransferId"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "providerEventId": { - "type": "string" - }, - "intentId": { - "type": "string" - }, - "finalState": { - "type": "string" - }, - "statusChanged": { - "type": "boolean" - } - }, - "required": ["ok", "providerEventId", "finalState", "statusChanged"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/complete-bridge-offramp": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "intentOrTransferId": { - "type": "string" - }, - "exchangeRate": { - "type": "number" - }, - "developerFee": { - "type": "string" - } - }, - "required": ["intentOrTransferId"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "providerEventId": { - "type": "string" - }, - "intentId": { - "type": "string" - }, - "finalState": { - "type": "string" - }, - "statusChanged": { - "type": "boolean" - } - }, - "required": ["ok", "providerEventId", "finalState", "statusChanged"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/fail-bridge-transfer": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "intentOrTransferId": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "terminalState": { - "anyOf": [ - { - "type": "string", - "enum": ["error"] - }, - { - "type": "string", - "enum": ["returned"] - }, - { - "type": "string", - "enum": ["undeliverable"] - }, - { - "type": "string", - "enum": ["refunded"] - } - ] - } - }, - "required": ["intentOrTransferId", "reason"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "providerEventId": { - "type": "string" - }, - "intentId": { - "type": "string" - }, - "finalState": { - "type": "string" - }, - "statusChanged": { - "type": "boolean" - } - }, - "required": ["ok", "providerEventId", "finalState", "statusChanged"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/complete-rhino-deposit": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "depositAddress": { - "type": "string" - }, - "chainIn": { - "description": "Rhino's source-chain enum, e.g. BASE / ARBITRUM / BINANCE", - "type": "string" - }, - "token": { - "anyOf": [ - { - "type": "string", - "enum": ["USDC"] - }, - { - "type": "string", - "enum": ["USDT"] - } - ] - }, - "depositor": { - "description": "Source-chain wallet that funded the SDA", - "type": "string" - }, - "recipient": { - "description": "Destination peanut wallet address on Arbitrum", - "type": "string" - }, - "amountIn": { - "description": "Human-unit source amount, e.g. \"1.00\"", - "type": "string" - }, - "amountOut": { - "description": "Human-unit destination amount after FX + fees", - "type": "string" - }, - "amountOutUsd": { - "type": "number" - } - }, - "required": [ - "depositAddress", - "chainIn", - "token", - "depositor", - "recipient", - "amountIn", - "amountOut", - "amountOutUsd" - ] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "providerEventId": { - "type": "string" - }, - "finalState": { - "type": "string" - }, - "statusChanged": { - "type": "boolean" - } - }, - "required": ["ok", "providerEventId", "finalState", "statusChanged"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/complete-rhino-req-fulfilment": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "depositAddress": { - "type": "string" - }, - "chainIn": { - "description": "Rhino's source-chain enum, e.g. BASE / ARBITRUM / BINANCE", - "type": "string" - }, - "token": { - "anyOf": [ - { - "type": "string", - "enum": ["USDC"] - }, - { - "type": "string", - "enum": ["USDT"] - } - ] - }, - "depositor": { - "description": "Source-chain wallet that funded the SDA", - "type": "string" - }, - "recipient": { - "description": "Destination peanut wallet address on Arbitrum", - "type": "string" - }, - "amountIn": { - "description": "Human-unit source amount, e.g. \"1.00\"", - "type": "string" - }, - "amountOut": { - "description": "Human-unit destination amount after FX + fees", - "type": "string" - }, - "amountOutUsd": { - "type": "number" - } - }, - "required": [ - "depositAddress", - "chainIn", - "token", - "depositor", - "recipient", - "amountIn", - "amountOut", - "amountOutUsd" - ] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "providerEventId": { - "type": "string" - }, - "intentId": { - "type": "string" - }, - "finalState": { - "type": "string" - }, - "statusChanged": { - "type": "boolean" - } - }, - "required": ["ok", "providerEventId", "finalState", "statusChanged"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/complete-rhino-withdraw": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "depositAddress": { - "type": "string" - }, - "chainIn": { - "description": "Rhino's source-chain enum, e.g. BASE / ARBITRUM / BINANCE", - "type": "string" - }, - "token": { - "anyOf": [ - { - "type": "string", - "enum": ["USDC"] - }, - { - "type": "string", - "enum": ["USDT"] - } - ] - }, - "depositor": { - "description": "Source-chain wallet that funded the SDA", - "type": "string" - }, - "recipient": { - "description": "Destination peanut wallet address on Arbitrum", - "type": "string" - }, - "amountIn": { - "description": "Human-unit source amount, e.g. \"1.00\"", - "type": "string" - }, - "amountOut": { - "description": "Human-unit destination amount after FX + fees", - "type": "string" - }, - "amountOutUsd": { - "type": "number" - } - }, - "required": [ - "depositAddress", - "chainIn", - "token", - "depositor", - "recipient", - "amountIn", - "amountOut", - "amountOutUsd" - ] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "providerEventId": { - "type": "string" - }, - "intentId": { - "type": "string" - }, - "finalState": { - "type": "string" - }, - "statusChanged": { - "type": "boolean" - } - }, - "required": ["ok", "providerEventId", "finalState", "statusChanged"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/complete-rhino-claim-xchain": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "depositAddress": { - "type": "string" - }, - "chainIn": { - "description": "Rhino's source-chain enum, e.g. BASE / ARBITRUM / BINANCE", - "type": "string" - }, - "token": { - "anyOf": [ - { - "type": "string", - "enum": ["USDC"] - }, - { - "type": "string", - "enum": ["USDT"] - } - ] - }, - "depositor": { - "description": "Source-chain wallet that funded the SDA", - "type": "string" - }, - "recipient": { - "description": "Destination peanut wallet address on Arbitrum", - "type": "string" - }, - "amountIn": { - "description": "Human-unit source amount, e.g. \"1.00\"", - "type": "string" - }, - "amountOut": { - "description": "Human-unit destination amount after FX + fees", - "type": "string" - }, - "amountOutUsd": { - "type": "number" - } - }, - "required": [ - "depositAddress", - "chainIn", - "token", - "depositor", - "recipient", - "amountIn", - "amountOut", - "amountOutUsd" - ] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "providerEventId": { - "type": "string" - }, - "finalState": { - "type": "string" - }, - "statusChanged": { - "type": "boolean" - } - }, - "required": ["ok", "providerEventId", "finalState", "statusChanged"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/fail-rhino-transfer": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "depositAddress": { - "type": "string" - }, - "chainIn": { - "description": "Rhino's source-chain enum, e.g. BASE / ARBITRUM / BINANCE", - "type": "string" - }, - "token": { - "anyOf": [ - { - "type": "string", - "enum": ["USDC"] - }, - { - "type": "string", - "enum": ["USDT"] - } - ] - }, - "depositor": { - "description": "Source-chain wallet that funded the SDA", - "type": "string" - }, - "recipient": { - "description": "Destination peanut wallet address on Arbitrum", - "type": "string" - }, - "amountIn": { - "description": "Human-unit source amount, e.g. \"1.00\"", - "type": "string" - }, - "amountOut": { - "description": "Human-unit destination amount after FX + fees", - "type": "string" - }, - "amountOutUsd": { - "type": "number" - }, - "reason": { - "type": "string" - } - }, - "required": [ - "depositAddress", - "chainIn", - "token", - "depositor", - "recipient", - "amountIn", - "amountOut", - "amountOutUsd" - ] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "providerEventId": { - "type": "string" - }, - "intentId": { - "type": "string" - }, - "finalState": { - "type": "string" - }, - "statusChanged": { - "type": "boolean" - } - }, - "required": ["ok", "providerEventId", "finalState", "statusChanged"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/auto-complete-pending": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "userId": { - "type": "string" - } - }, - "required": ["userId"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "completed": { - "type": "array", - "items": { - "type": "object", - "properties": { - "intentId": { - "type": "string" - }, - "kind": { - "type": "string" - }, - "provider": { - "type": "string" - }, - "finalState": { - "type": "string" - } - }, - "required": ["intentId", "kind", "provider", "finalState"] - } - }, - "skipped": { - "type": "array", - "items": { - "type": "object", - "properties": { - "intentId": { - "type": "string" - }, - "kind": { - "type": "string" - }, - "provider": { - "type": "string" - }, - "reason": { - "type": "string" - } - }, - "required": ["intentId", "kind", "provider", "reason"] - } - } - }, - "required": ["ok", "completed", "skipped"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/full-setup": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "userId": { - "type": "string" - }, - "usdcMicros": { - "description": "Defaults to 100 USDC.", - "type": "string" - }, - "bridgeDepositUsd": { - "description": "Defaults to 25.", - "type": "string" - }, - "bridgeCountry": { - "description": "ISO-2; defaults to US.", - "type": "string" - }, - "mantecaCountry": { - "description": "Defaults to AR.", - "type": "string" - }, - "sumsubCountry": { - "description": "Defaults to US.", - "type": "string" - } - }, - "required": ["userId"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "steps": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "ok": { - "type": "boolean" - }, - "detail": {} - }, - "required": ["name", "ok", "detail"] - } - } - }, - "required": ["ok", "steps"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/userid-by-username": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "username", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "userId": { - "type": "string" - }, - "username": { - "type": "string" - } - }, - "required": ["userId", "username"] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "suggestions": { - "type": "array", - "items": { - "type": "string" - } - }, - "totalUsers": { - "type": "number" - } - }, - "required": ["error", "suggestions", "totalUsers"] - } - } - } - } - } - } - }, - "/dev/cheats/list-users": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "prefix", - "required": false - }, - { - "schema": { - "minimum": 1, - "maximum": 100, - "type": "number" - }, - "in": "query", - "name": "limit", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "users": { - "type": "array", - "items": { - "type": "string" - } - }, - "totalUsers": { - "type": "number" - } - }, - "required": ["users", "totalUsers"] - } - } - } - } - } - } - }, - "/dev/cheats/mint-jwt": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "userId": { - "type": "string" - } - }, - "required": ["userId"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "token": { - "type": "string" - }, - "userId": { - "type": "string" - } - }, - "required": ["token", "userId"] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "hint": { - "type": "string" - } - }, - "required": ["error", "hint"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "hint": { - "type": "string" - } - }, - "required": ["error", "hint"] - } - } - } - } - } - } - }, - "/dev/cheats/join-card-waitlist": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "userId": { - "type": "string" - } - }, - "required": ["userId"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "joinedAt": { - "type": "string" - } - }, - "required": ["ok", "joinedAt"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/release-from-waitlist": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "userId": { - "type": "string" - } - }, - "required": ["userId"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "releasedAt": { - "type": "string" - } - }, - "required": ["ok", "releasedAt"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/grant-flow-early-access": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "userId": { - "type": "string" - } - }, - "required": ["userId"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "grantedAt": { - "type": "string" - } - }, - "required": ["ok", "grantedAt"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/clear-skip-celebration": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "userId": { - "type": "string" - } - }, - "required": ["userId"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - } - }, - "required": ["ok"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/hit-activation-threshold": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "userId": { - "type": "string" - } - }, - "required": ["userId"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "syntheticIntentId": { - "type": "string" - }, - "note": { - "type": "string" - } - }, - "required": ["ok", "syntheticIntentId", "note"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/dev/cheats/reset-card-waitlist": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "userId": { - "type": "string" - } - }, - "required": ["userId"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "deletedPerkUsages": { - "type": "number" - } - }, - "required": ["ok", "deletedPerkUsages"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [false] - }, - "error": { - "type": "string" - } - }, - "required": ["ok", "error"] - } - } - } - } - } - } - }, - "/ws/charges/{username}": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "username", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/fx/rates": { - "get": { - "description": "Public, indicative display-sell FX rates resolved relative to one base. unitsPerBase is quote-currency units per one base unit.", - "parameters": [ - { - "schema": { - "pattern": "^[A-Za-z]{3,4}$", - "type": "string" - }, - "in": "query", - "name": "base", - "required": false, - "description": "ISO-style currency code or supported four-letter internal ticker" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "type": "object", - "properties": { - "base": { - "pattern": "^[A-Z]{3,4}$", - "type": "string" - }, - "basis": { - "type": "string", - "enum": ["display_sell"] - }, - "indicative": { - "type": "boolean", - "enum": [true] - }, - "generatedAt": { - "format": "date-time", - "type": "string" - }, - "rates": { - "minItems": 1, - "maxItems": 512, - "type": "array", - "items": { - "additionalProperties": false, - "type": "object", - "properties": { - "code": { - "pattern": "^[A-Z]{3,4}$", - "type": "string" - }, - "unitsPerBase": { - "pattern": "^(?:0|[1-9]\\d*)(?:\\.\\d{1,18})?$", - "type": "string" - }, - "selection": { - "anyOf": [ - { - "type": "string", - "enum": ["identity"] - }, - { - "type": "string", - "enum": ["provider_pair"] - }, - { - "type": "string", - "enum": ["reference_pair"] - } - ] - }, - "baseSource": { - "anyOf": [ - { - "type": "string", - "enum": ["identity"] - }, - { - "type": "string", - "enum": ["bridge"] - }, - { - "type": "string", - "enum": ["manteca"] - }, - { - "type": "string", - "enum": ["reference"] - } - ] - }, - "quoteSource": { - "anyOf": [ - { - "type": "string", - "enum": ["identity"] - }, - { - "type": "string", - "enum": ["bridge"] - }, - { - "type": "string", - "enum": ["manteca"] - }, - { - "type": "string", - "enum": ["reference"] - } - ] - }, - "effectiveAt": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "code", - "unitsPerBase", - "selection", - "baseSource", - "quoteSource", - "effectiveAt" - ] - } - } - }, - "required": ["base", "basis", "indicative", "generatedAt", "rates"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["error", "message"] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["error", "message"] - } - } - } - }, - "429": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "503": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["error", "message"] - } - } - } - } - } - } - }, - "/fx/rate": { - "get": { - "description": "Public indicative display-sell rate for one currency pair.", - "parameters": [ - { - "schema": { - "pattern": "^[A-Za-z]{3,4}$", - "type": "string" - }, - "in": "query", - "name": "from", - "required": true, - "description": "ISO-style currency code or supported four-letter internal ticker" - }, - { - "schema": { - "pattern": "^[A-Za-z]{3,4}$", - "type": "string" - }, - "in": "query", - "name": "to", - "required": true, - "description": "ISO-style currency code or supported four-letter internal ticker" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "type": "object", - "properties": { - "from": { - "pattern": "^[A-Z]{3,4}$", - "type": "string" - }, - "to": { - "pattern": "^[A-Z]{3,4}$", - "type": "string" - }, - "rate": { - "pattern": "^(?:0|[1-9]\\d*)(?:\\.\\d{1,18})?$", - "type": "string" - }, - "basis": { - "type": "string", - "enum": ["display_sell"] - }, - "indicative": { - "type": "boolean", - "enum": [true] - }, - "selection": { - "anyOf": [ - { - "type": "string", - "enum": ["identity"] - }, - { - "type": "string", - "enum": ["provider_pair"] - }, - { - "type": "string", - "enum": ["reference_pair"] - } - ] - }, - "fromSource": { - "anyOf": [ - { - "type": "string", - "enum": ["identity"] - }, - { - "type": "string", - "enum": ["bridge"] - }, - { - "type": "string", - "enum": ["manteca"] - }, - { - "type": "string", - "enum": ["reference"] - } - ] - }, - "toSource": { - "anyOf": [ - { - "type": "string", - "enum": ["identity"] - }, - { - "type": "string", - "enum": ["bridge"] - }, - { - "type": "string", - "enum": ["manteca"] - }, - { - "type": "string", - "enum": ["reference"] - } - ] - }, - "effectiveAt": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ] - }, - "generatedAt": { - "format": "date-time", - "type": "string" - } - }, - "required": [ - "from", - "to", - "rate", - "basis", - "indicative", - "selection", - "fromSource", - "toSource", - "effectiveAt", - "generatedAt" - ] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["error", "message"] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["error", "message"] - } - } - } - }, - "429": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "503": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["error", "message"] - } - } - } - } - } - } - } - }, - "servers": [ - { - "url": "http://localhost:5050" - } - ] + "openapi": "3.0.3", + "info": { + "title": "peanut-api", + "version": "1.0.0" + }, + "components": { + "schemas": {} + }, + "paths": { + "/": { + "get": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/test-error": { + "get": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/apple-app-site-association": { + "get": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/assetLinks.json": { + "get": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/claim": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "chainId": { + "pattern": "^[0-9]+$", + "type": "string" + }, + "version": { + "default": "v4.3", + "anyOf": [ + { + "type": "string", + "enum": [ + "v4.3" + ] + }, + { + "type": "string", + "enum": [ + "v4.4" + ] + } + ] + }, + "claimParams": { + "minItems": 3, + "type": "array", + "items": {} + }, + "withMFA": { + "default": false, + "type": "boolean" + }, + "depositDetails": { + "type": "object", + "properties": { + "pubKey20": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "tokenAddress": { + "type": "string" + }, + "contractType": { + "type": "number" + }, + "claimed": { + "type": "boolean" + }, + "requiresMFA": { + "type": "boolean" + }, + "timestamp": { + "type": "number" + }, + "tokenId": { + "type": "string" + }, + "senderAddress": { + "type": "string" + } + }, + "required": [ + "pubKey20", + "amount", + "tokenAddress", + "contractType", + "claimed", + "requiresMFA", + "timestamp", + "tokenId", + "senderAddress" + ] + }, + "optimisticReturn": { + "type": "boolean" + }, + "campaignTag": { + "type": "string" + } + }, + "required": [ + "chainId", + "claimParams" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/healthz": { + "get": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/ens/reverse/{address}": { + "get": { + "parameters": [ + { + "schema": { + "pattern": "^0x[a-fA-F0-9]{40}$", + "type": "string" + }, + "in": "path", + "name": "address", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/ens/{ensName}": { + "get": { + "parameters": [ + { + "schema": { + "minimum": 1, + "type": "integer" + }, + "in": "query", + "name": "chainId", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "ensName", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/add-account": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "accountIdentifier": { + "type": "string" + }, + "accountType": { + "anyOf": [ + { + "type": "string", + "enum": [ + "iban" + ] + }, + { + "type": "string", + "enum": [ + "us" + ] + }, + { + "type": "string", + "enum": [ + "evm-address" + ] + }, + { + "type": "string", + "enum": [ + "peanut-wallet" + ] + }, + { + "type": "string", + "enum": [ + "bridgeBankAccount" + ] + }, + { + "type": "string", + "enum": [ + "manteca" + ] + }, + { + "type": "string", + "enum": [ + "clabe" + ] + }, + { + "type": "string", + "enum": [ + "cbu" + ] + }, + { + "type": "string", + "enum": [ + "cvu" + ] + }, + { + "type": "string", + "enum": [ + "pix" + ] + } + ] + }, + "userId": { + "type": "string" + }, + "bridgeAccountIdentifier": { + "type": "string" + }, + "chainId": { + "type": "string" + }, + "telegramHandle": { + "type": "string" + } + }, + "required": [ + "accountIdentifier", + "accountType", + "userId" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "api-key", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/users/username/{username}": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "username", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/users/me": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "api-key", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "properties": { + "capabilities": { + "type": "object", + "properties": { + "rails": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "description": "`${provider}.${method}` e.g. \"bridge.ach_us\"", + "type": "string" + }, + "provider": { + "anyOf": [ + { + "type": "string", + "enum": [ + "bridge" + ] + }, + { + "type": "string", + "enum": [ + "manteca" + ] + }, + { + "type": "string", + "enum": [ + "rain" + ] + } + ] + }, + "method": { + "type": "string" + }, + "channel": { + "anyOf": [ + { + "type": "string", + "enum": [ + "bank" + ] + }, + { + "type": "string", + "enum": [ + "card" + ] + }, + { + "type": "string", + "enum": [ + "qr-only" + ] + } + ] + }, + "country": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "status": { + "anyOf": [ + { + "type": "string", + "enum": [ + "enabled" + ] + }, + { + "type": "string", + "enum": [ + "pending" + ] + }, + { + "type": "string", + "enum": [ + "requires-info" + ] + }, + { + "type": "string", + "enum": [ + "blocked" + ] + } + ] + }, + "operations": { + "type": "object", + "properties": { + "pay": { + "anyOf": [ + { + "type": "string", + "enum": [ + "enabled" + ] + }, + { + "type": "string", + "enum": [ + "pending" + ] + }, + { + "type": "string", + "enum": [ + "requires-info" + ] + }, + { + "type": "string", + "enum": [ + "blocked" + ] + } + ] + }, + "deposit": { + "anyOf": [ + { + "type": "string", + "enum": [ + "enabled" + ] + }, + { + "type": "string", + "enum": [ + "pending" + ] + }, + { + "type": "string", + "enum": [ + "requires-info" + ] + }, + { + "type": "string", + "enum": [ + "blocked" + ] + } + ] + }, + "withdraw": { + "anyOf": [ + { + "type": "string", + "enum": [ + "enabled" + ] + }, + { + "type": "string", + "enum": [ + "pending" + ] + }, + { + "type": "string", + "enum": [ + "requires-info" + ] + }, + { + "type": "string", + "enum": [ + "blocked" + ] + } + ] + } + } + }, + "blockingActions": { + "type": "array", + "items": { + "type": "string" + } + }, + "hintActions": { + "type": "array", + "items": { + "type": "string" + } + }, + "reason": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "userMessage": { + "type": "string" + }, + "details": { + "type": "string" + } + }, + "required": [ + "code", + "userMessage" + ] + }, + "resolved": { + "type": "object", + "properties": { + "status": { + "anyOf": [ + { + "type": "string", + "enum": [ + "enabled" + ] + }, + { + "type": "string", + "enum": [ + "pending" + ] + }, + { + "type": "string", + "enum": [ + "fixable" + ] + }, + { + "type": "string", + "enum": [ + "blocked" + ] + } + ] + }, + "blocking": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "userMessage": { + "type": "string" + }, + "selfHealable": { + "type": "boolean" + }, + "selfHealKind": { + "anyOf": [ + { + "type": "string", + "enum": [ + "document-resubmit" + ] + }, + { + "type": "string", + "enum": [ + "restart-identity" + ] + }, + { + "type": "string", + "enum": [ + "provide-email" + ] + }, + { + "type": "string", + "enum": [ + "contact-support" + ] + } + ] + }, + "details": { + "type": "string" + } + }, + "required": [ + "code", + "userMessage", + "selfHealable" + ] + }, + "nextAction": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "kind": { + "anyOf": [ + { + "type": "string", + "enum": [ + "sumsub" + ] + }, + { + "type": "string", + "enum": [ + "accept-tos" + ] + }, + { + "type": "string", + "enum": [ + "wait" + ] + }, + { + "type": "string", + "enum": [ + "contact-support" + ] + }, + { + "type": "string", + "enum": [ + "provide-email" + ] + }, + { + "type": "string", + "enum": [ + "bridge-hosted" + ] + } + ] + }, + "purpose": { + "type": "string" + }, + "levelKey": { + "type": "string" + }, + "tosUrl": { + "type": "string" + }, + "effectiveDate": { + "type": "string" + }, + "requirementKey": { + "type": "string" + } + }, + "required": [ + "key", + "kind", + "purpose" + ] + } + }, + "required": [ + "status" + ] + } + }, + "required": [ + "id", + "provider", + "method", + "channel", + "country", + "currency", + "status" + ] + } + }, + "nextActions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "kind": { + "anyOf": [ + { + "type": "string", + "enum": [ + "sumsub" + ] + }, + { + "type": "string", + "enum": [ + "accept-tos" + ] + }, + { + "type": "string", + "enum": [ + "wait" + ] + }, + { + "type": "string", + "enum": [ + "contact-support" + ] + }, + { + "type": "string", + "enum": [ + "provide-email" + ] + }, + { + "type": "string", + "enum": [ + "bridge-hosted" + ] + } + ] + }, + "purpose": { + "type": "string" + }, + "levelKey": { + "type": "string" + }, + "tosUrl": { + "type": "string" + }, + "effectiveDate": { + "type": "string" + }, + "requirementKey": { + "type": "string" + } + }, + "required": [ + "key", + "kind", + "purpose" + ] + } + }, + "restrictions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "affectedRailIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "userMessage": { + "type": "string" + } + }, + "required": [ + "code", + "affectedRailIds", + "userMessage" + ] + } + } + }, + "required": [ + "rails", + "nextActions", + "restrictions" + ] + }, + "identityVerification": { + "type": "object", + "properties": { + "status": { + "anyOf": [ + { + "type": "string", + "enum": [ + "not_started" + ] + }, + { + "type": "string", + "enum": [ + "processing" + ] + }, + { + "type": "string", + "enum": [ + "verified" + ] + }, + { + "type": "string", + "enum": [ + "action_required" + ] + }, + { + "type": "string", + "enum": [ + "failed" + ] + } + ] + }, + "actionMessage": { + "type": "string" + }, + "rejectLabels": { + "type": "array", + "items": { + "type": "string" + } + }, + "submittedAt": { + "type": "string" + }, + "reviewedAt": { + "type": "string" + } + }, + "required": [ + "status" + ] + } + }, + "required": [ + "capabilities", + "identityVerification" + ] + } + } + } + } + } + } + }, + "/get-user-id": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "accountIdentifier": { + "type": "string" + } + }, + "required": [ + "accountIdentifier" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "api-key", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/update-user": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "fullName": { + "type": "string" + }, + "bridge_customer_id": { + "type": "string" + }, + "telegramUsername": { + "type": "string" + }, + "offrampHandle": { + "maxLength": 320, + "type": "string" + }, + "pushSubscriptionId": { + "type": "string" + }, + "showFullName": { + "type": "boolean" + }, + "hasSeenEarlyUserModal": { + "type": "boolean" + }, + "bridgeKycStatus": { + "anyOf": [ + { + "type": "string", + "enum": [ + "not_started" + ] + }, + { + "type": "string", + "enum": [ + "incomplete" + ] + }, + { + "type": "string", + "enum": [ + "under_review" + ] + }, + { + "type": "string", + "enum": [ + "approved" + ] + }, + { + "type": "string", + "enum": [ + "rejected" + ] + } + ] + }, + "dismissActivationCelebration": { + "type": "boolean" + } + }, + "required": [ + "userId" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "api-key", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/users/me/delete": { + "post": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/users/logout": { + "post": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/users/contacts": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "limit", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "offset", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "search", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "api-key", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/users/history": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "cursor", + "required": false + }, + { + "schema": { + "type": "number" + }, + "in": "query", + "name": "limit", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "targetUsername", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/history/{entryId}": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "kind", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "entryId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/users/search": { + "get": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/users/initiate-kyc": { + "post": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/users/accounts": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "accountType": { + "anyOf": [ + { + "type": "string", + "enum": [ + "iban" + ] + }, + { + "type": "string", + "enum": [ + "us" + ] + }, + { + "type": "string", + "enum": [ + "clabe" + ] + }, + { + "type": "string", + "enum": [ + "gb" + ] + } + ] + }, + "accountNumber": { + "type": "string" + }, + "countryCode": { + "type": "string" + }, + "countryName": { + "type": "string" + }, + "accountOwnerType": { + "anyOf": [ + { + "type": "string", + "enum": [ + "individual" + ] + }, + { + "type": "string", + "enum": [ + "business" + ] + } + ] + }, + "accountOwnerName": { + "type": "object", + "properties": { + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "businessName": { + "type": "string" + } + } + }, + "address": { + "type": "object", + "properties": { + "street": { + "type": "string" + }, + "city": { + "type": "string" + }, + "country": { + "type": "string" + }, + "state": { + "type": "string" + }, + "postalCode": { + "type": "string" + } + }, + "required": [ + "street", + "city", + "country", + "postalCode" + ] + }, + "bic": { + "type": "string" + }, + "routingNumber": { + "type": "string" + }, + "sortCode": { + "type": "string" + } + }, + "required": [ + "accountType", + "accountNumber", + "countryCode", + "countryName", + "accountOwnerType", + "accountOwnerName", + "address" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "api-key", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/users/{userId}": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "userId", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "api-key", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "fullName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "username": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "showFullName": { + "type": "boolean" + }, + "canReceiveBankOfframp": { + "type": "boolean" + }, + "isVerified": { + "type": "boolean" + } + }, + "required": [ + "userId", + "fullName", + "username", + "showFullName", + "canReceiveBankOfframp", + "isVerified" + ] + } + } + } + } + } + } + }, + "/users/interaction-status": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userIds": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "userIds" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/users/limits": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/users/increase-limits": { + "post": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/users/rails": { + "post": { + "responses": { + "200": { + "description": "Default Response" + } + } + }, + "get": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/users/capabilities": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "api-key", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "capabilities": { + "type": "object", + "properties": { + "rails": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "description": "`${provider}.${method}` e.g. \"bridge.ach_us\"", + "type": "string" + }, + "provider": { + "anyOf": [ + { + "type": "string", + "enum": [ + "bridge" + ] + }, + { + "type": "string", + "enum": [ + "manteca" + ] + }, + { + "type": "string", + "enum": [ + "rain" + ] + } + ] + }, + "method": { + "type": "string" + }, + "channel": { + "anyOf": [ + { + "type": "string", + "enum": [ + "bank" + ] + }, + { + "type": "string", + "enum": [ + "card" + ] + }, + { + "type": "string", + "enum": [ + "qr-only" + ] + } + ] + }, + "country": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "status": { + "anyOf": [ + { + "type": "string", + "enum": [ + "enabled" + ] + }, + { + "type": "string", + "enum": [ + "pending" + ] + }, + { + "type": "string", + "enum": [ + "requires-info" + ] + }, + { + "type": "string", + "enum": [ + "blocked" + ] + } + ] + }, + "operations": { + "type": "object", + "properties": { + "pay": { + "anyOf": [ + { + "type": "string", + "enum": [ + "enabled" + ] + }, + { + "type": "string", + "enum": [ + "pending" + ] + }, + { + "type": "string", + "enum": [ + "requires-info" + ] + }, + { + "type": "string", + "enum": [ + "blocked" + ] + } + ] + }, + "deposit": { + "anyOf": [ + { + "type": "string", + "enum": [ + "enabled" + ] + }, + { + "type": "string", + "enum": [ + "pending" + ] + }, + { + "type": "string", + "enum": [ + "requires-info" + ] + }, + { + "type": "string", + "enum": [ + "blocked" + ] + } + ] + }, + "withdraw": { + "anyOf": [ + { + "type": "string", + "enum": [ + "enabled" + ] + }, + { + "type": "string", + "enum": [ + "pending" + ] + }, + { + "type": "string", + "enum": [ + "requires-info" + ] + }, + { + "type": "string", + "enum": [ + "blocked" + ] + } + ] + } + } + }, + "blockingActions": { + "type": "array", + "items": { + "type": "string" + } + }, + "hintActions": { + "type": "array", + "items": { + "type": "string" + } + }, + "reason": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "userMessage": { + "type": "string" + }, + "details": { + "type": "string" + } + }, + "required": [ + "code", + "userMessage" + ] + }, + "resolved": { + "type": "object", + "properties": { + "status": { + "anyOf": [ + { + "type": "string", + "enum": [ + "enabled" + ] + }, + { + "type": "string", + "enum": [ + "pending" + ] + }, + { + "type": "string", + "enum": [ + "fixable" + ] + }, + { + "type": "string", + "enum": [ + "blocked" + ] + } + ] + }, + "blocking": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "userMessage": { + "type": "string" + }, + "selfHealable": { + "type": "boolean" + }, + "selfHealKind": { + "anyOf": [ + { + "type": "string", + "enum": [ + "document-resubmit" + ] + }, + { + "type": "string", + "enum": [ + "restart-identity" + ] + }, + { + "type": "string", + "enum": [ + "provide-email" + ] + }, + { + "type": "string", + "enum": [ + "contact-support" + ] + } + ] + }, + "details": { + "type": "string" + } + }, + "required": [ + "code", + "userMessage", + "selfHealable" + ] + }, + "nextAction": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "kind": { + "anyOf": [ + { + "type": "string", + "enum": [ + "sumsub" + ] + }, + { + "type": "string", + "enum": [ + "accept-tos" + ] + }, + { + "type": "string", + "enum": [ + "wait" + ] + }, + { + "type": "string", + "enum": [ + "contact-support" + ] + }, + { + "type": "string", + "enum": [ + "provide-email" + ] + }, + { + "type": "string", + "enum": [ + "bridge-hosted" + ] + } + ] + }, + "purpose": { + "type": "string" + }, + "levelKey": { + "type": "string" + }, + "tosUrl": { + "type": "string" + }, + "effectiveDate": { + "type": "string" + }, + "requirementKey": { + "type": "string" + } + }, + "required": [ + "key", + "kind", + "purpose" + ] + } + }, + "required": [ + "status" + ] + } + }, + "required": [ + "id", + "provider", + "method", + "channel", + "country", + "currency", + "status" + ] + } + }, + "nextActions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "kind": { + "anyOf": [ + { + "type": "string", + "enum": [ + "sumsub" + ] + }, + { + "type": "string", + "enum": [ + "accept-tos" + ] + }, + { + "type": "string", + "enum": [ + "wait" + ] + }, + { + "type": "string", + "enum": [ + "contact-support" + ] + }, + { + "type": "string", + "enum": [ + "provide-email" + ] + }, + { + "type": "string", + "enum": [ + "bridge-hosted" + ] + } + ] + }, + "purpose": { + "type": "string" + }, + "levelKey": { + "type": "string" + }, + "tosUrl": { + "type": "string" + }, + "effectiveDate": { + "type": "string" + }, + "requirementKey": { + "type": "string" + } + }, + "required": [ + "key", + "kind", + "purpose" + ] + } + }, + "restrictions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "affectedRailIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "userMessage": { + "type": "string" + } + }, + "required": [ + "code", + "affectedRailIds", + "userMessage" + ] + } + } + }, + "required": [ + "rails", + "nextActions", + "restrictions" + ] + } + }, + "required": [ + "capabilities" + ] + } + } + } + } + } + } + }, + "/users/kyc/start-action": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "key": { + "minLength": 1, + "description": "A capability nextAction key", + "type": "string" + } + }, + "required": [ + "key" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "api-key", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sumsubAccessToken": { + "type": "string" + }, + "levelName": { + "type": "string" + }, + "externalActionId": { + "type": "string" + }, + "verificationUrl": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/users/bridge-tos-link": { + "get": { + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tosLink": { + "type": "string" + }, + "endorsement": { + "type": "string" + } + }, + "required": [ + "tosLink", + "endorsement" + ] + } + } + } + } + } + } + }, + "/users/bridge-tos-confirm": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/users/consent/status": { + "get": { + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "documents": { + "type": "array", + "items": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "currentVersion": { + "type": "string" + }, + "acceptedVersion": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "acceptedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "needsAcceptance": { + "type": "boolean" + } + }, + "required": [ + "slug", + "currentVersion", + "acceptedVersion", + "acceptedAt", + "needsAcceptance" + ] + } + }, + "needsReConsent": { + "type": "boolean" + } + }, + "required": [ + "documents", + "needsReConsent" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/users/consent/accept": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "documents": { + "minItems": 1, + "maxItems": 8, + "type": "array", + "items": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "version": { + "type": "string" + }, + "hash": { + "type": "string" + } + }, + "required": [ + "slug", + "version" + ] + } + } + }, + "required": [ + "documents" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "recorded": { + "type": "number" + } + }, + "required": [ + "recorded" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/validate-bank-account-number": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "bankAccountNumber": { + "type": "string" + } + }, + "required": [ + "bankAccountNumber" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/validate-bank-account": { + "post": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/validate-bank-number": { + "post": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/validate-bic": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "bic": { + "type": "string" + } + }, + "required": [ + "bic" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/is-valid-bic": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "bic": { + "type": "string" + } + }, + "required": [ + "bic" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/passkeys/register/options": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "rpID": { + "type": "string" + } + }, + "required": [ + "username" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "api-key", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/passkeys/register/verify": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "username": { + "type": "string" + }, + "cred": {}, + "rpID": { + "type": "string" + }, + "acceptedLegal": { + "maxItems": 4, + "type": "array", + "items": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "version": { + "type": "string" + }, + "hash": { + "type": "string" + } + }, + "required": [ + "slug", + "version" + ] + } + } + }, + "required": [ + "userId", + "username", + "cred" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "api-key", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/passkeys/login/options": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "rpID": { + "type": "string" + } + } + } + } + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "api-key", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/passkeys/login/verify": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cred": {}, + "rpID": { + "type": "string" + } + }, + "required": [ + "cred" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "api-key", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/auth/step-up/options": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "rpID": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/auth/step-up/verify": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cred": {}, + "rpID": { + "type": "string" + } + }, + "required": [ + "cred" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "expiresIn": { + "type": "number" + } + }, + "required": [ + "token", + "expiresIn" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/requests": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "additionalProperties": false, + "type": "object", + "properties": { + "chainId": { + "type": "string" + }, + "tokenAmount": { + "type": "string" + }, + "recipientAddress": { + "type": "string" + }, + "trackId": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "tokenType": { + "not": {} + }, + "tokenAddress": { + "not": {} + }, + "tokenDecimals": { + "not": {} + }, + "tokenSymbol": { + "not": {} + } + }, + "required": [ + "recipientAddress" + ] + }, + { + "additionalProperties": false, + "type": "object", + "properties": { + "chainId": { + "type": "string" + }, + "tokenAmount": { + "type": "string" + }, + "recipientAddress": { + "type": "string" + }, + "trackId": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "tokenType": { + "type": "string" + }, + "tokenAddress": { + "pattern": "^(0x[a-fA-F0-9]{40}|[1-9A-HJ-NP-Za-km-z]{32,44})$", + "type": "string" + }, + "tokenDecimals": { + "type": "string" + }, + "tokenSymbol": { + "type": "string" + } + }, + "required": [ + "chainId", + "recipientAddress", + "tokenType", + "tokenAddress", + "tokenDecimals", + "tokenSymbol" + ] + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Default Response" + } + } + }, + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "recipient", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "tokenAmount", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "tokenAddress", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "chainId", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/requests/{uuid}": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "uuid", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + }, + "patch": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "type": "object", + "properties": { + "chainId": { + "type": "string" + }, + "tokenAmount": { + "type": "string" + }, + "recipientAddress": { + "type": "string" + }, + "trackId": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "tokenAddress": { + "type": "string", + "pattern": "^0x[a-fA-F0-9]{40}$" + }, + "tokenDecimals": { + "type": "string" + }, + "tokenType": { + "type": "string" + }, + "tokenSymbol": { + "type": "string" + } + } + } + } + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "uuid", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + }, + "delete": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "uuid", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/charges": { + "post": { + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "anyOf": [ + { + "type": "object", + "allOf": [ + { + "type": "object", + "properties": { + "pricing_type": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixed_price" + ] + }, + { + "type": "string", + "enum": [ + "no_price" + ] + } + ] + }, + "local_price": { + "type": "object", + "properties": { + "amount": { + "type": "string" + }, + "currency": { + "type": "string" + } + }, + "required": [ + "amount" + ] + }, + "baseUrl": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "transactionType": { + "anyOf": [ + { + "type": "string", + "enum": [ + "REQUEST" + ] + }, + { + "type": "string", + "enum": [ + "DIRECT_SEND" + ] + }, + { + "type": "string", + "enum": [ + "SEND_LINK" + ] + }, + { + "type": "string", + "enum": [ + "DEPOSIT" + ] + }, + { + "type": "string", + "enum": [ + "WITHDRAW" + ] + } + ] + }, + "attachment": {}, + "mimetype": { + "type": "string" + }, + "filename": { + "type": "string" + } + }, + "required": [ + "pricing_type", + "local_price" + ] + }, + { + "type": "object", + "properties": { + "checkout_id": { + "type": "string" + }, + "requestId": { + "not": {} + }, + "requestProps": { + "type": "object", + "properties": { + "chainId": { + "type": "string" + }, + "tokenAddress": { + "type": "string" + }, + "tokenType": { + "type": "string" + }, + "tokenSymbol": { + "type": "string" + }, + "tokenDecimals": { + "type": "integer" + }, + "recipientAddress": { + "type": "string" + }, + "requesteeUsername": { + "type": "string" + }, + "tokenAmount": { + "type": "string" + } + } + } + }, + "required": [ + "checkout_id" + ] + } + ] + }, + { + "type": "object", + "allOf": [ + { + "type": "object", + "properties": { + "pricing_type": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixed_price" + ] + }, + { + "type": "string", + "enum": [ + "no_price" + ] + } + ] + }, + "local_price": { + "type": "object", + "properties": { + "amount": { + "type": "string" + }, + "currency": { + "type": "string" + } + }, + "required": [ + "amount" + ] + }, + "baseUrl": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "transactionType": { + "anyOf": [ + { + "type": "string", + "enum": [ + "REQUEST" + ] + }, + { + "type": "string", + "enum": [ + "DIRECT_SEND" + ] + }, + { + "type": "string", + "enum": [ + "SEND_LINK" + ] + }, + { + "type": "string", + "enum": [ + "DEPOSIT" + ] + }, + { + "type": "string", + "enum": [ + "WITHDRAW" + ] + } + ] + }, + "attachment": {}, + "mimetype": { + "type": "string" + }, + "filename": { + "type": "string" + } + }, + "required": [ + "pricing_type", + "local_price" + ] + }, + { + "type": "object", + "properties": { + "checkout_id": { + "not": {} + }, + "requestId": { + "type": "string" + }, + "requestProps": { + "type": "object", + "properties": { + "chainId": { + "type": "string" + }, + "tokenAddress": { + "type": "string" + }, + "tokenType": { + "type": "string" + }, + "tokenSymbol": { + "type": "string" + }, + "tokenDecimals": { + "type": "integer" + }, + "recipientAddress": { + "type": "string" + }, + "requesteeUsername": { + "type": "string" + }, + "tokenAmount": { + "type": "string" + } + } + } + }, + "required": [ + "requestId" + ] + } + ] + }, + { + "type": "object", + "allOf": [ + { + "type": "object", + "properties": { + "pricing_type": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixed_price" + ] + }, + { + "type": "string", + "enum": [ + "no_price" + ] + } + ] + }, + "local_price": { + "type": "object", + "properties": { + "amount": { + "type": "string" + }, + "currency": { + "type": "string" + } + }, + "required": [ + "amount" + ] + }, + "baseUrl": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "transactionType": { + "anyOf": [ + { + "type": "string", + "enum": [ + "REQUEST" + ] + }, + { + "type": "string", + "enum": [ + "DIRECT_SEND" + ] + }, + { + "type": "string", + "enum": [ + "SEND_LINK" + ] + }, + { + "type": "string", + "enum": [ + "DEPOSIT" + ] + }, + { + "type": "string", + "enum": [ + "WITHDRAW" + ] + } + ] + }, + "attachment": {}, + "mimetype": { + "type": "string" + }, + "filename": { + "type": "string" + } + }, + "required": [ + "pricing_type", + "local_price" + ] + }, + { + "type": "object", + "properties": { + "checkout_id": { + "not": {} + }, + "requestId": { + "not": {} + }, + "requestProps": { + "type": "object", + "properties": { + "chainId": { + "type": "string" + }, + "tokenAddress": { + "type": "string" + }, + "tokenType": { + "type": "string" + }, + "tokenSymbol": { + "type": "string" + }, + "tokenDecimals": { + "type": "integer" + }, + "recipientAddress": { + "type": "string" + }, + "requesteeUsername": { + "type": "string" + }, + "tokenAmount": { + "type": "string" + } + }, + "required": [ + "chainId", + "tokenAddress", + "tokenType", + "tokenSymbol", + "tokenDecimals", + "recipientAddress" + ] + } + }, + "required": [ + "requestProps" + ] + } + ] + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Default Response" + } + } + }, + "get": { + "parameters": [ + { + "schema": { + "minimum": 1, + "maximum": 100, + "default": 25, + "type": "number" + }, + "in": "query", + "name": "limit", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "starting_after", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "ending_before", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "status", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/charges/{uuid}/payments": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "hash": { + "type": "string" + }, + "chainId": { + "type": "string" + }, + "tokenAddress": { + "type": "string" + }, + "payerAddress": { + "type": "string" + }, + "sourceChainId": { + "type": "string" + }, + "sourceTokenAddress": { + "type": "string" + }, + "sourceTokenSymbol": { + "type": "string" + } + }, + "required": [ + "hash", + "chainId", + "tokenAddress", + "payerAddress" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "uuid", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/charges/{chargeId}": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "chargeId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + }, + "delete": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "chargeId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/request-charges/{uuid}": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "uuid", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/direct-send": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "recipientAddress": { + "pattern": "^0x[a-fA-F0-9]{40}$", + "type": "string" + }, + "chainId": { + "type": "string" + }, + "tokenAddress": { + "pattern": "^0x[a-fA-F0-9]{40}$", + "type": "string" + }, + "tokenAmount": { + "pattern": "^(0|[1-9]\\d*)(\\.\\d+)?$", + "type": "string" + }, + "tokenDecimals": { + "minimum": 0, + "maximum": 36, + "type": "integer" + }, + "tokenSymbol": { + "type": "string" + }, + "tokenType": { + "type": "string" + }, + "hash": { + "pattern": "^0x[a-fA-F0-9]{64}$", + "type": "string" + }, + "payerAddress": { + "pattern": "^0x[a-fA-F0-9]{40}$", + "type": "string" + }, + "memo": { + "type": "string" + } + }, + "required": [ + "recipientAddress", + "chainId", + "tokenAddress", + "tokenAmount", + "tokenDecimals", + "hash", + "payerAddress" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/send-links": { + "post": { + "responses": { + "200": { + "description": "Default Response" + } + } + }, + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "c", + "required": true + }, + { + "schema": { + "type": "number" + }, + "in": "query", + "name": "i", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "v", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/send-links/{pubKey}": { + "patch": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "pubKey", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + }, + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "c", + "required": false + }, + { + "schema": { + "type": "number" + }, + "in": "query", + "name": "i", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "v", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "pubKey", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/send-links/claim/{txHash}/associate-user": { + "patch": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "txHash", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/bridge/webhooks": { + "post": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/bridge/customers/{uuid}": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "uuid", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "api-key", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/bridge/customers/{uuid}/external-accounts": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "accountNumber": { + "type": "string" + }, + "bic": { + "type": "string" + }, + "country": { + "type": "string" + }, + "address": { + "type": "object", + "properties": { + "street": { + "type": "string" + }, + "city": { + "type": "string" + }, + "country": { + "type": "string" + }, + "state": { + "type": "string" + }, + "postalCode": { + "type": "string" + } + }, + "required": [ + "street", + "city", + "country", + "postalCode" + ] + }, + "accountOwnerName": { + "type": "object", + "properties": { + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "businessName": { + "type": "string" + } + } + }, + "accountOwnerType": { + "anyOf": [ + { + "type": "string", + "enum": [ + "business" + ] + }, + { + "type": "string", + "enum": [ + "individual" + ] + } + ] + }, + "routingNumber": { + "type": "string" + }, + "sortCode": { + "type": "string" + }, + "accountType": { + "anyOf": [ + { + "type": "string", + "enum": [ + "iban" + ] + }, + { + "type": "string", + "enum": [ + "us" + ] + }, + { + "type": "string", + "enum": [ + "clabe" + ] + }, + { + "type": "string", + "enum": [ + "gb" + ] + } + ] + }, + "reuseOnError": { + "type": "boolean" + } + }, + "required": [ + "accountNumber", + "country", + "accountOwnerName", + "accountOwnerType", + "accountType" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "uuid", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "api-key", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + }, + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "uuid", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "api-key", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/bridge/customers/{customerId}/external-accounts/{externalAccountId}": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "customerId", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "externalAccountId", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "api-key", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + }, + "delete": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "customerId", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "externalAccountId", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "api-key", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/bridge/customers/{customerId}/external-accounts/{externalAccountId}/reactivate": { + "post": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "customerId", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "externalAccountId", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "api-key", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/bridge/customers/{uuid}/kyc-links": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "uuid", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "api-key", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/bridge/offramp/create": { + "post": { + "summary": "Initiate an off-ramp transfer", + "tags": [ + "offramp" + ], + "description": "This endpoint initiates a new off-ramp transfer. It uses the configured off-ramp provider (e.g., Bridge) to create a transfer and returns deposit instructions for the user.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "onBehalfOf": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "sendLinkPubKey": { + "type": "string" + }, + "source": { + "type": "object", + "properties": { + "currency": { + "anyOf": [ + { + "type": "string", + "enum": [ + "usdc" + ] + }, + { + "type": "string", + "enum": [ + "eurc" + ] + }, + { + "type": "string", + "enum": [ + "usdt" + ] + }, + { + "type": "string", + "enum": [ + "dai" + ] + } + ] + }, + "paymentRail": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ethereum" + ] + }, + { + "type": "string", + "enum": [ + "polygon" + ] + }, + { + "type": "string", + "enum": [ + "base" + ] + }, + { + "type": "string", + "enum": [ + "optimism" + ] + }, + { + "type": "string", + "enum": [ + "solana" + ] + }, + { + "type": "string", + "enum": [ + "stellar" + ] + }, + { + "type": "string", + "enum": [ + "arbitrum" + ] + }, + { + "type": "string", + "enum": [ + "avalance_c_chain" + ] + } + ] + }, + "fromAddress": { + "type": "string" + } + }, + "required": [ + "currency", + "paymentRail" + ] + }, + "destination": { + "type": "object", + "properties": { + "currency": { + "anyOf": [ + { + "type": "string", + "enum": [ + "usd" + ] + }, + { + "type": "string", + "enum": [ + "eur" + ] + }, + { + "type": "string", + "enum": [ + "mxn" + ] + }, + { + "type": "string", + "enum": [ + "gbp" + ] + } + ] + }, + "paymentRail": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ach" + ] + }, + { + "type": "string", + "enum": [ + "ach_push" + ] + }, + { + "type": "string", + "enum": [ + "ach_same_day" + ] + }, + { + "type": "string", + "enum": [ + "wire" + ] + }, + { + "type": "string", + "enum": [ + "sepa" + ] + }, + { + "type": "string", + "enum": [ + "swift" + ] + }, + { + "type": "string", + "enum": [ + "spei" + ] + }, + { + "type": "string", + "enum": [ + "faster_payments" + ] + } + ] + }, + "externalAccountId": { + "type": "string" + }, + "wireMessage": { + "type": "string" + }, + "sepaReference": { + "type": "string" + }, + "achReference": { + "type": "string" + }, + "fasterPaymentsReference": { + "type": "string" + } + }, + "required": [ + "currency", + "paymentRail", + "externalAccountId" + ] + }, + "features": { + "type": "object", + "properties": { + "flexibleAmount": { + "type": "boolean" + }, + "staticTemplate": { + "type": "boolean" + }, + "allowAnyFromAddress": { + "type": "boolean" + } + } + }, + "developerFee": { + "type": "string" + }, + "developerFeePercent": { + "type": "string" + } + }, + "required": [ + "onBehalfOf", + "source", + "destination" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "api-key", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "transferId": { + "type": "string" + }, + "depositInstructions": { + "type": "object", + "properties": { + "toAddress": { + "type": "string" + }, + "blockchainMemo": { + "type": "string" + } + }, + "required": [ + "toAddress" + ] + } + }, + "required": [ + "transferId", + "depositInstructions" + ] + } + } + } + }, + "401": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/bridge/offramp/create-for-guest": { + "post": { + "summary": "Initiate an off-ramp transfer for a guest", + "tags": [ + "offramp" + ], + "description": "This endpoint initiates a new off-ramp transfer for a guest user. It uses the configured off-ramp provider (e.g., Bridge) to create a transfer and returns deposit instructions for the user. This is intended for server-to-server use where the user is not authenticated.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "sendLinkPubKey": { + "type": "string" + }, + "source": { + "type": "object", + "properties": { + "currency": { + "anyOf": [ + { + "type": "string", + "enum": [ + "usdc" + ] + }, + { + "type": "string", + "enum": [ + "eurc" + ] + }, + { + "type": "string", + "enum": [ + "usdt" + ] + }, + { + "type": "string", + "enum": [ + "dai" + ] + } + ] + }, + "paymentRail": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ethereum" + ] + }, + { + "type": "string", + "enum": [ + "polygon" + ] + }, + { + "type": "string", + "enum": [ + "base" + ] + }, + { + "type": "string", + "enum": [ + "optimism" + ] + }, + { + "type": "string", + "enum": [ + "solana" + ] + }, + { + "type": "string", + "enum": [ + "stellar" + ] + }, + { + "type": "string", + "enum": [ + "arbitrum" + ] + }, + { + "type": "string", + "enum": [ + "avalance_c_chain" + ] + } + ] + }, + "fromAddress": { + "type": "string" + } + }, + "required": [ + "currency", + "paymentRail" + ] + }, + "destination": { + "type": "object", + "properties": { + "currency": { + "anyOf": [ + { + "type": "string", + "enum": [ + "usd" + ] + }, + { + "type": "string", + "enum": [ + "eur" + ] + }, + { + "type": "string", + "enum": [ + "mxn" + ] + }, + { + "type": "string", + "enum": [ + "gbp" + ] + } + ] + }, + "paymentRail": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ach" + ] + }, + { + "type": "string", + "enum": [ + "ach_push" + ] + }, + { + "type": "string", + "enum": [ + "ach_same_day" + ] + }, + { + "type": "string", + "enum": [ + "wire" + ] + }, + { + "type": "string", + "enum": [ + "sepa" + ] + }, + { + "type": "string", + "enum": [ + "swift" + ] + }, + { + "type": "string", + "enum": [ + "spei" + ] + }, + { + "type": "string", + "enum": [ + "faster_payments" + ] + } + ] + }, + "externalAccountId": { + "type": "string" + }, + "wireMessage": { + "type": "string" + }, + "sepaReference": { + "type": "string" + }, + "achReference": { + "type": "string" + } + }, + "required": [ + "currency", + "paymentRail", + "externalAccountId" + ] + }, + "beneficiaryName": { + "minLength": 1, + "type": "string" + }, + "beneficiaryAddress": { + "type": "object", + "properties": { + "street": { + "type": "string" + }, + "city": { + "type": "string" + }, + "country": { + "type": "string" + }, + "state": { + "type": "string" + }, + "postalCode": { + "type": "string" + } + }, + "required": [ + "street", + "city", + "country" + ] + } + }, + "required": [ + "userId", + "source", + "destination" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "api-key", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "transferId": { + "type": "string" + }, + "depositInstructions": { + "type": "object", + "properties": { + "toAddress": { + "type": "string" + }, + "blockchainMemo": { + "type": "string" + } + }, + "required": [ + "toAddress" + ] + }, + "quote": { + "type": "object", + "properties": { + "initial_amount": { + "type": "string" + }, + "developer_fee": { + "type": "string" + }, + "exchange_fee": { + "type": "string" + }, + "subtotal_amount": { + "type": "string" + }, + "remaining_prefunded_amount": { + "type": "string" + }, + "gas_fee": { + "type": "string" + }, + "final_amount": { + "type": "string" + }, + "source_tx_hash": { + "type": "string" + }, + "destination_tx_hash": { + "type": "string" + }, + "exchange_rate": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "initial_amount", + "developer_fee", + "exchange_fee", + "subtotal_amount" + ] + } + }, + "required": [ + "transferId", + "depositInstructions" + ] + } + } + } + }, + "401": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "503": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/bridge/transfers/{transferId}/confirm": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "txHash" + ], + "properties": { + "txHash": { + "type": "string", + "minLength": 66, + "maxLength": 66 + } + } + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "transferId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "properties": {} + } + } + } + }, + "409": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/bridge/onramp/create": { + "post": { + "summary": "Initiate an on-ramp transfer", + "tags": [ + "onramp" + ], + "description": "This endpoint initiates a new on-ramp transfer (fiat to crypto). It creates a transfer from fiat to USDC on Arbitrum to the user's peanut wallet and returns bank deposit instructions.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "amount": { + "type": "string" + }, + "chargeId": { + "type": "string" + }, + "recipientAddress": { + "type": "string" + }, + "source": { + "type": "object", + "properties": { + "currency": { + "anyOf": [ + { + "type": "string", + "enum": [ + "usd" + ] + }, + { + "type": "string", + "enum": [ + "eur" + ] + }, + { + "type": "string", + "enum": [ + "mxn" + ] + }, + { + "type": "string", + "enum": [ + "gbp" + ] + } + ] + }, + "paymentRail": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ach" + ] + }, + { + "type": "string", + "enum": [ + "ach_push" + ] + }, + { + "type": "string", + "enum": [ + "ach_same_day" + ] + }, + { + "type": "string", + "enum": [ + "wire" + ] + }, + { + "type": "string", + "enum": [ + "sepa" + ] + }, + { + "type": "string", + "enum": [ + "swift" + ] + }, + { + "type": "string", + "enum": [ + "spei" + ] + }, + { + "type": "string", + "enum": [ + "faster_payments" + ] + } + ] + } + }, + "required": [ + "currency", + "paymentRail" + ] + } + }, + "required": [ + "amount", + "source" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "api-key", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "transferId": { + "type": "string" + }, + "depositInstructions": { + "type": "object", + "properties": { + "amount": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "depositMessage": { + "type": "string" + }, + "bankName": { + "type": "string" + }, + "bankAddress": { + "type": "string" + }, + "bankRoutingNumber": { + "type": "string" + }, + "bankAccountNumber": { + "type": "string" + }, + "bankBeneficiaryName": { + "type": "string" + }, + "bankBeneficiaryAddress": { + "type": "string" + }, + "iban": { + "type": "string" + }, + "bic": { + "type": "string" + }, + "accountHolderName": { + "type": "string" + }, + "clabe": { + "type": "string" + }, + "sortCode": { + "type": "string" + }, + "accountNumber": { + "type": "string" + }, + "reference": { + "type": "string" + } + }, + "required": [ + "amount", + "currency", + "depositMessage" + ] + } + }, + "required": [ + "transferId", + "depositInstructions" + ] + } + } + } + }, + "401": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/bridge/onramp/{transferId}/cancel": { + "delete": { + "summary": "Cancel an on-ramp transfer", + "tags": [ + "onramp" + ], + "description": "This endpoint cancels an on-ramp transfer. The transfer must be in AWAITING_FUNDS/PENDING state. It updates the ledger and calls Bridge API to cancel the transfer.", + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "transferId", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "api-key", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + } + }, + "required": [ + "success", + "message" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/bridge/onramp/quote": { + "get": { + "parameters": [ + { + "schema": { + "type": "string", + "enum": [ + "iban", + "us", + "clabe", + "gb" + ] + }, + "in": "query", + "name": "accountType", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "sourceAmount", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/bridge/exchange-rate": { + "get": { + "parameters": [ + { + "schema": { + "type": "string", + "enum": [ + "iban", + "us", + "clabe", + "gb" + ] + }, + "in": "query", + "name": "accountType", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/rain/cards/withdraw/session-approve": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "serializedApproval": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "serializedApproval" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/rain/cards/withdraw/prepare": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "amount": { + "minLength": 1, + "type": "string" + }, + "recipientAddress": { + "pattern": "^0x[0-9a-fA-F]{40}$", + "type": "string" + }, + "directTransfer": { + "type": "boolean" + }, + "kind": { + "anyOf": [ + { + "type": "string", + "enum": [ + "P2P_SEND" + ] + }, + { + "type": "string", + "enum": [ + "QR_PAY" + ] + }, + { + "type": "string", + "enum": [ + "LINK_CREATE" + ] + }, + { + "type": "string", + "enum": [ + "CRYPTO_WITHDRAW" + ] + }, + { + "type": "string", + "enum": [ + "FIAT_OFFRAMP" + ] + }, + { + "type": "string", + "enum": [ + "FIAT_ONRAMP" + ] + }, + { + "type": "string", + "enum": [ + "REQUEST_PAY" + ] + }, + { + "type": "string", + "enum": [ + "AUTO_REBALANCE" + ] + }, + { + "type": "string", + "enum": [ + "CARD_SPEND" + ] + }, + { + "type": "string", + "enum": [ + "DEPOSIT_EXTERNAL" + ] + }, + { + "type": "string", + "enum": [ + "OTHER" + ] + } + ] + }, + "totalAmountCents": { + "minLength": 1, + "type": "string" + }, + "chargeId": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "amount", + "recipientAddress", + "directTransfer", + "kind" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "preparationId": { + "type": "string" + }, + "coordinatorAddress": { + "type": "string" + }, + "collateralProxy": { + "type": "string" + }, + "adminAddress": { + "type": "string" + }, + "chainId": { + "type": "string" + }, + "tokenAddress": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "recipientAddress": { + "type": "string" + }, + "directTransfer": { + "type": "boolean" + }, + "adminSalt": { + "type": "string" + }, + "adminNonce": { + "type": "string" + }, + "executorSignature": { + "type": "string" + }, + "executorSalt": { + "type": "string" + }, + "expiresAt": { + "type": "number" + } + }, + "required": [ + "preparationId", + "coordinatorAddress", + "collateralProxy", + "adminAddress", + "chainId", + "tokenAddress", + "amount", + "recipientAddress", + "directTransfer", + "adminSalt", + "adminNonce", + "executorSignature", + "executorSalt", + "expiresAt" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "422": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "425": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + }, + "retryAfterSec": { + "minimum": 1, + "maximum": 600, + "type": "integer" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "502": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/rain/cards/withdraw/stamp": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "preparationId": { + "minLength": 1, + "type": "string" + }, + "txHash": { + "pattern": "^0x[0-9a-fA-F]{64}$", + "type": "string" + } + }, + "required": [ + "preparationId", + "txHash" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "409": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/rain/cards/withdraw/submit": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "preparationId": { + "minLength": 1, + "type": "string" + }, + "amount": { + "minLength": 1, + "type": "string" + }, + "recipientAddress": { + "pattern": "^0x[0-9a-fA-F]{40}$", + "type": "string" + }, + "directTransfer": { + "type": "boolean" + }, + "adminSalt": { + "pattern": "^0x[0-9a-fA-F]{64}$", + "type": "string" + }, + "adminNonce": { + "minLength": 1, + "type": "string" + }, + "adminSignature": { + "pattern": "^0x[0-9a-fA-F]+$", + "type": "string" + }, + "executorSignature": { + "type": "string" + }, + "executorSalt": { + "type": "string" + }, + "expiresAt": { + "type": "number" + } + }, + "required": [ + "preparationId", + "amount", + "recipientAddress", + "directTransfer", + "adminSalt", + "adminNonce", + "adminSignature", + "executorSignature", + "executorSalt", + "expiresAt" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "txHash": { + "type": "string" + } + }, + "required": [ + "txHash" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "409": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string", + "enum": [ + "STALE_CARD_APPROVAL" + ] + } + }, + "required": [ + "error" + ] + } + } + } + }, + "410": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "502": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/manteca/qr-payment/init": { + "post": { + "summary": "Process QR payment", + "tags": [ + "manteca" + ], + "description": "Process a QR payment through Manteca. Supports PIX, QR 3.0, and CODI payments.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "qrCode": { + "description": "The QR code string to process for payment", + "type": "string" + }, + "amount": { + "description": "Amount for static QR codes (optional for dynamic QR codes)", + "type": "string" + }, + "qrType": { + "description": "Type of QR code (e.g. PIX, QR30, CODI). Used to select the correct fallback user for non-Manteca users.", + "type": "string" + } + }, + "required": [ + "qrCode" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/manteca/qr-payment/complete-with-signed-tx": { + "post": { + "summary": "Complete QR payment with signed transaction", + "tags": [ + "manteca" + ], + "description": "Completes Manteca payment first, then either broadcasts the signed UserOp or submits the signed Rain withdrawal via the session key. This prevents funds from being stuck if Manteca fails.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "userOp" + ] + }, + "paymentLockCode": { + "description": "The payment lock code from init", + "type": "string" + }, + "qrType": { + "description": "Type of QR code (e.g. PIX, QR30, CODI). Used to select the correct fallback user for non-Manteca users.", + "type": "string" + }, + "signedUserOp": { + "type": "object", + "properties": { + "sender": { + "type": "string" + }, + "nonce": {}, + "callData": { + "type": "string" + }, + "signature": { + "type": "string" + }, + "factory": { + "type": "string" + }, + "factoryData": { + "type": "string" + }, + "callGasLimit": {}, + "verificationGasLimit": {}, + "preVerificationGas": {}, + "maxFeePerGas": {}, + "maxPriorityFeePerGas": {}, + "paymaster": { + "type": "string" + }, + "paymasterData": { + "type": "string" + }, + "paymasterVerificationGasLimit": {}, + "paymasterPostOpGasLimit": {} + }, + "required": [ + "sender", + "nonce", + "callData", + "signature", + "callGasLimit", + "verificationGasLimit", + "preVerificationGas", + "maxFeePerGas", + "maxPriorityFeePerGas", + "paymasterVerificationGasLimit", + "paymasterPostOpGasLimit" + ] + }, + "chainId": { + "type": "string" + }, + "entryPointAddress": { + "type": "string" + }, + "rainPreparationId": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "kind", + "paymentLockCode", + "signedUserOp", + "chainId", + "entryPointAddress" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "rainWithdrawal" + ] + }, + "paymentLockCode": { + "description": "The payment lock code from init", + "type": "string" + }, + "qrType": { + "type": "string" + }, + "signedRainWithdrawal": { + "type": "object", + "properties": { + "preparationId": { + "minLength": 1, + "type": "string" + }, + "amount": { + "minLength": 1, + "type": "string" + }, + "recipientAddress": { + "pattern": "^0x[0-9a-fA-F]{40}$", + "type": "string" + }, + "directTransfer": { + "type": "boolean" + }, + "adminSalt": { + "pattern": "^0x[0-9a-fA-F]{64}$", + "type": "string" + }, + "adminNonce": { + "minLength": 1, + "type": "string" + }, + "adminSignature": { + "pattern": "^0x[0-9a-fA-F]+$", + "type": "string" + }, + "executorSignature": { + "type": "string" + }, + "executorSalt": { + "type": "string" + }, + "expiresAt": { + "type": "number" + } + }, + "required": [ + "preparationId", + "amount", + "recipientAddress", + "directTransfer", + "adminSalt", + "adminNonce", + "adminSignature", + "executorSignature", + "executorSalt", + "expiresAt" + ] + }, + "chainId": { + "type": "string" + } + }, + "required": [ + "kind", + "paymentLockCode", + "signedRainWithdrawal", + "chainId" + ] + } + ] + } + } + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/manteca/deposit": { + "post": { + "summary": "Create deposit order (onramp)", + "tags": [ + "manteca" + ], + "description": "Create a deposit order to convert fiat to crypto via Manteca. Returns deposit instructions.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "amount": { + "type": "string" + }, + "isUsdDenominated": { + "type": "boolean" + }, + "currency": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ARS" + ] + }, + { + "type": "string", + "enum": [ + "BRL" + ] + }, + { + "type": "string", + "enum": [ + "CLP" + ] + }, + { + "type": "string", + "enum": [ + "COP" + ] + }, + { + "type": "string", + "enum": [ + "PUSD" + ] + }, + { + "type": "string", + "enum": [ + "CRC" + ] + }, + { + "type": "string", + "enum": [ + "GTQ" + ] + }, + { + "type": "string", + "enum": [ + "MXN" + ] + }, + { + "type": "string", + "enum": [ + "PHP" + ] + }, + { + "type": "string", + "enum": [ + "BOB" + ] + } + ] + }, + "chargeId": { + "type": "string" + } + }, + "required": [ + "amount", + "currency" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/manteca/deposit/{depositId}/cancel": { + "patch": { + "summary": "Cancel deposit order", + "tags": [ + "manteca" + ], + "description": "Cancel a deposit order created via Manteca.", + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "depositId", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/manteca/deposit/{depositId}/status": { + "get": { + "summary": "Get deposit order status", + "tags": [ + "manteca" + ], + "description": "Poll the status of a deposit order by its Manteca synthetic id. Used by the BRL PIX QR flow to detect completion (intent.status === COMPLETED) without leaving the user on a static QR. Read-only \u2014 the webhook/poller remain the authoritative completion trigger.", + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "depositId", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/manteca/withdraw/init": { + "post": { + "summary": "Initialize withdraw with locked price", + "tags": [ + "manteca" + ], + "description": "Creates a price lock for withdraw. Returns the locked exchange rate valid for ~120 seconds. Use this before showing the user the final amount.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "amount": { + "description": "Amount to withdraw in USD (USDC)", + "type": "string" + }, + "currency": { + "description": "Target fiat currency (e.g. ARS, BRL)", + "anyOf": [ + { + "type": "string", + "enum": [ + "ARS" + ] + }, + { + "type": "string", + "enum": [ + "BRL" + ] + }, + { + "type": "string", + "enum": [ + "CLP" + ] + }, + { + "type": "string", + "enum": [ + "COP" + ] + }, + { + "type": "string", + "enum": [ + "PUSD" + ] + }, + { + "type": "string", + "enum": [ + "CRC" + ] + }, + { + "type": "string", + "enum": [ + "GTQ" + ] + }, + { + "type": "string", + "enum": [ + "MXN" + ] + }, + { + "type": "string", + "enum": [ + "PHP" + ] + }, + { + "type": "string", + "enum": [ + "BOB" + ] + } + ] + } + }, + "required": [ + "amount", + "currency" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/manteca/withdraw": { + "post": { + "summary": "Create withdraw order (offramp)", + "tags": [ + "manteca" + ], + "description": "Create a withdraw order to convert crypto to fiat via Manteca. Optionally use a pre-locked price from /withdraw/init.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "amount": { + "description": "Amount to withdraw in crypto asset", + "type": "string" + }, + "txHash": { + "description": "Transaction hash of the withdrawal", + "type": "string" + }, + "destinationAddress": { + "description": "Destination address to withdraw to", + "type": "string" + }, + "bankCode": { + "type": "string" + }, + "accountType": { + "anyOf": [ + { + "type": "string", + "enum": [ + "SAVINGS" + ] + }, + { + "type": "string", + "enum": [ + "CHECKING" + ] + }, + { + "type": "string", + "enum": [ + "DEBIT" + ] + }, + { + "type": "string", + "enum": [ + "PHONE" + ] + }, + { + "type": "string", + "enum": [ + "VISTA" + ] + }, + { + "type": "string", + "enum": [ + "RUT" + ] + } + ] + }, + "currency": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ARS" + ] + }, + { + "type": "string", + "enum": [ + "BRL" + ] + }, + { + "type": "string", + "enum": [ + "CLP" + ] + }, + { + "type": "string", + "enum": [ + "COP" + ] + }, + { + "type": "string", + "enum": [ + "PUSD" + ] + }, + { + "type": "string", + "enum": [ + "CRC" + ] + }, + { + "type": "string", + "enum": [ + "GTQ" + ] + }, + { + "type": "string", + "enum": [ + "MXN" + ] + }, + { + "type": "string", + "enum": [ + "PHP" + ] + }, + { + "type": "string", + "enum": [ + "BOB" + ] + } + ] + }, + "priceLockCode": { + "description": "Price lock code from /withdraw/init. If not provided, a new price lock is created.", + "type": "string" + } + }, + "required": [ + "amount", + "txHash", + "destinationAddress" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/manteca/withdraw/complete-with-signed-tx": { + "post": { + "summary": "Complete withdraw with signed transaction (sign-then-broadcast)", + "tags": [ + "manteca" + ], + "description": "Creates Manteca ramp-off order FIRST, then either broadcasts the signed UserOp or submits the signed Rain withdrawal via the session key. Prevents funds from being stuck if Manteca fails.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "userOp" + ] + }, + "priceLockCode": { + "description": "The price lock code from /withdraw/init", + "type": "string" + }, + "amount": { + "description": "Amount to withdraw in USD (USDC)", + "type": "string" + }, + "destinationAddress": { + "description": "Destination bank account address", + "type": "string" + }, + "bankCode": { + "type": "string" + }, + "accountType": { + "anyOf": [ + { + "type": "string", + "enum": [ + "SAVINGS" + ] + }, + { + "type": "string", + "enum": [ + "CHECKING" + ] + }, + { + "type": "string", + "enum": [ + "DEBIT" + ] + }, + { + "type": "string", + "enum": [ + "PHONE" + ] + }, + { + "type": "string", + "enum": [ + "VISTA" + ] + }, + { + "type": "string", + "enum": [ + "RUT" + ] + } + ] + }, + "currency": { + "description": "Target fiat currency (must match price lock)", + "anyOf": [ + { + "type": "string", + "enum": [ + "ARS" + ] + }, + { + "type": "string", + "enum": [ + "BRL" + ] + }, + { + "type": "string", + "enum": [ + "CLP" + ] + }, + { + "type": "string", + "enum": [ + "COP" + ] + }, + { + "type": "string", + "enum": [ + "PUSD" + ] + }, + { + "type": "string", + "enum": [ + "CRC" + ] + }, + { + "type": "string", + "enum": [ + "GTQ" + ] + }, + { + "type": "string", + "enum": [ + "MXN" + ] + }, + { + "type": "string", + "enum": [ + "PHP" + ] + }, + { + "type": "string", + "enum": [ + "BOB" + ] + } + ] + }, + "signedUserOp": { + "type": "object", + "properties": { + "sender": { + "type": "string" + }, + "nonce": {}, + "callData": { + "type": "string" + }, + "signature": { + "type": "string" + }, + "factory": { + "type": "string" + }, + "factoryData": { + "type": "string" + }, + "callGasLimit": {}, + "verificationGasLimit": {}, + "preVerificationGas": {}, + "maxFeePerGas": {}, + "maxPriorityFeePerGas": {}, + "paymaster": { + "type": "string" + }, + "paymasterData": { + "type": "string" + }, + "paymasterVerificationGasLimit": {}, + "paymasterPostOpGasLimit": {} + }, + "required": [ + "sender", + "nonce", + "callData", + "signature", + "callGasLimit", + "verificationGasLimit", + "preVerificationGas", + "maxFeePerGas", + "maxPriorityFeePerGas", + "paymasterVerificationGasLimit", + "paymasterPostOpGasLimit" + ] + }, + "chainId": { + "type": "string" + }, + "entryPointAddress": { + "type": "string" + }, + "rainPreparationId": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "kind", + "priceLockCode", + "amount", + "destinationAddress", + "currency", + "signedUserOp", + "chainId", + "entryPointAddress" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "rainWithdrawal" + ] + }, + "priceLockCode": { + "description": "The price lock code from /withdraw/init", + "type": "string" + }, + "amount": { + "description": "Amount to withdraw in USD (USDC)", + "type": "string" + }, + "destinationAddress": { + "description": "Destination bank account address", + "type": "string" + }, + "bankCode": { + "type": "string" + }, + "accountType": { + "anyOf": [ + { + "type": "string", + "enum": [ + "SAVINGS" + ] + }, + { + "type": "string", + "enum": [ + "CHECKING" + ] + }, + { + "type": "string", + "enum": [ + "DEBIT" + ] + }, + { + "type": "string", + "enum": [ + "PHONE" + ] + }, + { + "type": "string", + "enum": [ + "VISTA" + ] + }, + { + "type": "string", + "enum": [ + "RUT" + ] + } + ] + }, + "currency": { + "description": "Target fiat currency (must match price lock)", + "anyOf": [ + { + "type": "string", + "enum": [ + "ARS" + ] + }, + { + "type": "string", + "enum": [ + "BRL" + ] + }, + { + "type": "string", + "enum": [ + "CLP" + ] + }, + { + "type": "string", + "enum": [ + "COP" + ] + }, + { + "type": "string", + "enum": [ + "PUSD" + ] + }, + { + "type": "string", + "enum": [ + "CRC" + ] + }, + { + "type": "string", + "enum": [ + "GTQ" + ] + }, + { + "type": "string", + "enum": [ + "MXN" + ] + }, + { + "type": "string", + "enum": [ + "PHP" + ] + }, + { + "type": "string", + "enum": [ + "BOB" + ] + } + ] + }, + "signedRainWithdrawal": { + "type": "object", + "properties": { + "preparationId": { + "minLength": 1, + "type": "string" + }, + "amount": { + "minLength": 1, + "type": "string" + }, + "recipientAddress": { + "pattern": "^0x[0-9a-fA-F]{40}$", + "type": "string" + }, + "directTransfer": { + "type": "boolean" + }, + "adminSalt": { + "pattern": "^0x[0-9a-fA-F]{64}$", + "type": "string" + }, + "adminNonce": { + "minLength": 1, + "type": "string" + }, + "adminSignature": { + "pattern": "^0x[0-9a-fA-F]+$", + "type": "string" + }, + "executorSignature": { + "type": "string" + }, + "executorSalt": { + "type": "string" + }, + "expiresAt": { + "type": "number" + } + }, + "required": [ + "preparationId", + "amount", + "recipientAddress", + "directTransfer", + "adminSalt", + "adminNonce", + "adminSignature", + "executorSignature", + "executorSalt", + "expiresAt" + ] + }, + "chainId": { + "type": "string" + } + }, + "required": [ + "kind", + "priceLockCode", + "amount", + "destinationAddress", + "currency", + "signedRainWithdrawal", + "chainId" + ] + } + ] + } + } + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/manteca/webhook": { + "post": { + "summary": "Manteca webhook handler", + "tags": [ + "manteca" + ], + "description": "Handle webhook notifications from Manteca about synthetic status updates", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "event": { + "description": "Event type from Manteca", + "type": "string" + }, + "data": { + "description": "Event payload from Manteca" + } + }, + "required": [ + "event", + "data" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "md-webhook-signature", + "required": false, + "description": "HMAC signature for webhook verification" + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/manteca/initiate-onboarding": { + "post": { + "summary": "Initiate Manteca onboarding", + "tags": [ + "manteca" + ], + "description": "Creates an onboarding widget URL for the current user using userId as userExternalId and returns the URL", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "returnUrl": { + "type": "string" + }, + "failureUrl": { + "type": "string" + }, + "exchange": { + "type": "string" + } + }, + "required": [ + "returnUrl" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/manteca/prices": { + "get": { + "parameters": [ + { + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "USDT" + ] + }, + { + "type": "string", + "enum": [ + "USDC" + ] + }, + { + "type": "string", + "enum": [ + "ETH" + ] + }, + { + "type": "string", + "enum": [ + "BTC" + ] + }, + { + "type": "string", + "enum": [ + "ARS" + ] + }, + { + "type": "string", + "enum": [ + "USD" + ] + }, + { + "type": "string", + "enum": [ + "BRL" + ] + }, + { + "type": "string", + "enum": [ + "CLP" + ] + } + ] + }, + "in": "query", + "name": "asset", + "required": true + }, + { + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "USDT" + ] + }, + { + "type": "string", + "enum": [ + "USDC" + ] + }, + { + "type": "string", + "enum": [ + "ETH" + ] + }, + { + "type": "string", + "enum": [ + "BTC" + ] + }, + { + "type": "string", + "enum": [ + "ARS" + ] + }, + { + "type": "string", + "enum": [ + "USD" + ] + }, + { + "type": "string", + "enum": [ + "BRL" + ] + }, + { + "type": "string", + "enum": [ + "CLP" + ] + } + ] + }, + "in": "query", + "name": "against", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "api-key", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/perks/claim": { + "post": { + "summary": "Claim a perk", + "tags": [ + "perks" + ], + "description": "User-triggered action to claim USDC sponsorship for an eligible perk", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "usageId": { + "description": "PerkUsage id to claim", + "type": "string" + } + }, + "required": [ + "usageId" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/perks/pending": { + "get": { + "summary": "Get pending perks", + "tags": [ + "perks" + ], + "description": "Returns all PENDING_CLAIM perks for the authenticated user", + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "perks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "amountUsd": { + "type": "number" + }, + "createdAt": { + "type": "string" + }, + "inviteeName": { + "type": "string" + } + }, + "required": [ + "id", + "amountUsd", + "createdAt" + ] + } + } + }, + "required": [ + "perks" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/invites/accept": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "inviteCode": { + "minLength": 1, + "maxLength": 255, + "pattern": ".*\\S.*", + "type": "string" + }, + "type": { + "anyOf": [ + { + "type": "string", + "enum": [ + "DIRECT" + ] + }, + { + "type": "string", + "enum": [ + "PAYMENT_LINK" + ] + } + ] + }, + "campaignTag": { + "minLength": 1, + "maxLength": 68, + "pattern": ".*\\S.*", + "type": "string" + } + }, + "required": [ + "inviteCode", + "type" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "attributionResolved": { + "type": "boolean", + "enum": [ + true + ] + }, + "onboardingResolved": { + "type": "boolean", + "enum": [ + true + ] + }, + "attributionKind": { + "anyOf": [ + { + "type": "string", + "enum": [ + "PERSONAL" + ] + }, + { + "type": "string", + "enum": [ + "SYSTEM" + ] + }, + { + "type": "string", + "enum": [ + "LEGACY_UNRESOLVED" + ] + } + ] + }, + "claims": { + "type": "array", + "items": { + "type": "object", + "properties": { + "badgeCampaign": { + "minLength": 1, + "maxLength": 68, + "pattern": ".*\\S.*", + "description": "Opaque badge-acquisition campaign value. New integrations use the badge_campaign slug.", + "type": "string" + }, + "badgeCode": { + "minLength": 1, + "maxLength": 50, + "pattern": "^[A-Z][A-Z0-9_]{0,49}$", + "description": "Stable badge code. Clients must tolerate codes added after SDK generation.", + "type": "string" + }, + "badge": { + "type": "object", + "properties": { + "code": { + "minLength": 1, + "maxLength": 50, + "pattern": "^[A-Z][A-Z0-9_]{0,49}$", + "description": "Stable badge code. Clients must tolerate codes added after SDK generation.", + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "publicDescription": { + "type": "string" + }, + "iconUrl": { + "type": "string" + } + }, + "required": [ + "code", + "name", + "description", + "publicDescription", + "iconUrl" + ] + }, + "outcome": { + "anyOf": [ + { + "type": "string", + "enum": [ + "awarded" + ] + }, + { + "type": "string", + "enum": [ + "already_owned" + ] + }, + { + "type": "string", + "enum": [ + "inactive" + ] + }, + { + "type": "string", + "enum": [ + "expired" + ] + }, + { + "type": "string", + "enum": [ + "unknown" + ] + }, + { + "type": "string", + "enum": [ + "definition_missing" + ] + } + ] + }, + "acquisition": { + "type": "object", + "properties": { + "fallback": { + "type": "string", + "enum": [ + "normal_app" + ] + }, + "destination": { + "anyOf": [ + { + "type": "string", + "enum": [ + "offramp_migration" + ] + }, + { + "type": "string", + "enum": [ + "normal_app" + ] + } + ] + } + }, + "required": [ + "fallback", + "destination" + ] + } + }, + "required": [ + "badgeCampaign", + "outcome" + ] + } + }, + "legacyAcquisition": { + "type": "object", + "properties": { + "campaignTag": { + "type": "string" + }, + "fallback": { + "type": "string", + "enum": [ + "normal_app" + ] + }, + "destination": { + "anyOf": [ + { + "type": "string", + "enum": [ + "offramp_migration" + ] + }, + { + "type": "string", + "enum": [ + "normal_app" + ] + } + ] + } + }, + "required": [ + "campaignTag", + "fallback", + "destination" + ] + } + }, + "required": [ + "message", + "attributionResolved", + "onboardingResolved", + "attributionKind", + "claims" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "409": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "attributionResolved": { + "type": "boolean", + "enum": [ + false + ] + }, + "onboardingResolved": { + "type": "boolean", + "enum": [ + false + ] + }, + "claims": { + "type": "array", + "items": { + "type": "object", + "properties": { + "badgeCampaign": { + "minLength": 1, + "maxLength": 68, + "pattern": ".*\\S.*", + "description": "Opaque badge-acquisition campaign value. New integrations use the badge_campaign slug.", + "type": "string" + }, + "badgeCode": { + "minLength": 1, + "maxLength": 50, + "pattern": "^[A-Z][A-Z0-9_]{0,49}$", + "description": "Stable badge code. Clients must tolerate codes added after SDK generation.", + "type": "string" + }, + "badge": { + "type": "object", + "properties": { + "code": { + "minLength": 1, + "maxLength": 50, + "pattern": "^[A-Z][A-Z0-9_]{0,49}$", + "description": "Stable badge code. Clients must tolerate codes added after SDK generation.", + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "publicDescription": { + "type": "string" + }, + "iconUrl": { + "type": "string" + } + }, + "required": [ + "code", + "name", + "description", + "publicDescription", + "iconUrl" + ] + }, + "outcome": { + "anyOf": [ + { + "type": "string", + "enum": [ + "awarded" + ] + }, + { + "type": "string", + "enum": [ + "already_owned" + ] + }, + { + "type": "string", + "enum": [ + "inactive" + ] + }, + { + "type": "string", + "enum": [ + "expired" + ] + }, + { + "type": "string", + "enum": [ + "unknown" + ] + }, + { + "type": "string", + "enum": [ + "definition_missing" + ] + } + ] + }, + "acquisition": { + "type": "object", + "properties": { + "fallback": { + "type": "string", + "enum": [ + "normal_app" + ] + }, + "destination": { + "anyOf": [ + { + "type": "string", + "enum": [ + "offramp_migration" + ] + }, + { + "type": "string", + "enum": [ + "normal_app" + ] + } + ] + } + }, + "required": [ + "fallback", + "destination" + ] + } + }, + "required": [ + "badgeCampaign", + "outcome" + ] + } + }, + "legacyAcquisition": { + "type": "object", + "properties": { + "campaignTag": { + "type": "string" + }, + "fallback": { + "type": "string", + "enum": [ + "normal_app" + ] + }, + "destination": { + "anyOf": [ + { + "type": "string", + "enum": [ + "offramp_migration" + ] + }, + { + "type": "string", + "enum": [ + "normal_app" + ] + } + ] + } + }, + "required": [ + "campaignTag", + "fallback", + "destination" + ] + } + }, + "required": [ + "message", + "attributionResolved", + "onboardingResolved", + "claims" + ] + } + } + } + } + } + } + }, + "/invites": { + "get": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/invites/validate": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "inviteCode": { + "minLength": 1, + "maxLength": 255, + "pattern": ".*\\S.*", + "type": "string" + } + }, + "required": [ + "inviteCode" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "attributionResolved": { + "type": "boolean", + "enum": [ + true + ] + }, + "onboardingResolved": { + "type": "boolean", + "enum": [ + true + ] + }, + "attributionKind": { + "anyOf": [ + { + "type": "string", + "enum": [ + "PERSONAL" + ] + }, + { + "type": "string", + "enum": [ + "SYSTEM" + ] + }, + { + "type": "string", + "enum": [ + "LEGACY_UNRESOLVED" + ] + } + ] + }, + "username": { + "type": "string" + }, + "legacyAcquisition": { + "type": "object", + "properties": { + "campaignTag": { + "type": "string" + }, + "fallback": { + "type": "string", + "enum": [ + "normal_app" + ] + }, + "destination": { + "anyOf": [ + { + "type": "string", + "enum": [ + "offramp_migration" + ] + }, + { + "type": "string", + "enum": [ + "normal_app" + ] + } + ] + } + }, + "required": [ + "campaignTag", + "fallback", + "destination" + ] + } + }, + "required": [ + "message", + "attributionResolved", + "onboardingResolved", + "attributionKind", + "username" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "409": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "attributionResolved": { + "type": "boolean", + "enum": [ + false + ] + }, + "onboardingResolved": { + "type": "boolean", + "enum": [ + false + ] + }, + "legacyAcquisition": { + "type": "object", + "properties": { + "campaignTag": { + "type": "string" + }, + "fallback": { + "type": "string", + "enum": [ + "normal_app" + ] + }, + "destination": { + "anyOf": [ + { + "type": "string", + "enum": [ + "offramp_migration" + ] + }, + { + "type": "string", + "enum": [ + "normal_app" + ] + } + ] + } + }, + "required": [ + "campaignTag", + "fallback", + "destination" + ] + } + }, + "required": [ + "message", + "attributionResolved", + "onboardingResolved" + ] + } + } + } + } + } + } + }, + "/invites/waitlist-position": { + "get": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/invites/graph": { + "get": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/invites/graph/external": { + "get": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/invites/user-graph": { + "get": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/notifications/send": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "externalUserIds": { + "minItems": 1, + "maxItems": 10, + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "url": { + "type": "string" + }, + "data": { + "type": "object", + "additionalProperties": {} + }, + "templateId": { + "type": "string" + }, + "idempotencyKey": { + "type": "string" + }, + "channel": { + "anyOf": [ + { + "type": "string", + "enum": [ + "push" + ] + }, + { + "type": "string", + "enum": [ + "email" + ] + } + ] + } + }, + "required": [ + "externalUserIds" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "x-admin-token", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/notifications/admin/recent": { + "get": { + "summary": "Recent notification rows for a user (all channels + statuses)", + "tags": [ + "admin" + ], + "parameters": [ + { + "schema": { + "minLength": 1, + "type": "string" + }, + "in": "query", + "name": "userId", + "required": true + }, + { + "schema": { + "minimum": 1, + "maximum": 100, + "default": 20, + "type": "integer" + }, + "in": "query", + "name": "limit", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "x-admin-token", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "eventType": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "status": { + "anyOf": [ + { + "type": "string", + "enum": [ + "PENDING" + ] + }, + { + "type": "string", + "enum": [ + "SENT" + ] + }, + { + "type": "string", + "enum": [ + "FAILED" + ] + }, + { + "type": "string", + "enum": [ + "SKIPPED" + ] + } + ] + }, + "skipReason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "error": { + "anyOf": [ + { + "type": "object", + "properties": { + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "reason", + "name" + ] + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string" + }, + "sentAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "eventType", + "channel", + "status", + "skipReason", + "providerId", + "error", + "createdAt", + "sentAt" + ] + } + } + }, + "required": [ + "items" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/notifications": { + "get": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/notifications/unread-count": { + "get": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/notifications/mark-read": { + "post": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/unsubscribe": { + "get": { + "tags": [ + "notifications" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "token", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + }, + "post": { + "tags": [ + "notifications" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "token", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/webhooks/onesignal": { + "post": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/badge/claims": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "badgeCampaigns": { + "minItems": 1, + "maxItems": 20, + "type": "array", + "items": { + "minLength": 1, + "maxLength": 68, + "pattern": ".*\\S.*", + "description": "Opaque badge-acquisition campaign value. New integrations use the badge_campaign slug.", + "type": "string" + } + } + }, + "required": [ + "badgeCampaigns" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "claims": { + "type": "array", + "items": { + "type": "object", + "properties": { + "badgeCampaign": { + "minLength": 1, + "maxLength": 68, + "pattern": ".*\\S.*", + "description": "Opaque badge-acquisition campaign value. New integrations use the badge_campaign slug.", + "type": "string" + }, + "badgeCode": { + "minLength": 1, + "maxLength": 50, + "pattern": "^[A-Z][A-Z0-9_]{0,49}$", + "description": "Stable badge code. Clients must tolerate codes added after SDK generation.", + "type": "string" + }, + "badge": { + "type": "object", + "properties": { + "code": { + "minLength": 1, + "maxLength": 50, + "pattern": "^[A-Z][A-Z0-9_]{0,49}$", + "description": "Stable badge code. Clients must tolerate codes added after SDK generation.", + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "publicDescription": { + "type": "string" + }, + "iconUrl": { + "type": "string" + } + }, + "required": [ + "code", + "name", + "description", + "publicDescription", + "iconUrl" + ] + }, + "outcome": { + "anyOf": [ + { + "type": "string", + "enum": [ + "awarded" + ] + }, + { + "type": "string", + "enum": [ + "already_owned" + ] + }, + { + "type": "string", + "enum": [ + "inactive" + ] + }, + { + "type": "string", + "enum": [ + "expired" + ] + }, + { + "type": "string", + "enum": [ + "unknown" + ] + }, + { + "type": "string", + "enum": [ + "definition_missing" + ] + } + ] + }, + "acquisition": { + "type": "object", + "properties": { + "fallback": { + "type": "string", + "enum": [ + "normal_app" + ] + }, + "destination": { + "anyOf": [ + { + "type": "string", + "enum": [ + "offramp_migration" + ] + }, + { + "type": "string", + "enum": [ + "normal_app" + ] + } + ] + } + }, + "required": [ + "fallback", + "destination" + ] + } + }, + "required": [ + "badgeCampaign", + "outcome" + ] + } + } + }, + "required": [ + "claims" + ] + } + } + } + } + } + } + }, + "/badge/award": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "campaignTag": { + "minLength": 1, + "maxLength": 68, + "pattern": ".*\\S.*", + "description": "Opaque badge-acquisition campaign value. New integrations use the badge_campaign slug.", + "type": "string" + } + }, + "required": [ + "campaignTag" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "claim": { + "type": "object", + "properties": { + "badgeCampaign": { + "minLength": 1, + "maxLength": 68, + "pattern": ".*\\S.*", + "description": "Opaque badge-acquisition campaign value. New integrations use the badge_campaign slug.", + "type": "string" + }, + "badgeCode": { + "minLength": 1, + "maxLength": 50, + "pattern": "^[A-Z][A-Z0-9_]{0,49}$", + "description": "Stable badge code. Clients must tolerate codes added after SDK generation.", + "type": "string" + }, + "badge": { + "type": "object", + "properties": { + "code": { + "minLength": 1, + "maxLength": 50, + "pattern": "^[A-Z][A-Z0-9_]{0,49}$", + "description": "Stable badge code. Clients must tolerate codes added after SDK generation.", + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "publicDescription": { + "type": "string" + }, + "iconUrl": { + "type": "string" + } + }, + "required": [ + "code", + "name", + "description", + "publicDescription", + "iconUrl" + ] + }, + "outcome": { + "anyOf": [ + { + "type": "string", + "enum": [ + "awarded" + ] + }, + { + "type": "string", + "enum": [ + "already_owned" + ] + }, + { + "type": "string", + "enum": [ + "inactive" + ] + }, + { + "type": "string", + "enum": [ + "expired" + ] + }, + { + "type": "string", + "enum": [ + "unknown" + ] + }, + { + "type": "string", + "enum": [ + "definition_missing" + ] + } + ] + }, + "acquisition": { + "type": "object", + "properties": { + "fallback": { + "type": "string", + "enum": [ + "normal_app" + ] + }, + "destination": { + "anyOf": [ + { + "type": "string", + "enum": [ + "offramp_migration" + ] + }, + { + "type": "string", + "enum": [ + "normal_app" + ] + } + ] + } + }, + "required": [ + "fallback", + "destination" + ] + } + }, + "required": [ + "badgeCampaign", + "outcome" + ] + } + }, + "required": [ + "message", + "claim" + ] + } + } + } + } + } + } + }, + "/admin/support/grant": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { + "minLength": 1, + "maxLength": 64, + "type": "string" + }, + "override": { + "type": "boolean" + }, + "amountUsd": { + "minimum": 0.01, + "maximum": 50, + "type": "number" + } + }, + "required": [ + "username" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "x-admin-token", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/admin/card-waitlist/release": { + "post": { + "summary": "Release users from the card waitlist", + "tags": [ + "admin" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userIds": { + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "userIds" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "x-admin-token", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "released": { + "type": "array", + "items": { + "type": "string" + } + }, + "skipped": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "released", + "skipped" + ] + } + } + } + }, + "401": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/points": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/points/history": { + "get": { + "parameters": [ + { + "schema": { + "minimum": 1, + "maximum": 100, + "type": "number" + }, + "in": "query", + "name": "limit", + "required": false + }, + { + "schema": { + "minimum": 0, + "type": "number" + }, + "in": "query", + "name": "offset", + "required": false + }, + { + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "BRIDGE_FEE" + ] + }, + { + "type": "string", + "enum": [ + "MANTECA_FEE" + ] + }, + { + "type": "string", + "enum": [ + "RAIN_CARD_SPEND" + ] + }, + { + "type": "string", + "enum": [ + "CHARGE_FEE" + ] + }, + { + "type": "string", + "enum": [ + "P2P_SEND_LINK" + ] + }, + { + "type": "string", + "enum": [ + "P2P_REQUEST_PAYMENT" + ] + }, + { + "type": "string", + "enum": [ + "CRYPTO_WITHDRAW" + ] + }, + { + "type": "string", + "enum": [ + "SIGNUP" + ] + }, + { + "type": "string", + "enum": [ + "KYC_VERIFIED" + ] + }, + { + "type": "string", + "enum": [ + "TRANSITIVE_UPDATE" + ] + }, + { + "type": "string", + "enum": [ + "HANDSHAKE_BONUS" + ] + }, + { + "type": "string", + "enum": [ + "ADMIN_ADJUSTMENT" + ] + }, + { + "type": "string", + "enum": [ + "PERK_REDEMPTION" + ] + }, + { + "type": "string", + "enum": [ + "MIGRATION_FROM_OLD_SYSTEM" + ] + }, + { + "type": "string", + "enum": [ + "REFERRAL_REWARD_ACCRUAL" + ] + } + ] + }, + "in": "query", + "name": "type", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/points/leaderboard": { + "get": { + "parameters": [ + { + "schema": { + "minimum": 1, + "maximum": 500, + "type": "number" + }, + "in": "query", + "name": "limit", + "required": false + }, + { + "schema": { + "type": "boolean" + }, + "in": "query", + "name": "includeMe", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/points/time-leaderboard": { + "get": { + "parameters": [ + { + "schema": { + "minimum": 1, + "maximum": 100, + "type": "number" + }, + "in": "query", + "name": "limit", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "since", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/points/invites": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/points/cash-status": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/points/calculate": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "actionType": { + "anyOf": [ + { + "type": "string", + "enum": [ + "BRIDGE_TRANSFER" + ] + }, + { + "type": "string", + "enum": [ + "MANTECA_TRANSFER" + ] + }, + { + "type": "string", + "enum": [ + "MANTECA_QR_PAYMENT" + ] + }, + { + "type": "string", + "enum": [ + "P2P_SEND_LINK" + ] + }, + { + "type": "string", + "enum": [ + "P2P_REQUEST_PAYMENT" + ] + }, + { + "type": "string", + "enum": [ + "KYC_VERIFIED" + ] + } + ] + }, + "usdAmount": { + "minimum": 0, + "type": "number" + }, + "otherUserId": { + "type": "string" + } + }, + "required": [ + "actionType" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/points/admin/adjust": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "pointsChange": { + "type": "number" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "userId", + "pointsChange", + "reason" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "api-key", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/quests/leaderboards": { + "get": { + "parameters": [ + { + "schema": { + "minimum": 1, + "maximum": 10, + "default": 3, + "type": "number" + }, + "in": "query", + "name": "limit", + "required": false + }, + { + "schema": { + "default": false, + "type": "boolean" + }, + "in": "query", + "name": "useTestTimePeriod", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/quests/{questId}/leaderboard": { + "get": { + "parameters": [ + { + "schema": { + "minimum": 1, + "maximum": 10, + "default": 10, + "type": "number" + }, + "in": "query", + "name": "limit", + "required": false + }, + { + "schema": { + "default": false, + "type": "boolean" + }, + "in": "query", + "name": "useTestTimePeriod", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "questId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/qr/{code}": { + "get": { + "summary": "Get QR redirect or status", + "tags": [ + "redirects" + ], + "description": "Returns redirect URL if claimed, or indicates QR is available to claim", + "parameters": [ + { + "schema": { + "minLength": 16, + "maxLength": 16, + "type": "string" + }, + "in": "path", + "name": "code", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/qr/{code}/claim": { + "post": { + "summary": "Claim a QR code", + "tags": [ + "redirects" + ], + "description": "Claim an unclaimed QR code and tie it to your invite link", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "targetUrl": { + "format": "uri", + "type": "string" + } + } + } + } + } + }, + "parameters": [ + { + "schema": { + "minLength": 16, + "maxLength": 16, + "type": "string" + }, + "in": "path", + "name": "code", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/rhino/deposit": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "destinationAddress": { + "type": "string" + }, + "type": { + "anyOf": [ + { + "type": "string", + "enum": [ + "EVM" + ] + }, + { + "type": "string", + "enum": [ + "SOL" + ] + }, + { + "type": "string", + "enum": [ + "TRON" + ] + } + ] + } + }, + "required": [ + "destinationAddress", + "type" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/rhino/request-fulfilment": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "type": { + "anyOf": [ + { + "type": "string", + "enum": [ + "EVM" + ] + }, + { + "type": "string", + "enum": [ + "SOL" + ] + }, + { + "type": "string", + "enum": [ + "TRON" + ] + } + ] + }, + "chargeId": { + "type": "string" + }, + "senderPeanutWalletAddress": { + "type": "string" + } + }, + "required": [ + "type", + "chargeId" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/rhino/rhinofi-event": { + "post": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/rhino/status/{depositAddress}": { + "get": { + "tags": [ + "rhino" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "depositAddress", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/rhino/reset-status/{depositAddress}": { + "post": { + "tags": [ + "rhino" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "depositAddress", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/rhino/sda-transfer/preview": { + "post": { + "tags": [ + "rhino" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "chainIn": { + "type": "string" + }, + "chainOut": { + "type": "string" + }, + "token": { + "minLength": 1, + "type": "string" + }, + "amount": { + "type": "string" + }, + "mode": { + "anyOf": [ + { + "type": "string", + "enum": [ + "pay" + ] + }, + { + "type": "string", + "enum": [ + "receive" + ] + } + ] + } + }, + "required": [ + "chainIn", + "chainOut", + "token", + "amount", + "mode" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/rhino/sda-transfer": { + "post": { + "tags": [ + "rhino" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "context": { + "anyOf": [ + { + "type": "string", + "enum": [ + "withdraw" + ] + }, + { + "type": "string", + "enum": [ + "pay-request" + ] + }, + { + "type": "string", + "enum": [ + "claim-xchain" + ] + } + ] + }, + "contextId": { + "type": "string" + }, + "depositChain": { + "type": "string" + }, + "destinationChain": { + "type": "string" + }, + "destinationAddress": { + "type": "string" + }, + "tokenOut": { + "minLength": 1, + "type": "string" + }, + "senderPeanutWalletAddress": { + "type": "string" + }, + "feeUsd": { + "type": "number" + }, + "payAmount": { + "type": "string" + }, + "receiveAmount": { + "type": "string" + } + }, + "required": [ + "context", + "contextId", + "depositChain", + "destinationChain", + "destinationAddress", + "tokenOut" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/rhino/bridge/quote": { + "post": { + "tags": [ + "rhino" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "amount": { + "minLength": 1, + "type": "string" + }, + "tokenIn": { + "minLength": 1, + "type": "string" + }, + "tokenOut": { + "minLength": 1, + "type": "string" + }, + "chainOut": { + "minLength": 1, + "type": "string" + }, + "recipient": { + "minLength": 1, + "type": "string" + }, + "depositor": { + "minLength": 1, + "type": "string" + }, + "mode": { + "anyOf": [ + { + "type": "string", + "enum": [ + "pay" + ] + }, + { + "type": "string", + "enum": [ + "receive" + ] + } + ] + } + }, + "required": [ + "amount", + "tokenIn", + "tokenOut", + "chainOut", + "recipient", + "depositor", + "mode" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/rhino/bridge/commit": { + "post": { + "tags": [ + "rhino" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "quoteId": { + "minLength": 1, + "type": "string" + }, + "isSwap": { + "type": "boolean" + }, + "isSameChainSwap": { + "type": "boolean" + } + }, + "required": [ + "quoteId", + "isSwap", + "isSameChainSwap" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/rhino/bridge/status/{bridgeId}": { + "get": { + "tags": [ + "rhino" + ], + "parameters": [ + { + "schema": { + "minLength": 1, + "type": "string" + }, + "in": "path", + "name": "bridgeId", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/rhino/bridge/chains": { + "get": { + "tags": [ + "rhino" + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/card": { + "get": { + "summary": "Get card info + waitlist state", + "tags": [ + "card" + ], + "description": "Returns the authenticated user's card flow access, eligibility, waitlist state, and skip-badge holdings.", + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "hasCardAccess": { + "type": "boolean" + }, + "isEligible": { + "type": "boolean" + }, + "eligibilityReason": { + "type": "string" + }, + "flowEarlyAccess": { + "type": "boolean" + }, + "isPublicLaunched": { + "type": "boolean" + }, + "waitlistJoinedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "waitlistPosition": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "waitlistReleasedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "skipBadges": { + "type": "array", + "items": { + "type": "string" + } + }, + "waitlistTotal": { + "type": "number" + }, + "admittedTotal": { + "type": "number" + } + }, + "required": [ + "hasCardAccess", + "isEligible", + "flowEarlyAccess", + "isPublicLaunched", + "waitlistJoinedAt", + "waitlistPosition", + "waitlistReleasedAt", + "skipBadges", + "waitlistTotal", + "admittedTotal" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/card/waitlist/join": { + "post": { + "summary": "Join the virtual-card waitlist", + "tags": [ + "card" + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "joinedAt": { + "type": "string" + }, + "position": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "joinedAt", + "position" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/card/waitlist/state": { + "get": { + "summary": "Get the user\u2019s current waitlist state", + "tags": [ + "card" + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "joinedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "position": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "releasedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "joinedAt", + "position", + "releasedAt" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/card/flow-early-access": { + "post": { + "summary": "Grant the user early access to the /card flow (via /shhhhh)", + "tags": [ + "card" + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "grantedAt": { + "type": "string" + } + }, + "required": [ + "grantedAt" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/sumsub/webhooks": { + "post": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/users/identity": { + "post": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/users/identity/resubmit": { + "post": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/rain/cards": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "termsAccepted": { + "type": "boolean" + }, + "serializedApproval": { + "minLength": 1, + "type": "string" + }, + "confirmedResidenceCountry": { + "minLength": 2, + "maxLength": 2, + "type": "string" + }, + "acceptedDocuments": { + "maxItems": 8, + "type": "array", + "items": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "version": { + "type": "string" + }, + "hash": { + "type": "string" + } + }, + "required": [ + "slug", + "version" + ] + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "pending" + ] + }, + "rainUserId": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "status", + "rainUserId", + "message" + ] + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "incomplete" + ] + }, + "missing": { + "type": "array", + "items": { + "type": "string" + } + }, + "questionnaireComplete": { + "type": "boolean" + }, + "sumsubAccessToken": { + "type": "string" + } + }, + "required": [ + "status", + "missing", + "questionnaireComplete", + "sumsubAccessToken" + ] + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "main-kyc-required" + ] + }, + "missingDocTypes": { + "type": "array", + "items": { + "type": "string" + } + }, + "sumsubAccessToken": { + "type": "string" + } + }, + "required": [ + "status", + "missingDocTypes", + "sumsubAccessToken" + ] + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "terms-required" + ] + }, + "isUsResident": { + "type": "boolean" + }, + "termsVersion": { + "type": "string" + } + }, + "required": [ + "status", + "isUsResident", + "termsVersion" + ] + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "country-confirmation-required" + ] + }, + "candidates": { + "type": "array", + "items": { + "type": "string" + } + }, + "evidence": { + "type": "object", + "properties": { + "addressCountry": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "idDocumentCountry": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "addressCountry", + "idDocumentCountry" + ] + } + }, + "required": [ + "status", + "candidates", + "evidence" + ] + }, + { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "rainUserId": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "status", + "message" + ] + } + ] + } + } + } + } + } + }, + "get": { + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "object", + "properties": { + "hasApplication": { + "type": "boolean" + }, + "railStatus": { + "type": "string" + }, + "applicationStatus": { + "type": "string" + }, + "rainUserId": { + "type": "string" + }, + "contractAddress": { + "type": "string" + }, + "coordinatorAddress": { + "type": "string" + } + }, + "required": [ + "hasApplication" + ] + }, + "balance": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "creditLimit": { + "type": "number" + }, + "pendingCharges": { + "type": "number" + }, + "postedCharges": { + "type": "number" + }, + "balanceDue": { + "type": "number" + }, + "spendingPower": { + "type": "number" + }, + "inTransitToCollateralCents": { + "type": "number" + } + }, + "required": [ + "creditLimit", + "pendingCharges", + "postedCharges", + "balanceDue", + "spendingPower", + "inTransitToCollateralCents" + ] + } + ] + }, + "balanceUnavailable": { + "type": "boolean" + }, + "cards": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "rainCardId": { + "type": "string" + }, + "last4": { + "type": "string" + }, + "expiryMonth": { + "type": "number" + }, + "expiryYear": { + "type": "number" + }, + "status": { + "type": "string" + }, + "network": { + "type": "string" + }, + "issuedAt": { + "type": "string" + }, + "hasWithdrawApproval": { + "type": "boolean" + } + }, + "required": [ + "id", + "rainCardId", + "last4", + "expiryMonth", + "expiryYear", + "status", + "network", + "issuedAt", + "hasWithdrawApproval" + ] + } + } + }, + "required": [ + "status", + "balance", + "balanceUnavailable", + "cards" + ] + } + } + } + } + } + } + }, + "/rain/cards/status": { + "get": { + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "hasApplication": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "applicationStatus": { + "type": "string" + }, + "rainUserId": { + "type": "string" + }, + "contractAddress": { + "type": "string" + } + }, + "required": [ + "hasApplication" + ] + } + } + } + } + } + } + }, + "/rain/cards/readiness": { + "get": { + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ready": { + "type": "boolean" + }, + "hasApplication": { + "type": "boolean" + }, + "readyAt": { + "type": "string" + } + }, + "required": [ + "ready", + "hasApplication" + ] + } + } + } + } + } + } + }, + "/rain/cards/{cardId}/activate": { + "post": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "cardId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/rain/cards/{cardId}/lock": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "verifiedWithdrawal": { + "type": "object", + "properties": { + "preparationId": { + "minLength": 1, + "type": "string" + }, + "amount": { + "minLength": 1, + "type": "string" + }, + "recipientAddress": { + "pattern": "^0x[0-9a-fA-F]{40}$", + "type": "string" + }, + "directTransfer": { + "type": "boolean" + }, + "adminSalt": { + "pattern": "^0x[0-9a-fA-F]{64}$", + "type": "string" + }, + "adminNonce": { + "minLength": 1, + "type": "string" + }, + "adminSignature": { + "pattern": "^0x[0-9a-fA-F]+$", + "type": "string" + }, + "executorSignature": { + "type": "string" + }, + "executorSalt": { + "type": "string" + }, + "expiresAt": { + "type": "number" + } + }, + "required": [ + "preparationId", + "amount", + "recipientAddress", + "directTransfer", + "adminSalt", + "adminNonce", + "adminSignature", + "executorSignature", + "executorSalt", + "expiresAt" + ] + } + } + } + } + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "cardId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/rain/cards/{cardId}/cancel": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "feedback": { + "maxLength": 2000, + "type": "string" + }, + "verifiedWithdrawal": { + "type": "object", + "properties": { + "preparationId": { + "minLength": 1, + "type": "string" + }, + "amount": { + "minLength": 1, + "type": "string" + }, + "recipientAddress": { + "pattern": "^0x[0-9a-fA-F]{40}$", + "type": "string" + }, + "directTransfer": { + "type": "boolean" + }, + "adminSalt": { + "pattern": "^0x[0-9a-fA-F]{64}$", + "type": "string" + }, + "adminNonce": { + "minLength": 1, + "type": "string" + }, + "adminSignature": { + "pattern": "^0x[0-9a-fA-F]+$", + "type": "string" + }, + "executorSignature": { + "type": "string" + }, + "executorSalt": { + "type": "string" + }, + "expiresAt": { + "type": "number" + } + }, + "required": [ + "preparationId", + "amount", + "recipientAddress", + "directTransfer", + "adminSalt", + "adminNonce", + "adminSignature", + "executorSignature", + "executorSalt", + "expiresAt" + ] + } + } + } + } + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "cardId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/rain/cards/{cardId}/cancellation-feedback": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "feedback": { + "minLength": 1, + "maxLength": 2000, + "type": "string" + } + }, + "required": [ + "feedback" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "cardId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/rain/cards/{cardId}": { + "patch": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cardLimit": { + "minimum": 0, + "type": "number" + }, + "limits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "amount": { + "minimum": 0, + "type": "number" + }, + "frequency": { + "anyOf": [ + { + "type": "string", + "enum": [ + "perAuthorization" + ] + }, + { + "type": "string", + "enum": [ + "per24HourPeriod" + ] + }, + { + "type": "string", + "enum": [ + "per30DayPeriod" + ] + }, + { + "type": "string", + "enum": [ + "perAllTime" + ] + } + ] + } + }, + "required": [ + "amount", + "frequency" + ] + } + }, + "autoBalanceEnabled": { + "type": "boolean" + } + } + } + } + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "cardId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "502": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/rain/cards/{cardId}/physical-waitlist": { + "post": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "cardId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "joinedAt": { + "type": "string" + }, + "position": { + "type": "number" + } + }, + "required": [ + "joinedAt", + "position" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + }, + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "cardId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "joinedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "position": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "joinedAt", + "position" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/rain/cards/{cardId}/limits": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "cardId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "limits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "amount": { + "type": "number" + }, + "frequency": { + "anyOf": [ + { + "type": "string", + "enum": [ + "perAuthorization" + ] + }, + { + "type": "string", + "enum": [ + "per24HourPeriod" + ] + }, + { + "type": "string", + "enum": [ + "per30DayPeriod" + ] + }, + { + "type": "string", + "enum": [ + "perAllTime" + ] + } + ] + } + }, + "required": [ + "amount", + "frequency" + ] + } + } + }, + "required": [ + "limits" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "502": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/rain/cards/balance": { + "get": { + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "creditLimit": { + "type": "number" + }, + "pendingCharges": { + "type": "number" + }, + "postedCharges": { + "type": "number" + }, + "balanceDue": { + "type": "number" + }, + "spendingPower": { + "type": "number" + } + }, + "required": [ + "creditLimit", + "pendingCharges", + "postedCharges", + "balanceDue", + "spendingPower" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/rain/cards/recover-funds/preview": { + "get": { + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "collateralProxy": { + "type": "string" + }, + "recipient": { + "type": "string" + }, + "amountWei": { + "type": "string" + }, + "amountCents": { + "type": "string" + }, + "dustWei": { + "type": "string" + }, + "autoBalanceEnabled": { + "type": "boolean" + }, + "hasRecoverableCard": { + "type": "boolean" + } + }, + "required": [ + "collateralProxy", + "recipient", + "amountWei", + "amountCents", + "dustWei", + "autoBalanceEnabled", + "hasRecoverableCard" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "502": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/rain/cards/recover-funds/prepare": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {} + } + } + } + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "preparationId": { + "type": "string" + }, + "coordinatorAddress": { + "type": "string" + }, + "collateralProxy": { + "type": "string" + }, + "adminAddress": { + "type": "string" + }, + "chainId": { + "type": "string" + }, + "tokenAddress": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "recipientAddress": { + "type": "string" + }, + "directTransfer": { + "type": "boolean" + }, + "adminSalt": { + "type": "string" + }, + "adminNonce": { + "type": "string" + }, + "executorSignature": { + "type": "string" + }, + "executorSalt": { + "type": "string" + }, + "expiresAt": { + "type": "number" + }, + "amountCents": { + "type": "string" + }, + "dustWei": { + "type": "string" + } + }, + "required": [ + "preparationId", + "coordinatorAddress", + "collateralProxy", + "adminAddress", + "chainId", + "tokenAddress", + "amount", + "recipientAddress", + "directTransfer", + "adminSalt", + "adminNonce", + "executorSignature", + "executorSalt", + "expiresAt", + "amountCents", + "dustWei" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "422": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "502": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/rain/cards/session-key-address": { + "get": { + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "address": { + "type": "string" + } + }, + "required": [ + "address" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/rain/cards/auto-balance/approve": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "serializedApproval": { + "minLength": 1, + "type": "string" + }, + "cardLimit": { + "minimum": 0, + "type": "number" + } + }, + "required": [ + "serializedApproval" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/rain/cards/{cardId}/details": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "cardId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pan": { + "type": "string" + }, + "cvv": { + "type": "string" + }, + "expiryMonth": { + "type": "number" + }, + "expiryYear": { + "type": "number" + }, + "last4": { + "type": "string" + }, + "network": { + "type": "string" + }, + "cardholderName": { + "type": "string" + } + }, + "required": [ + "pan", + "cvv", + "expiryMonth", + "expiryYear", + "last4", + "network" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "429": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/rain/cards/{cardId}/pin": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "cardId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pin": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "pin" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "429": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + }, + "put": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pin": { + "minLength": 4, + "maxLength": 4, + "type": "string" + } + }, + "required": [ + "pin" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "cardId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "429": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/rain/webhooks": { + "post": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/tokens/price": { + "get": { + "tags": [ + "tokens" + ], + "parameters": [ + { + "schema": { + "minLength": 1, + "type": "string" + }, + "in": "query", + "name": "address", + "required": true + }, + { + "schema": { + "minLength": 1, + "type": "string" + }, + "in": "query", + "name": "chainId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/tokens/wallet-portfolio": { + "get": { + "tags": [ + "tokens" + ], + "parameters": [ + { + "schema": { + "minLength": 1, + "type": "string" + }, + "in": "query", + "name": "address", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/dev/test-session": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "minLength": 5, + "description": "Email. Finds or creates the user.", + "type": "string" + }, + "userId": { + "type": "string" + }, + "country": { + "minLength": 2, + "maxLength": 3, + "type": "string" + }, + "kyc": { + "anyOf": [ + { + "type": "string", + "enum": [ + "verified" + ] + }, + { + "type": "string", + "enum": [ + "pending" + ] + }, + { + "type": "string", + "enum": [ + "rejected" + ] + }, + { + "type": "string", + "enum": [ + "none" + ] + } + ] + }, + "provider": { + "anyOf": [ + { + "type": "string", + "enum": [ + "sumsub" + ] + }, + { + "type": "string", + "enum": [ + "bridge" + ] + }, + { + "type": "string", + "enum": [ + "manteca" + ] + } + ] + }, + "username": { + "type": "string" + }, + "harnessLabel": { + "type": "string" + } + }, + "required": [ + "email" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "x-test-harness-secret", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "user": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "email": { + "type": "string" + }, + "username": { + "type": "string" + }, + "harnessLabel": { + "type": "string" + } + }, + "required": [ + "userId", + "email", + "harnessLabel" + ] + } + }, + "required": [ + "token", + "user" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/dev/seed-scenario": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "scenario": { + "anyOf": [ + { + "type": "string", + "enum": [ + "send-link-pending" + ] + }, + { + "type": "string", + "enum": [ + "send-link-claimed" + ] + }, + { + "type": "string", + "enum": [ + "request-pot-open" + ] + }, + { + "type": "string", + "enum": [ + "kyc-matrix" + ] + }, + { + "type": "string", + "enum": [ + "user-with-bank-accounts" + ] + }, + { + "type": "string", + "enum": [ + "withdraw-ready" + ] + }, + { + "type": "string", + "enum": [ + "manteca-qr-payment" + ] + }, + { + "type": "string", + "enum": [ + "multi-user-send" + ] + }, + { + "type": "string", + "enum": [ + "points-and-perks" + ] + } + ] + }, + "harnessLabel": { + "type": "string" + } + }, + "required": [ + "scenario" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "x-test-harness-secret", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "scenario": { + "type": "string" + }, + "data": {} + }, + "required": [ + "scenario", + "data" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/dev/reproduce": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "scenario": { + "description": "qa scenario name for logging", + "type": "string" + }, + "entry": { + "type": "object", + "properties": { + "route": { + "default": "/home", + "type": "string" + }, + "userId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "route" + ] + }, + "localStorage": { + "type": "object", + "additionalProperties": {} + }, + "stepSnapshot": { + "type": "object", + "properties": { + "userIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "tables": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + } + }, + "capturedAt": { + "type": "string" + } + }, + "required": [ + "userIds", + "tables" + ] + }, + "stepActions": { + "type": "array", + "items": {} + }, + "notes": { + "type": "string" + } + }, + "required": [ + "scenario", + "entry" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "x-test-harness-secret", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "token": { + "type": "string" + }, + "localStorage": { + "type": "object", + "additionalProperties": {} + }, + "restored": { + "type": "boolean" + } + }, + "required": [ + "url", + "sessionId", + "userId", + "token", + "localStorage" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/dev/reproduce/{sessionId}": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "scenario": { + "type": "string" + }, + "entry": { + "type": "object", + "properties": { + "route": { + "type": "string" + }, + "userId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "route", + "userId" + ] + }, + "localStorage": { + "type": "object", + "additionalProperties": {} + }, + "stepActions": { + "type": "array", + "items": {} + }, + "userId": { + "type": "string" + }, + "token": { + "type": "string" + } + }, + "required": [ + "scenario", + "entry", + "localStorage", + "userId", + "token" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/dev/poll": { + "post": { + "summary": "Run one polling cycle synchronously", + "tags": [ + "dev" + ], + "description": "Triggers runPollingCycle() on demand \u2014 used by the QA harness to observe provider state transitions without waiting for the 5-minute interval.", + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "ms": { + "type": "number" + } + }, + "required": [ + "ok", + "ms" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/register-address": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "address": { + "description": "0x-prefixed hex address (lowercased server-side)", + "type": "string" + }, + "userId": { + "description": "Peanut user_id to associate with this address", + "type": "string" + } + }, + "required": [ + "address", + "userId" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "registered": { + "type": "string" + } + }, + "required": [ + "ok", + "registered" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/reset-deposit-tracker": { + "post": { + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "registeredAddresses": { + "type": "number" + }, + "skippedInvalid": { + "type": "number" + }, + "lastProcessedBlock": { + "type": "string" + } + }, + "required": [ + "ok", + "registeredAddresses", + "skippedInvalid", + "lastProcessedBlock" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/reset-harness-data/{label}": { + "post": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "label", + "required": true, + "description": "harnessLabel value to wipe" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "deleted": { + "type": "number" + }, + "label": { + "type": "string" + } + }, + "required": [ + "ok", + "deleted", + "label" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/submit-to-providers": { + "post": { + "summary": "Run KYC provider submission synchronously for the given user/applicant", + "tags": [ + "dev" + ], + "description": "Drives Peanut's submitToProviders(userId, applicantId) synchronously, the same function the Sumsub status-processor calls after GREEN. Used by QA harness to test routing (AR user \u2192 Manteca, US user \u2192 Bridge) end-to-end.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "description": "Peanut user_id whose rails to submit", + "type": "string" + }, + "applicantId": { + "description": "Sumsub applicant id with approved docs + extracted data", + "type": "string" + } + }, + "required": [ + "userId", + "applicantId" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "ms": { + "type": "number" + } + }, + "required": [ + "ok", + "ms" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/trigger-sumsub-webhook": { + "post": { + "summary": "Synthesise a Sumsub webhook and run the status-processor", + "tags": [ + "dev" + ], + "description": "Invokes processSumsubKycUpdate({ applicantId, externalUserId, reviewAnswer, reviewStatus, source: 'webhook' }) \u2014 same entry point as the real /sumsub/webhooks route, minus HMAC. Used by the QA harness to exercise the full Sumsub \u2192 routing chain without real webhook delivery.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "applicantId": { + "description": "Sumsub applicant id", + "type": "string" + }, + "externalUserId": { + "description": "Peanut user_id (applicant.externalUserId)", + "type": "string" + }, + "reviewAnswer": { + "default": "GREEN", + "anyOf": [ + { + "type": "string", + "enum": [ + "GREEN" + ] + }, + { + "type": "string", + "enum": [ + "RED" + ] + }, + { + "type": "string", + "enum": [ + "ERROR" + ] + } + ] + }, + "reviewStatus": { + "description": "Sumsub reviewStatus (default: \"completed\")", + "type": "string" + }, + "levelName": { + "type": "string" + }, + "webhookType": { + "description": "e.g. \"applicantReviewed\"", + "type": "string" + } + }, + "required": [ + "applicantId", + "externalUserId" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "ms": { + "type": "number" + } + }, + "required": [ + "ok", + "ms" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/ledger/history": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "userId", + "required": true, + "description": "Peanut user_id (varchar)" + }, + { + "schema": { + "minimum": 1, + "maximum": 500, + "default": 50, + "type": "integer" + }, + "in": "query", + "name": "limit", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "userId": { + "type": "string" + }, + "count": { + "type": "number" + }, + "intents": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "status": { + "type": "string" + }, + "requestedAmount": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "requestedAsset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "settledAmount": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "settledAsset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "fxRate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string" + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "entryCount": { + "type": "number" + } + }, + "required": [ + "id", + "kind", + "provider", + "status", + "requestedAmount", + "requestedAsset", + "settledAmount", + "settledAsset", + "fxRate", + "createdAt", + "completedAt", + "entryCount" + ] + } + } + }, + "required": [ + "ok", + "userId", + "count", + "intents" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/cheats/invite-code": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "ensureUsername", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "inviteCode": { + "type": "string" + }, + "inviterUsername": { + "type": "string" + } + }, + "required": [ + "ok", + "inviteCode", + "inviterUsername" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/cheats/whoami": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "userId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "userId": { + "type": "string" + }, + "username": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasMantecaUserId": { + "type": "boolean" + }, + "hasBridgeCustomerId": { + "type": "boolean" + }, + "bridgeKycStatus": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "walletAddresses": { + "type": "array", + "items": { + "type": "string" + } + }, + "kycVerifications": { + "type": "array", + "items": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "status": { + "type": "string" + }, + "mantecaGeo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "provider", + "status", + "mantecaGeo" + ] + } + } + }, + "required": [ + "ok", + "userId", + "username", + "email", + "hasMantecaUserId", + "hasBridgeCustomerId", + "bridgeKycStatus", + "walletAddresses", + "kycVerifications" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/cheats/fund-sa": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "usdc": { + "description": "6-decimal USDC, default 10000000 = $10", + "type": "string" + }, + "ethWei": { + "type": "string" + } + }, + "required": [ + "userId" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "txHash": { + "type": "string" + }, + "saAddress": { + "type": "string" + }, + "usdcSent": { + "type": "string" + }, + "ethSent": { + "type": "string" + } + }, + "required": [ + "ok", + "txHash", + "saAddress", + "usdcSent", + "ethSent" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/cheats/sweep-perk-to-kernel": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "amount": { + "description": "6-decimal USDC base units; defaults to entire EOA balance", + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "txHash": { + "type": "string" + }, + "eoaAddress": { + "type": "string" + }, + "kernelAddress": { + "type": "string" + }, + "usdcSwept": { + "type": "string" + } + }, + "required": [ + "ok", + "txHash", + "eoaAddress", + "kernelAddress", + "usdcSwept" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/cheats/peanut-make-deposit": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "amount": { + "type": "string" + }, + "recipientAddress": { + "pattern": "^0x[0-9a-fA-F]{40}$", + "description": "Address that the recipient will withdraw to", + "type": "string" + } + }, + "required": [ + "recipientAddress" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "vaultAddress": { + "type": "string" + }, + "depositIdx": { + "type": "number" + }, + "password": { + "type": "string" + }, + "pubKey20": { + "type": "string" + }, + "claimParams": { + "type": "array", + "items": {} + }, + "makeDepositTxHash": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "ok", + "vaultAddress", + "depositIdx", + "password", + "pubKey20", + "claimParams", + "makeDepositTxHash", + "amount" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/crisp/webhooks": { + "post": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/dev/cheats/approve-kyc": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "provider": { + "anyOf": [ + { + "type": "string", + "enum": [ + "bridge" + ] + }, + { + "type": "string", + "enum": [ + "manteca" + ] + }, + { + "type": "string", + "enum": [ + "sumsub" + ] + } + ] + }, + "country": { + "description": "ISO-2 (AR/BR/US/GB/DE/MX)", + "type": "string" + } + }, + "required": [ + "userId", + "provider" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "updated": { + "type": "string" + }, + "details": {} + }, + "required": [ + "ok", + "updated", + "details" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/cheats/fund-rain-collateral": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "amountMicros": { + "type": "string" + } + }, + "required": [ + "userId" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "saAddress": { + "type": "string" + }, + "tokenAddress": { + "type": "string" + }, + "amountMicros": { + "type": "string" + }, + "txHash": { + "type": "string" + } + }, + "required": [ + "ok", + "saAddress", + "tokenAddress", + "amountMicros", + "txHash" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/cheats/grant-card-access": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "revoke": { + "type": "boolean" + } + }, + "required": [ + "userId" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "userId": { + "type": "string" + }, + "username": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "cardAccessGrantedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "ok", + "userId", + "username", + "cardAccessGrantedAt" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/cheats/grant-badge": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "code": { + "minLength": 1, + "maxLength": 50, + "pattern": "^[A-Z][A-Z0-9_]{0,49}$", + "description": "Stable badge code. Clients must tolerate codes added after SDK generation.", + "type": "string" + }, + "revoke": { + "type": "boolean" + } + }, + "required": [ + "userId", + "code" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "userId": { + "type": "string" + }, + "code": { + "type": "string" + }, + "granted": { + "type": "boolean" + }, + "earnedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "ok", + "userId", + "code", + "granted", + "earnedAt" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/cheats/reset-card": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "keepCardAccess": { + "description": "If true, leave card_access_granted_at intact; only clear the card rows.", + "type": "boolean" + } + }, + "required": [ + "userId" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "deletedCards": { + "type": "number" + }, + "cardAccessGrantedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "ok", + "deletedCards", + "cardAccessGrantedAt" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/cheats/simulate-bridge-deposit": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "amountUsd": { + "type": "string" + } + }, + "required": [ + "userId", + "amountUsd" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "virtualAccountId": { + "type": "string" + }, + "simulateResponse": {} + }, + "required": [ + "ok", + "virtualAccountId", + "simulateResponse" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/cheats/reset-user": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + } + }, + "required": [ + "userId" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "cleared": { + "type": "object", + "properties": { + "kycVerifications": { + "type": "number" + }, + "ledgerIntents": { + "type": "number" + } + }, + "required": [ + "kycVerifications", + "ledgerIntents" + ] + } + }, + "required": [ + "ok", + "cleared" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/cheats/complete-bridge-onramp": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "intentOrTransferId": { + "description": "Either a TransactionIntent.id or a Bridge transfer id.", + "type": "string" + }, + "exchangeRate": { + "type": "number" + }, + "developerFee": { + "type": "string" + } + }, + "required": [ + "intentOrTransferId" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "providerEventId": { + "type": "string" + }, + "intentId": { + "type": "string" + }, + "finalState": { + "type": "string" + }, + "statusChanged": { + "type": "boolean" + } + }, + "required": [ + "ok", + "providerEventId", + "finalState", + "statusChanged" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/cheats/complete-bridge-offramp": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "intentOrTransferId": { + "type": "string" + }, + "exchangeRate": { + "type": "number" + }, + "developerFee": { + "type": "string" + } + }, + "required": [ + "intentOrTransferId" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "providerEventId": { + "type": "string" + }, + "intentId": { + "type": "string" + }, + "finalState": { + "type": "string" + }, + "statusChanged": { + "type": "boolean" + } + }, + "required": [ + "ok", + "providerEventId", + "finalState", + "statusChanged" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/cheats/fail-bridge-transfer": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "intentOrTransferId": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "terminalState": { + "anyOf": [ + { + "type": "string", + "enum": [ + "error" + ] + }, + { + "type": "string", + "enum": [ + "returned" + ] + }, + { + "type": "string", + "enum": [ + "undeliverable" + ] + }, + { + "type": "string", + "enum": [ + "refunded" + ] + } + ] + } + }, + "required": [ + "intentOrTransferId", + "reason" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "providerEventId": { + "type": "string" + }, + "intentId": { + "type": "string" + }, + "finalState": { + "type": "string" + }, + "statusChanged": { + "type": "boolean" + } + }, + "required": [ + "ok", + "providerEventId", + "finalState", + "statusChanged" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/cheats/complete-rhino-deposit": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "depositAddress": { + "type": "string" + }, + "chainIn": { + "description": "Rhino's source-chain enum, e.g. BASE / ARBITRUM / BINANCE", + "type": "string" + }, + "token": { + "anyOf": [ + { + "type": "string", + "enum": [ + "USDC" + ] + }, + { + "type": "string", + "enum": [ + "USDT" + ] + } + ] + }, + "depositor": { + "description": "Source-chain wallet that funded the SDA", + "type": "string" + }, + "recipient": { + "description": "Destination peanut wallet address on Arbitrum", + "type": "string" + }, + "amountIn": { + "description": "Human-unit source amount, e.g. \"1.00\"", + "type": "string" + }, + "amountOut": { + "description": "Human-unit destination amount after FX + fees", + "type": "string" + }, + "amountOutUsd": { + "type": "number" + } + }, + "required": [ + "depositAddress", + "chainIn", + "token", + "depositor", + "recipient", + "amountIn", + "amountOut", + "amountOutUsd" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "providerEventId": { + "type": "string" + }, + "finalState": { + "type": "string" + }, + "statusChanged": { + "type": "boolean" + } + }, + "required": [ + "ok", + "providerEventId", + "finalState", + "statusChanged" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/cheats/complete-rhino-req-fulfilment": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "depositAddress": { + "type": "string" + }, + "chainIn": { + "description": "Rhino's source-chain enum, e.g. BASE / ARBITRUM / BINANCE", + "type": "string" + }, + "token": { + "anyOf": [ + { + "type": "string", + "enum": [ + "USDC" + ] + }, + { + "type": "string", + "enum": [ + "USDT" + ] + } + ] + }, + "depositor": { + "description": "Source-chain wallet that funded the SDA", + "type": "string" + }, + "recipient": { + "description": "Destination peanut wallet address on Arbitrum", + "type": "string" + }, + "amountIn": { + "description": "Human-unit source amount, e.g. \"1.00\"", + "type": "string" + }, + "amountOut": { + "description": "Human-unit destination amount after FX + fees", + "type": "string" + }, + "amountOutUsd": { + "type": "number" + } + }, + "required": [ + "depositAddress", + "chainIn", + "token", + "depositor", + "recipient", + "amountIn", + "amountOut", + "amountOutUsd" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "providerEventId": { + "type": "string" + }, + "intentId": { + "type": "string" + }, + "finalState": { + "type": "string" + }, + "statusChanged": { + "type": "boolean" + } + }, + "required": [ + "ok", + "providerEventId", + "finalState", + "statusChanged" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/cheats/complete-rhino-withdraw": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "depositAddress": { + "type": "string" + }, + "chainIn": { + "description": "Rhino's source-chain enum, e.g. BASE / ARBITRUM / BINANCE", + "type": "string" + }, + "token": { + "anyOf": [ + { + "type": "string", + "enum": [ + "USDC" + ] + }, + { + "type": "string", + "enum": [ + "USDT" + ] + } + ] + }, + "depositor": { + "description": "Source-chain wallet that funded the SDA", + "type": "string" + }, + "recipient": { + "description": "Destination peanut wallet address on Arbitrum", + "type": "string" + }, + "amountIn": { + "description": "Human-unit source amount, e.g. \"1.00\"", + "type": "string" + }, + "amountOut": { + "description": "Human-unit destination amount after FX + fees", + "type": "string" + }, + "amountOutUsd": { + "type": "number" + } + }, + "required": [ + "depositAddress", + "chainIn", + "token", + "depositor", + "recipient", + "amountIn", + "amountOut", + "amountOutUsd" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "providerEventId": { + "type": "string" + }, + "intentId": { + "type": "string" + }, + "finalState": { + "type": "string" + }, + "statusChanged": { + "type": "boolean" + } + }, + "required": [ + "ok", + "providerEventId", + "finalState", + "statusChanged" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/cheats/complete-rhino-claim-xchain": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "depositAddress": { + "type": "string" + }, + "chainIn": { + "description": "Rhino's source-chain enum, e.g. BASE / ARBITRUM / BINANCE", + "type": "string" + }, + "token": { + "anyOf": [ + { + "type": "string", + "enum": [ + "USDC" + ] + }, + { + "type": "string", + "enum": [ + "USDT" + ] + } + ] + }, + "depositor": { + "description": "Source-chain wallet that funded the SDA", + "type": "string" + }, + "recipient": { + "description": "Destination peanut wallet address on Arbitrum", + "type": "string" + }, + "amountIn": { + "description": "Human-unit source amount, e.g. \"1.00\"", + "type": "string" + }, + "amountOut": { + "description": "Human-unit destination amount after FX + fees", + "type": "string" + }, + "amountOutUsd": { + "type": "number" + } + }, + "required": [ + "depositAddress", + "chainIn", + "token", + "depositor", + "recipient", + "amountIn", + "amountOut", + "amountOutUsd" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "providerEventId": { + "type": "string" + }, + "finalState": { + "type": "string" + }, + "statusChanged": { + "type": "boolean" + } + }, + "required": [ + "ok", + "providerEventId", + "finalState", + "statusChanged" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/cheats/fail-rhino-transfer": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "depositAddress": { + "type": "string" + }, + "chainIn": { + "description": "Rhino's source-chain enum, e.g. BASE / ARBITRUM / BINANCE", + "type": "string" + }, + "token": { + "anyOf": [ + { + "type": "string", + "enum": [ + "USDC" + ] + }, + { + "type": "string", + "enum": [ + "USDT" + ] + } + ] + }, + "depositor": { + "description": "Source-chain wallet that funded the SDA", + "type": "string" + }, + "recipient": { + "description": "Destination peanut wallet address on Arbitrum", + "type": "string" + }, + "amountIn": { + "description": "Human-unit source amount, e.g. \"1.00\"", + "type": "string" + }, + "amountOut": { + "description": "Human-unit destination amount after FX + fees", + "type": "string" + }, + "amountOutUsd": { + "type": "number" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "depositAddress", + "chainIn", + "token", + "depositor", + "recipient", + "amountIn", + "amountOut", + "amountOutUsd" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "providerEventId": { + "type": "string" + }, + "intentId": { + "type": "string" + }, + "finalState": { + "type": "string" + }, + "statusChanged": { + "type": "boolean" + } + }, + "required": [ + "ok", + "providerEventId", + "finalState", + "statusChanged" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/cheats/auto-complete-pending": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + } + }, + "required": [ + "userId" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "completed": { + "type": "array", + "items": { + "type": "object", + "properties": { + "intentId": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "finalState": { + "type": "string" + } + }, + "required": [ + "intentId", + "kind", + "provider", + "finalState" + ] + } + }, + "skipped": { + "type": "array", + "items": { + "type": "object", + "properties": { + "intentId": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "intentId", + "kind", + "provider", + "reason" + ] + } + } + }, + "required": [ + "ok", + "completed", + "skipped" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/cheats/full-setup": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "usdcMicros": { + "description": "Defaults to 100 USDC.", + "type": "string" + }, + "bridgeDepositUsd": { + "description": "Defaults to 25.", + "type": "string" + }, + "bridgeCountry": { + "description": "ISO-2; defaults to US.", + "type": "string" + }, + "mantecaCountry": { + "description": "Defaults to AR.", + "type": "string" + }, + "sumsubCountry": { + "description": "Defaults to US.", + "type": "string" + } + }, + "required": [ + "userId" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "detail": {} + }, + "required": [ + "name", + "ok", + "detail" + ] + } + } + }, + "required": [ + "ok", + "steps" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/cheats/userid-by-username": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "username", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "userId", + "username" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "suggestions": { + "type": "array", + "items": { + "type": "string" + } + }, + "totalUsers": { + "type": "number" + } + }, + "required": [ + "error", + "suggestions", + "totalUsers" + ] + } + } + } + } + } + } + }, + "/dev/cheats/list-users": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "prefix", + "required": false + }, + { + "schema": { + "minimum": 1, + "maximum": 100, + "type": "number" + }, + "in": "query", + "name": "limit", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "users": { + "type": "array", + "items": { + "type": "string" + } + }, + "totalUsers": { + "type": "number" + } + }, + "required": [ + "users", + "totalUsers" + ] + } + } + } + } + } + } + }, + "/dev/cheats/mint-jwt": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + } + }, + "required": [ + "userId" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "userId": { + "type": "string" + } + }, + "required": [ + "token", + "userId" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "hint": { + "type": "string" + } + }, + "required": [ + "error", + "hint" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "hint": { + "type": "string" + } + }, + "required": [ + "error", + "hint" + ] + } + } + } + } + } + } + }, + "/dev/cheats/join-card-waitlist": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + } + }, + "required": [ + "userId" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "joinedAt": { + "type": "string" + } + }, + "required": [ + "ok", + "joinedAt" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/cheats/release-from-waitlist": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + } + }, + "required": [ + "userId" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "releasedAt": { + "type": "string" + } + }, + "required": [ + "ok", + "releasedAt" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/cheats/grant-flow-early-access": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + } + }, + "required": [ + "userId" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "grantedAt": { + "type": "string" + } + }, + "required": [ + "ok", + "grantedAt" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/cheats/clear-skip-celebration": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + } + }, + "required": [ + "userId" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "ok" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/cheats/hit-activation-threshold": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + } + }, + "required": [ + "userId" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "syntheticIntentId": { + "type": "string" + }, + "note": { + "type": "string" + } + }, + "required": [ + "ok", + "syntheticIntentId", + "note" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/dev/cheats/reset-card-waitlist": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + } + }, + "required": [ + "userId" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "deletedPerkUsages": { + "type": "number" + } + }, + "required": [ + "ok", + "deletedPerkUsages" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok", + "error" + ] + } + } + } + } + } + } + }, + "/ws/charges/{username}": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "username", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/fx/rates": { + "get": { + "description": "Public, indicative display-sell FX rates resolved relative to one base. unitsPerBase is quote-currency units per one base unit.", + "parameters": [ + { + "schema": { + "pattern": "^[A-Za-z]{3,4}$", + "type": "string" + }, + "in": "query", + "name": "base", + "required": false, + "description": "ISO-style currency code or supported four-letter internal ticker" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "type": "object", + "properties": { + "base": { + "pattern": "^[A-Z]{3,4}$", + "type": "string" + }, + "basis": { + "type": "string", + "enum": [ + "display_sell" + ] + }, + "indicative": { + "type": "boolean", + "enum": [ + true + ] + }, + "generatedAt": { + "format": "date-time", + "type": "string" + }, + "rates": { + "minItems": 1, + "maxItems": 512, + "type": "array", + "items": { + "additionalProperties": false, + "type": "object", + "properties": { + "code": { + "pattern": "^[A-Z]{3,4}$", + "type": "string" + }, + "unitsPerBase": { + "pattern": "^(?:0|[1-9]\\d*)(?:\\.\\d{1,18})?$", + "type": "string" + }, + "selection": { + "anyOf": [ + { + "type": "string", + "enum": [ + "identity" + ] + }, + { + "type": "string", + "enum": [ + "provider_pair" + ] + }, + { + "type": "string", + "enum": [ + "reference_pair" + ] + } + ] + }, + "baseSource": { + "anyOf": [ + { + "type": "string", + "enum": [ + "identity" + ] + }, + { + "type": "string", + "enum": [ + "bridge" + ] + }, + { + "type": "string", + "enum": [ + "manteca" + ] + }, + { + "type": "string", + "enum": [ + "reference" + ] + } + ] + }, + "quoteSource": { + "anyOf": [ + { + "type": "string", + "enum": [ + "identity" + ] + }, + { + "type": "string", + "enum": [ + "bridge" + ] + }, + { + "type": "string", + "enum": [ + "manteca" + ] + }, + { + "type": "string", + "enum": [ + "reference" + ] + } + ] + }, + "effectiveAt": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "code", + "unitsPerBase", + "selection", + "baseSource", + "quoteSource", + "effectiveAt" + ] + } + } + }, + "required": [ + "base", + "basis", + "indicative", + "generatedAt", + "rates" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "429": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "503": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/fx/rate": { + "get": { + "description": "Public indicative display-sell rate for one currency pair.", + "parameters": [ + { + "schema": { + "pattern": "^[A-Za-z]{3,4}$", + "type": "string" + }, + "in": "query", + "name": "from", + "required": true, + "description": "ISO-style currency code or supported four-letter internal ticker" + }, + { + "schema": { + "pattern": "^[A-Za-z]{3,4}$", + "type": "string" + }, + "in": "query", + "name": "to", + "required": true, + "description": "ISO-style currency code or supported four-letter internal ticker" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "type": "object", + "properties": { + "from": { + "pattern": "^[A-Z]{3,4}$", + "type": "string" + }, + "to": { + "pattern": "^[A-Z]{3,4}$", + "type": "string" + }, + "rate": { + "pattern": "^(?:0|[1-9]\\d*)(?:\\.\\d{1,18})?$", + "type": "string" + }, + "basis": { + "type": "string", + "enum": [ + "display_sell" + ] + }, + "indicative": { + "type": "boolean", + "enum": [ + true + ] + }, + "selection": { + "anyOf": [ + { + "type": "string", + "enum": [ + "identity" + ] + }, + { + "type": "string", + "enum": [ + "provider_pair" + ] + }, + { + "type": "string", + "enum": [ + "reference_pair" + ] + } + ] + }, + "fromSource": { + "anyOf": [ + { + "type": "string", + "enum": [ + "identity" + ] + }, + { + "type": "string", + "enum": [ + "bridge" + ] + }, + { + "type": "string", + "enum": [ + "manteca" + ] + }, + { + "type": "string", + "enum": [ + "reference" + ] + } + ] + }, + "toSource": { + "anyOf": [ + { + "type": "string", + "enum": [ + "identity" + ] + }, + { + "type": "string", + "enum": [ + "bridge" + ] + }, + { + "type": "string", + "enum": [ + "manteca" + ] + }, + { + "type": "string", + "enum": [ + "reference" + ] + } + ] + }, + "effectiveAt": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "generatedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "from", + "to", + "rate", + "basis", + "indicative", + "selection", + "fromSource", + "toSource", + "effectiveAt", + "generatedAt" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "429": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "503": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + } + }, + "servers": [ + { + "url": "http://localhost:5050" + } + ] } diff --git a/src/types/badge-assets.json b/src/types/badge-assets.json new file mode 100644 index 0000000000..d0ff02df17 --- /dev/null +++ b/src/types/badge-assets.json @@ -0,0 +1,52 @@ +{ + "assets": { + "ARBITRUM": "/badges/arbitrum.svg", + "ARBIVERSE_DEVCONNECT_BA_2025": "/badges/arbiverse_devconnect.svg", + "BETA_TESTER": "/badges/beta_tester.svg", + "BIGGEST_REQUEST_POT": "/badges/biggest_request_pot.svg", + "BIG_SPENDER_5K": "/badges/big_spender.svg", + "BUG_WHISPERER": "/badges/bug_whisperer.svg", + "CARD_ALPHA": "/badges/card_alpha.svg", + "CARD_CLOSED_BETA": "/badges/card_closed_beta.svg", + "CARD_FIRST_SWIPE": "/badges/happy_card.svg", + "CARD_PIONEER": "/badges/founding_pioneer.svg", + "CARD_SPENT_1K": "/badges/money_stack.svg", + "CERTIFIED_YAPPER": "/badges/certified_yapper.svg", + "DEVCONNECT_BA_2025": "/badges/devconnect_2025.svg", + "DOUBLE_DIGITS": "/badges/double_digits.svg", + "DUNBAR": "/badges/dunbar.svg", + "ETHFLORIPA_HUB": "/badges/ethfloripa_hub.svg", + "EVENT_ALUMNI": "/badges/event_alumni.svg", + "FESTA_JUNINA_2026": "/badges/festa_junina_2026.svg", + "FIRST_CRUMB": "/badges/first_crumb.svg", + "FIRST_INVITE": "/badges/first_invite.svg", + "FOUNDER_HOUSE": "/badges/founder_house.svg", + "FOUNDING_PIONEER": "/badges/founding_pioneer.svg", + "GIGA_YAPPER": "/badges/giga_yapper.svg", + "INFLUENCER_25": "/badges/influencer_25.svg", + "IRL_NOMADS": "/badges/irl_nomads.svg", + "MANICERO": "/badges/manicero.svg", + "MEGA_INFLUENCER": "/badges/invites_100.svg", + "MINI_INFLUENCER": "/badges/mini_influencer.svg", + "MOST_INVITES": "/badges/most_invites.svg", + "MOST_PAYMENTS_DEVCON": "/badges/most_payments.svg", + "MOST_RESTAURANTS_DEVCON": "/badges/foodie.svg", + "NAIJA": "/badges/naija.svg", + "NITA": "/badges/nita.svg", + "NOT_SO_SHHHH": "/badges/not_so_shhhh.svg", + "OFFRAMP_USER": "/badges/offramp_user.png", + "OG_2025_10_12": "/badges/og_v1.svg", + "PRODUCT_HUNT": "/badges/product_hunt.svg", + "PSYOPS_DIVISION": "/badges/psyops_division.svg", + "SECOND_INVITE": "/badges/second_invite.svg", + "SEEDLING_DEVCONNECT_BA_2025": "/badges/seedlings_devconnect.svg", + "SHHHHH": "/badges/shhhhh.svg", + "SUPPORT_SURVIVOR": "/badges/bug_whisperer.svg", + "TERERE": "/badges/terere.svg", + "THIRD_INVITE": "/badges/third_invite.svg", + "TOKEN_NATION_SP_2026": "/badges/token_nation_2026.svg", + "TOUCHED_GRASS": "/badges/touched_grass.svg", + "VERIFIED": "/badges/verified.svg", + "WAITLIST_SKIP": "/badges/skip_pass.svg" + } +} diff --git a/src/utils/__tests__/deferred-link.test.ts b/src/utils/__tests__/deferred-link.test.ts index 3120334670..9172ed2473 100644 --- a/src/utils/__tests__/deferred-link.test.ts +++ b/src/utils/__tests__/deferred-link.test.ts @@ -81,11 +81,51 @@ describe('buildDeferredPayload / parseDeferredPayload round-trip', () => { expect(parseDeferredPayload(payload)).toEqual({ lang: 'es-419', invite: 'abc123', - campaign: 'offramp', + badgeCampaigns: ['offramp'], dest: '/claim/XYZ?t=1', }) }) + it('round-trips repeated badge campaign identities without comma encoding or case loss', () => { + saveToCookie('campaignTag', ['Creator/Summer', 'Tag,With,Commas']) + + const payload = buildDeferredPayload('/home') + const payloadParams = new URLSearchParams(payload) + expect(payloadParams.getAll('badge_campaign')).toEqual(['Creator/Summer', 'Tag,With,Commas']) + expect(payloadParams.has('campaign')).toBe(false) + expect(parseDeferredPayload(payload)).toEqual({ + badgeCampaigns: ['Creator/Summer', 'Tag,With,Commas'], + dest: '/home', + }) + }) + + it('accepts old deferred campaign payloads while preferring the canonical namespace', () => { + expect(parseDeferredPayload('pnutdl=1&campaign=legacy-first&campaign=legacy-second')).toEqual({ + badgeCampaigns: ['legacy-first', 'legacy-second'], + }) + expect( + parseDeferredPayload( + 'pnutdl=1&utm_campaign=analytics&campaign=legacy&badge_campaign=canonical-first&badge_campaign=canonical-second' + ) + ).toEqual({ badgeCampaigns: ['canonical-first', 'canonical-second'] }) + }) + + it('keeps a marked historical UTM source-qualified for backend allowlist resolution', () => { + expect(parseDeferredPayload('pnutdl=1&utm_campaign=token-nation-2026')).toEqual({ + badgeCampaigns: ['utm:token-nation-2026'], + }) + }) + + it('round-trips the maximum-length source-qualified UTM identity through install handoff', () => { + const qualifiedUtmIdentity = `utm:${'x'.repeat(64)}` + saveToCookie('campaignTag', qualifiedUtmIdentity) + + expect(parseDeferredPayload(buildDeferredPayload('/home'))).toEqual({ + badgeCampaigns: [qualifiedUtmIdentity], + dest: '/home', + }) + }) + it('defaults dest to the current path + query and skips absent fields', () => { window.history.replaceState({}, '', '/claim/ABC?x=2') const parsed = parseDeferredPayload(buildDeferredPayload()) @@ -131,16 +171,16 @@ describe('parseDeferredPayload rejection', () => { }) describe('applyDeferredPayload', () => { - it('writes a SESSION inviteCode cookie (matching InvitesPage — a durable one locks existing users out of login) and a 30-day campaignTag', () => { + it('writes a SESSION inviteCode cookie and a 30-day campaign list', () => { applyDeferredPayload({ invite: 'abc', campaign: 'off' }) expect(mockSaveToCookie).toHaveBeenCalledWith('inviteCode', 'abc') expect(mockSaveToCookie).toHaveBeenCalledWith('campaignTag', 'off', 30) }) - it('normalizes invite and campaign like the existing writers', () => { + it('normalizes invite separately while preserving campaign spelling after outer trim', () => { applyDeferredPayload({ invite: ' @Alice ', campaign: ' OFFRAMP ' }) expect(mockSaveToCookie).toHaveBeenCalledWith('inviteCode', 'alice') - expect(mockSaveToCookie).toHaveBeenCalledWith('campaignTag', 'offramp', 30) + expect(mockSaveToCookie).toHaveBeenCalledWith('campaignTag', 'OFFRAMP', 30) }) it('strips the marketing locale prefix from dest (native export has no /{locale} routes)', () => { @@ -179,7 +219,9 @@ describe('applyDeferredPayload', () => { describe('restoreDeferredContext', () => { it('restores cookies, locale and dest from the android referrer, once', async () => { mockIsAndroidNative.mockReturnValue(true) - getReferrer.mockResolvedValue({ referrer: 'pnutdl=1&lang=es-419&invite=abc&campaign=off&dest=%2Fclaim%2FXYZ' }) + getReferrer.mockResolvedValue({ + referrer: 'pnutdl=1&lang=es-419&invite=abc&badge_campaign=off&dest=%2Fclaim%2FXYZ', + }) await expect(restoreDeferredContext()).resolves.toEqual({ dest: '/claim/XYZ', locale: 'es-419' }) expect(document.cookie).toContain('inviteCode=') diff --git a/src/utils/deferred-link.ts b/src/utils/deferred-link.ts index f185049b3d..ac9ac09f77 100644 --- a/src/utils/deferred-link.ts +++ b/src/utils/deferred-link.ts @@ -1,4 +1,4 @@ -// deferred deep linking: carry context (locale, invite code, campaign tag, +// deferred deep linking: carry context (locale, invite code, badge campaign identities, // destination path) from mobile web through the app-store install into the // native app. android rides the Play Install Referrer; iOS rides a clipboard // hand-off written on the store-bounce tap and read once on first launch. @@ -9,6 +9,13 @@ import { isValidLocale } from '@/i18n/config' import { isAndroidNative, isIOSNative } from './capacitor' import { getFromCookie, saveToCookie, sanitizeRedirectURL, toInviteCode } from './general.utils' import { deepLinkToNativePath } from './native-routes' +import { + BADGE_CAMPAIGN_QUERY_PARAM, + badgeCampaignIdentitiesFromDeferredSearchParams, + getPendingBadgeCampaigns, + parsePendingBadgeCampaigns, + queuePendingBadgeCampaigns, +} from '@/components/Invites/badge-campaign-context' // marker param distinguishing our payload from Play's organic referrer // (utm_source=google-play&utm_medium=organic) @@ -51,6 +58,9 @@ function persistRestoredLocale(locale: AppLocale): void { export interface DeferredPayload { lang?: string invite?: string + /** Current lossless multi-badge-campaign shape. */ + badgeCampaigns?: string[] + /** Legacy single-campaign payload accepted during app upgrade. */ campaign?: string dest?: string } @@ -90,7 +100,7 @@ function stripLocalePrefix(path: string): string { /** * builds the payload querystring from the current web context: locale from the - * /{locale}/ path prefix, invite/campaign from their existing cookies, dest + * /{locale}/ path prefix, invite/badge campaign from their existing cookies, dest * from the argument (defaults to the current path + query, locale stripped). */ export function buildDeferredPayload(dest?: string): string { @@ -102,8 +112,9 @@ export function buildDeferredPayload(dest?: string): string { const invite = getFromCookie('inviteCode') if (typeof invite === 'string' && invite) params.set('invite', invite) - const campaign = getFromCookie('campaignTag') - if (typeof campaign === 'string' && campaign) params.set('campaign', campaign) + for (const badgeCampaign of getPendingBadgeCampaigns()) { + params.append(BADGE_CAMPAIGN_QUERY_PARAM, badgeCampaign) + } const destination = dest ?? stripLocalePrefix(window.location.pathname) + window.location.search if (destination && destination !== '/') params.set('dest', destination) @@ -151,7 +162,13 @@ export function parseDeferredPayload(raw: string): DeferredPayload | null { } if (params.get(MARKER) !== '1') return null const pick = (key: string) => params.get(key) || undefined - return { lang: pick('lang'), invite: pick('invite'), campaign: pick('campaign'), dest: pick('dest') } + const badgeCampaigns = badgeCampaignIdentitiesFromDeferredSearchParams(params) + return { + lang: pick('lang'), + invite: pick('invite'), + badgeCampaigns: badgeCampaigns.length > 0 ? badgeCampaigns : undefined, + dest: pick('dest'), + } } // --------------------------------------------------------------------------- @@ -264,12 +281,11 @@ export function applyDeferredPayload(payload: DeferredPayload): RestoredContext // install→open→signup funnel and self-heals on app restart. const invite = payload.invite ? toInviteCode(payload.invite) : '' if (invite) saveToCookie('inviteCode', invite) - // campaignTag doesn't gate the setup step (removed in the #2346 fix), so - // it can safely outlive the session for badge attribution. lowercased - // like InvitesPage's utm_campaign handling — badge matching is - // case-sensitive. - const campaign = payload.campaign?.trim().toLowerCase() - if (campaign) saveToCookie('campaignTag', campaign, 30) + // Badge campaigns do not gate the setup step, so they can safely outlive the + // session for a network/configuration retry. Preserve the first trimmed + // spelling; backend resolution is case-insensitive. + const badgeCampaigns = parsePendingBadgeCampaigns(payload.badgeCampaigns ?? payload.campaign) + if (badgeCampaigns.length > 0) queuePendingBadgeCampaigns(badgeCampaigns, 30) const locale = payload.lang ? resolveAppLocale(payload.lang) : null if (locale) persistRestoredLocale(locale)