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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@
"dev:fallback": "next dev",
"postinstall": "node scripts/copy-flags.mjs",
"copy-flags": "node scripts/copy-flags.mjs",
"prebuild": "node scripts/copy-flags.mjs",
"generate:legal-versions": "node scripts/generate-legal-versions.mjs",
"predev": "pnpm generate:legal-versions",
"predev:clean": "pnpm generate:legal-versions",
"predev:fallback": "pnpm generate:legal-versions",
"preanalyze": "pnpm generate:legal-versions",
"prebuild": "node scripts/copy-flags.mjs && pnpm generate:legal-versions",
"build": "next build --webpack",
"start": "next start",
"lint": "eslint .",
Expand Down
65 changes: 65 additions & 0 deletions scripts/generate-legal-versions.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Generates src/constants/legal-versions.generated.ts from the legal content
// submodule (src/content/content/legal/<slug>/en.md): version = frontmatter
// `last_updated`, hash = sha256 of the raw en.md bytes. The consent ledger
// (peanut-api `consent_records`) stores what the user was actually shown, so
// these values must come from the same files the legal pages render — never
// hand-edit the generated file. Runs on predev/prebuild; commit the output so
// CI and static analysis see it without running the generator.
import { createHash } from 'node:crypto'
import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const legalDir = join(root, 'src/content/content/legal')
const outFile = join(root, 'src/constants/legal-versions.generated.ts')

if (!existsSync(legalDir)) {
// Submodule not checked out (e.g. a bare CI job) — keep the committed file.
console.warn('[legal-versions] src/content submodule missing, keeping committed constants')
process.exit(0)
}

const entries = []
for (const slug of readdirSync(legalDir).sort()) {
const file = join(legalDir, slug, 'en.md')
if (!existsSync(file)) continue
const raw = readFileSync(file)
const match = /^last_updated:\s*["']?(\d{4}-\d{2}-\d{2})["']?\s*$/m.exec(raw.toString('utf8'))
if (!match) {
console.error(`[legal-versions] ${slug}/en.md has no last_updated frontmatter — refusing to generate`)
process.exit(1)
}
const hash = createHash('sha256').update(raw).digest('hex')
entries.push({ slug, version: match[1], hash })
}

// output must be byte-stable under prettier so predev never dirties the tree:
// one field per line (printWidth 120), keys quoted only when not identifiers
const key = (slug) => (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(slug) ? slug : `'${slug}'`)
const body = entries
.map(
({ slug, version, hash }) =>
` ${key(slug)}: {\n version: '${version}',\n hash: '${hash}',\n },`
)
.join('\n')

writeFileSync(
outFile,
`// AUTO-GENERATED by scripts/generate-legal-versions.mjs — do not edit.
// version = frontmatter last_updated; hash = sha256 of the raw en.md in the
// src/content submodule. Echoed to the consent ledger so the backend records
// exactly which document revision the user was shown.
export interface LegalDocumentVersion {
version: string
hash: string
}

export const LEGAL_DOCUMENT_VERSIONS = {
${body}
} as const

export type LegalDocumentSlug = keyof typeof LEGAL_DOCUMENT_VERSIONS
`
)
console.log(`[legal-versions] wrote ${entries.length} documents to src/constants/legal-versions.generated.ts`)
10 changes: 8 additions & 2 deletions src/app/(mobile-ui)/card/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import PageContainer from '@/components/0_Bruddle/PageContainer'
import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper'
import { initiateSelfHealResubmission } from '@/app/actions/sumsub'
import { rainApi, type ApplyForCardResponse } from '@/services/rain'
import { cardConsentDocuments } from '@/services/consent'
import { useGrantSessionKey } from '@/hooks/wallet/useGrantSessionKey'
import { useCapabilities } from '@/hooks/useCapabilities'
import { useModalsContext } from '@/context/ModalsContext'
Expand Down Expand Up @@ -346,7 +347,12 @@ const CardPage: FC = () => {
with_session_key: !!serializedApproval,
})
try {
const res = await rainApi.applyForCard({ termsAccepted, serializedApproval })
// Consent-ledger echo: on acceptance, send the exact documents
// CardTermsScreen displayed for this region (version + hash).
const acceptedDocuments = termsAccepted
? cardConsentDocuments(pendingTerms?.isUsResident ?? false)
: undefined
const res = await rainApi.applyForCard({ termsAccepted, serializedApproval, acceptedDocuments })
posthog.capture(ANALYTICS_EVENTS.CARD_APPLY_SUCCEEDED, { outcome: res.status })
if (res.status === 'incomplete' && 'sumsubAccessToken' in res) {
setSumsubToken(res.sumsubAccessToken)
Expand All @@ -361,7 +367,7 @@ const CardPage: FC = () => {
posthog.capture(ANALYTICS_EVENTS.CARD_APPLY_FAILED, { error_message: message })
}
},
[advanceFromApplyResponse]
[advanceFromApplyResponse, pendingTerms]
)

const handleAcceptTerms = useCallback(async () => {
Expand Down
3 changes: 3 additions & 0 deletions src/app/(mobile-ui)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client'

import GuestLoginModal from '@/components/Global/GuestLoginModal'
import ReConsentModal from '@/components/Global/ReConsentModal'
import PeanutLoading from '@/components/Global/PeanutLoading'
import TopNavbar from '@/components/Global/TopNavbar'
import WalletNavigation from '@/components/Global/WalletNavigation'
Expand Down Expand Up @@ -235,6 +236,8 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
{/* Modal */}
<GuestLoginModal />

<ReConsentModal />

<SupportDrawer />

<QRScannerOverlay />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ jest.mock('@/utils/withdraw.utils', () => ({

jest.mock('@/utils/general.utils', () => ({
isTxReverted: (receipt: { status?: string } | null) => receipt?.status === 'reverted',
printableAddress: (address: string) => `${address.slice(0, 6)}...${address.slice(-4)}`,
}))

jest.mock('@/utils/url.utils', () => ({
Expand Down
22 changes: 20 additions & 2 deletions src/app/(mobile-ui)/withdraw/crypto/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN, PEANUT_WALLET_TOKEN_DECIMALS
import { ROUTE_NOT_FOUND_ERROR } from '@/constants/general.consts'
import { useCrossChainTransfer } from '@/features/payments/shared/hooks/useCrossChainTransfer'
import { usePaymentRecorder } from '@/features/payments/shared/hooks/usePaymentRecorder'
import { isTxReverted } from '@/utils/general.utils'
import { isTxReverted, printableAddress } from '@/utils/general.utils'
import { appBaseUrl } from '@/utils/url.utils'
import { ErrorHandler } from '@/utils/friendly-error.utils'
import posthog from 'posthog-js'
Expand Down Expand Up @@ -621,7 +621,25 @@ export default function WithdrawCryptoPage() {
}}
preventClose={isPreparingReview}
title="Is this address compatible?"
description="Only send to address that support the selected network and token. Incorrect transfers may be lost."
description={
<div className="space-y-2">
<p>
Only send to an address that supports the selected network and token. Incorrect transfers
may be lost.
</p>
{/* Show the concrete destination so the user confirms a real
address, not an abstract warning — for ENS recipients this
is the first time the resolved address is visible. */}
{!!withdrawData?.address && (
<p>
You're sending to{' '}
<span className="font-mono font-medium text-n-1 dark:text-white">
{printableAddress(withdrawData.address)}
</span>
</p>
)}
</div>
}
icon="alert"
footer={
<div className="w-full">
Expand Down
9 changes: 7 additions & 2 deletions src/app/actions/ens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import { unstable_cache } from '@/utils/no-cache'
import { serverFetch } from '@/utils/api-fetch'

export const resolveEns = unstable_cache(
async (ensName: string): Promise<string | undefined> => {
const response = await serverFetch(`/ens/${encodeURIComponent(ensName)}`, {
async (ensName: string, chainId?: string): Promise<string | undefined> => {
// ENSIP-11: names can hold a distinct address per chain — resolve for
// the destination chain, not just mainnet. Backend falls back to the
// ETH (coinType 60) record when no chain-specific record exists.
const numericChainId = chainId ? Number(chainId) : undefined
const query = numericChainId && Number.isInteger(numericChainId) ? `?chainId=${numericChainId}` : ''
const response = await serverFetch(`/ens/${encodeURIComponent(ensName)}${query}`, {
method: 'GET',
})
if (response.status === 404) return undefined
Expand Down
13 changes: 11 additions & 2 deletions src/components/Global/GeneralRecipientInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ type GeneralRecipientInputProps = {
* Solana/Tron short-circuit the IBAN/US-routing/ENS branches — a base58
* address is the only valid input for them. */
addressFamily?: WithdrawAddressFamily
/** Destination chain id — ENS names resolve to their ENSIP-11 per-chain
* address record for this chain (mainnet record when omitted). */
chainId?: string
}

export type GeneralRecipientUpdate = {
Expand All @@ -41,6 +44,7 @@ const GeneralRecipientInput = ({
showInfoText = true,
isWithdrawal = false,
addressFamily = 'evm',
chainId,
}: GeneralRecipientInputProps) => {
const recipientType = useRef<RecipientType>('address')
const errorMessage = useRef('')
Expand Down Expand Up @@ -78,7 +82,12 @@ const GeneralRecipientInput = ({
isValid = true
} else {
try {
const validation = await validateAndResolveRecipient(trimmedInput, isWithdrawal)
const validation = await validateAndResolveRecipient(
trimmedInput,
isWithdrawal,
addressFamily,
chainId
)

isValid = true
resolvedAddress.current = validation.resolvedAddress
Expand All @@ -101,7 +110,7 @@ const GeneralRecipientInput = ({
return false
}
},
[isWithdrawal, addressFamily]
[isWithdrawal, addressFamily, chainId]
)

const onInputUpdate = useCallback(
Expand Down
Loading
Loading