diff --git a/.gitignore b/.gitignore index 74de1c9..56e2450 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,7 @@ next-env.d.ts # The manifest is the batch's evidence, but it is reproducible from the script # and the cutoff, and it is recorded in D1 once the batch is frozen. dist/ + +test-results/ + +playwright-report/ diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 245ace0..72ca898 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -5,6 +5,13 @@ import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare"; void initOpenNextCloudflareForDev(); const nextConfig: NextConfig = { + async headers() { + return [ + { source: "/.well-known/:path*", headers: [{ key: "Access-Control-Allow-Origin", value: "*" }] }, + { source: "/.well-known/wallet-app.json", headers: [{ key: "Content-Type", value: "application/json; charset=utf-8" }] }, + { source: "/wallet-app/:path*", headers: [{ key: "Access-Control-Allow-Origin", value: "*" }] }, + ]; + }, reactCompiler: true, /** * How long one page may take to prerender, in seconds. diff --git a/apps/web/package.json b/apps/web/package.json index cb337e5..e12616d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,7 +20,7 @@ "@noble/hashes": "^1.8.0", "@scure/base": "^2.2.0", "@scure/btc-signer": "^2.2.0", - "@xcp/wallet-sdk": "github:XCP/wallet-sdk#5a056ce83dc3a999f9384a7cb625094471d19c91", + "@xcp/wallet-sdk": "github:XCP/wallet-sdk#089ca36869ab72440292c3064143e7dc0ea8e75c", "babel-plugin-react-compiler": "^1.0.0", "bignumber.js": "^9.3.1", "next": "16.3.4", diff --git a/apps/web/public/.well-known/wallet-app.json b/apps/web/public/.well-known/wallet-app.json new file mode 100644 index 0000000..5f79248 --- /dev/null +++ b/apps/web/public/.well-known/wallet-app.json @@ -0,0 +1,35 @@ +{ + "name": "xcp.fun", + "short_name": "xcp.fun", + "description": "Discover Counterparty token launches and community projects on Bitcoin.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#09090b", + "categories": [ + "finance", + "counterparty" + ], + "icons": [ + { + "src": "/wallet-app/icon.png", + "sizes": "1024x1024", + "type": "image/png", + "purpose": "any" + } + ], + "wallet": { + "version": "1", + "subtitle": "Counterparty launches", + "heroImageUrl": "/wallet-app/banner.png", + "splashImageUrl": "/wallet-app/splash.png", + "chains": { + "optional": [ + "bip122:000000000019d6689c085ae165831e93" + ] + }, + "capabilities": { + "optional": ["provider.bitcoin"] + } + } +} diff --git a/apps/web/public/_headers b/apps/web/public/_headers new file mode 100644 index 0000000..bd6bc92 --- /dev/null +++ b/apps/web/public/_headers @@ -0,0 +1,9 @@ + +/.well-known/* + Access-Control-Allow-Origin: * + +/.well-known/wallet-app.json + Content-Type: application/json; charset=utf-8 + +/wallet-app/* + Access-Control-Allow-Origin: * diff --git a/apps/web/public/wallet-app/banner.png b/apps/web/public/wallet-app/banner.png new file mode 100644 index 0000000..8bfe007 Binary files /dev/null and b/apps/web/public/wallet-app/banner.png differ diff --git a/apps/web/public/wallet-app/icon.png b/apps/web/public/wallet-app/icon.png new file mode 100644 index 0000000..9886dd9 Binary files /dev/null and b/apps/web/public/wallet-app/icon.png differ diff --git a/apps/web/public/wallet-app/splash.png b/apps/web/public/wallet-app/splash.png new file mode 100644 index 0000000..34d2ac8 Binary files /dev/null and b/apps/web/public/wallet-app/splash.png differ diff --git a/apps/web/src/app/[lang]/create/page.tsx b/apps/web/src/app/[lang]/create/page.tsx index bcfe8c2..7ac6171 100644 --- a/apps/web/src/app/[lang]/create/page.tsx +++ b/apps/web/src/app/[lang]/create/page.tsx @@ -226,9 +226,13 @@ export default function CreatePage() { const num = useNumbers(); const t = useT(); const usd = useFiat(); - const { address, status: walletStatus, signPsbt, signCommitAndReveal, broadcastTransaction, session } = useWallet(); + const { address, status: walletStatus, signPsbt, signCommitAndReveal, broadcastTransaction, session, wallet: selectedWallet } = useWallet(); const compose = useCompose(); const isTaproot = address?.startsWith("bc1p") ?? false; + const { data: canInscribe = false } = useSWR( + walletStatus === "connected" && isTaproot ? ["inscription-support", selectedWallet, address] : null, + async () => (await session.getWallet()?.features())?.marketplaceBundles?.includes("commit-and-reveal") ?? false, + ); const [inscribe, setInscribe] = useState(false); const [inscribeStep, setInscribeStep] = useState(null); const [inscribeTxid, setInscribeTxid] = useState(null); @@ -347,6 +351,7 @@ export default function CreatePage() { (nameCheck === "available" || nameCheck === "owned") && image !== null && !imageTooBigToInscribe && + (!inscribe || canInscribe) && isValidX(xProfile) && isValidTelegram(telegram) && scheduleValid && @@ -363,6 +368,9 @@ export default function CreatePage() { setSubmitting(true); setUploadError(null); try { + if (inscribe && !(await session.getWallet()?.features())?.marketplaceBundles?.includes("commit-and-reveal")) { + throw new Error(t("Use a wallet that supports image inscriptions, or launch with a hosted image.")); + } // 1. Host image + metadata JSON (write-once) so the on-chain // description URL resolves from the first block. const form = new FormData(); @@ -403,12 +411,6 @@ export default function CreatePage() { : baseFeeRate; if (inscribe && isTaproot && address) { - // 3a. Commit/reveal inscription: the image is inscribed and the - // inscription output burned; the description is the JSON URL, - // as on a hosted launch. A wallet that signs a commit and its - // reveal in one approval gets both before either is broadcast. - const features = await session.getWallet()?.features().catch(() => null); - const bundles = features?.marketplaceBundles ?? []; const { revealTxid } = await inscribeLaunch({ asset: name, lpAsset: generateLpAssetName(), @@ -420,7 +422,7 @@ export default function CreatePage() { description: upload.json_url, address, signPsbt, - signCommitAndReveal: bundles.includes("commit-and-reveal") ? signCommitAndReveal : undefined, + signCommitAndReveal, broadcast: broadcastTransaction, onStep: setInscribeStep, }); @@ -472,7 +474,7 @@ export default function CreatePage() { const finishLaunch = async () => { const record = pendingReveal; - if (!record || submitting || walletStatus !== "connected") return; + if (!record || submitting || walletStatus !== "connected" || (!record.revealRawTx && !canInscribe)) return; setSubmitting(true); setResuming(true); setUploadError(null); @@ -605,7 +607,10 @@ export default function CreatePage() { { asset: pendingReveal.asset }, )}

- + {!pendingReveal.revealRawTx && !canInscribe && ( +

{t("Connect a wallet that supports this inscription reveal to finish the launch.")}

+ )} + {resuming && inscribeStep && inscribeStep !== "done" ? t(INSCRIBE_STEP_LABELS[inscribeStep]) : t("Finish launching {asset}", { asset: pendingReveal.asset })} @@ -738,6 +743,7 @@ export default function CreatePage() { setInscribe(e.target.checked)} className="mt-0.5" /> @@ -748,6 +754,11 @@ export default function CreatePage() { "The image itself becomes the permanent on-chain description (commit + reveal, two signatures, higher fees scale with image size; the inscription is burned so it belongs to the asset forever). Max 385 KB. Taproot wallets only.", )} + {!canInscribe && ( + + {t("Use a wallet that supports image inscriptions, or launch with a hosted image.")} + + )} {imageTooBigToInscribe && ( {t( diff --git a/apps/web/src/components/header-wallet.tsx b/apps/web/src/components/header-wallet.tsx index c601778..eb84c40 100644 --- a/apps/web/src/components/header-wallet.tsx +++ b/apps/web/src/components/header-wallet.tsx @@ -19,7 +19,7 @@ import { useT } from "@/lib/i18n/client"; */ export function HeaderWallet() { const t = useT(); - const { status, address, proofStatus } = useWallet(); + const { status, address, proofStatus, connecting, connectError } = useWallet(); const menu = useWalletMenu(); const { accounts, switchAccount, disconnect } = menu; /** @@ -70,17 +70,20 @@ export function HeaderWallet() { // — and that read is synchronous from localStorage, so it lands within a // frame of hydration rather than after a round trip. return ( -
+
{!connected ? ( <> {installPrompt} + {connectError &&

{connectError}

} ) : ( diff --git a/apps/web/src/components/site-header.tsx b/apps/web/src/components/site-header.tsx index 1add7bf..73a6c5a 100644 --- a/apps/web/src/components/site-header.tsx +++ b/apps/web/src/components/site-header.tsx @@ -134,7 +134,7 @@ export function SiteHeader() {
{/* Telegram uses its icon on phones and in translated headers. Status chips take turns here to leave room for the menu. */} - + {queued ? : } @@ -148,7 +148,7 @@ export function SiteHeader() {
-
+
diff --git a/apps/web/src/lib/wallet/wallet-context.tsx b/apps/web/src/lib/wallet/wallet-context.tsx index 2006fd4..796dad8 100644 --- a/apps/web/src/lib/wallet/wallet-context.tsx +++ b/apps/web/src/lib/wallet/wallet-context.tsx @@ -7,6 +7,7 @@ import { WalletProvider as SdkWalletProvider, useWallet as useSdkWallet, } from "@xcp/wallet-sdk/react"; +import { CASATOOKAN_WALLET, discoverWallets, KNOWN_WALLETS } from "@xcp/wallet-sdk/web"; import "@/lib/wallet/sdk-config"; export type { ProofStatus }; @@ -28,9 +29,15 @@ const STATUS: Record = { reload_required: "disconnected", }; +const wallets = discoverWallets( + process.env.NEXT_PUBLIC_CASATOOKAN_ENABLED === "true" + ? [...KNOWN_WALLETS, CASATOOKAN_WALLET] + : KNOWN_WALLETS, +); + export function WalletProvider({ children }: { children: ReactNode }) { // Connect is login here: a wallet that proves nothing at connect (Horizon) is asked to sign the proof. - return {children}; + return {children}; } export function useWallet() { diff --git a/apps/web/src/locales/_source.json b/apps/web/src/locales/_source.json index 6d8fdb2..8a7020a 100644 --- a/apps/web/src/locales/_source.json +++ b/apps/web/src/locales/_source.json @@ -1355,6 +1355,9 @@ "components/connect-button.tsx", "components/header-wallet.tsx" ], + "Connect a wallet that supports this inscription reveal to finish the launch.": [ + "app/[lang]/create/page.tsx" + ], "Connect a wallet to chat.": [ "components/chat-panel.tsx" ], @@ -3866,6 +3869,9 @@ "Upload token image": [ "app/[lang]/create/page.tsx" ], + "Use a wallet that supports image inscriptions, or launch with a hosted image.": [ + "app/[lang]/create/page.tsx" + ], "Use at most {max} lines.": [ "components/chat-panel.tsx" ], diff --git a/apps/web/src/locales/es.json b/apps/web/src/locales/es.json index a3d00b4..034407c 100644 --- a/apps/web/src/locales/es.json +++ b/apps/web/src/locales/es.json @@ -1298,5 +1298,7 @@ "No expiry": "Sin vencimiento", "Burned": "Quemado", "Includes tokens sent to the burn address and explicitly destroyed tokens.": "Incluye los tokens enviados a la dirección de quema y los tokens destruidos explícitamente.", - "Destroyed": "Destruidos" + "Destroyed": "Destruidos", + "Use a wallet that supports image inscriptions, or launch with a hosted image.": "Usa una billetera compatible con inscripciones de imágenes o lanza con una imagen alojada.", + "Connect a wallet that supports this inscription reveal to finish the launch.": "Conecta una billetera compatible con la revelación de esta inscripción para finalizar el lanzamiento." } diff --git a/apps/web/src/locales/fr.json b/apps/web/src/locales/fr.json index 39b6e95..8080d79 100644 --- a/apps/web/src/locales/fr.json +++ b/apps/web/src/locales/fr.json @@ -1298,5 +1298,7 @@ "No expiry": "Sans expiration", "Burned": "Brûlés", "Includes tokens sent to the burn address and explicitly destroyed tokens.": "Comprend les tokens envoyés à l’adresse de destruction et les tokens explicitement détruits.", - "Destroyed": "Détruits" + "Destroyed": "Détruits", + "Use a wallet that supports image inscriptions, or launch with a hosted image.": "Utilisez un portefeuille compatible avec les inscriptions d’images, ou lancez avec une image hébergée.", + "Connect a wallet that supports this inscription reveal to finish the launch.": "Connectez un portefeuille compatible avec la révélation de cette inscription pour terminer le lancement." } diff --git a/apps/web/src/locales/ja.json b/apps/web/src/locales/ja.json index 6477d9d..9b37845 100644 --- a/apps/web/src/locales/ja.json +++ b/apps/web/src/locales/ja.json @@ -1298,5 +1298,7 @@ "No expiry": "有効期限なし", "Burned": "バーン済み", "Includes tokens sent to the burn address and explicitly destroyed tokens.": "バーンアドレスに送信されたトークンと、明示的に破棄されたトークンを含みます。", - "Destroyed": "破棄済み" + "Destroyed": "破棄済み", + "Use a wallet that supports image inscriptions, or launch with a hosted image.": "画像のインスクリプションに対応したウォレットを使うか、ホストされた画像でローンチしてください。", + "Connect a wallet that supports this inscription reveal to finish the launch.": "このインスクリプションのリビールに対応したウォレットを接続して、ローンチを完了してください。" } diff --git a/apps/web/src/locales/ko.json b/apps/web/src/locales/ko.json index 5374039..d1abcd2 100644 --- a/apps/web/src/locales/ko.json +++ b/apps/web/src/locales/ko.json @@ -1298,5 +1298,7 @@ "No expiry": "만료 없음", "Burned": "소각됨", "Includes tokens sent to the burn address and explicitly destroyed tokens.": "소각 주소로 전송된 토큰과 명시적으로 파기된 토큰을 포함합니다.", - "Destroyed": "파기됨" + "Destroyed": "파기됨", + "Use a wallet that supports image inscriptions, or launch with a hosted image.": "이미지 인스크립션을 지원하는 지갑을 사용하거나 호스팅된 이미지로 출시하세요.", + "Connect a wallet that supports this inscription reveal to finish the launch.": "출시를 완료하려면 이 인스크립션의 리빌을 지원하는 지갑을 연결하세요." } diff --git a/apps/web/src/locales/pt.json b/apps/web/src/locales/pt.json index e414123..abe1bd8 100644 --- a/apps/web/src/locales/pt.json +++ b/apps/web/src/locales/pt.json @@ -1298,5 +1298,7 @@ "No expiry": "Sem expiração", "Burned": "Queimados", "Includes tokens sent to the burn address and explicitly destroyed tokens.": "Inclui tokens enviados ao endereço de queima e tokens explicitamente destruídos.", - "Destroyed": "Destruídos" + "Destroyed": "Destruídos", + "Use a wallet that supports image inscriptions, or launch with a hosted image.": "Use uma carteira compatível com inscrições de imagens ou lance com uma imagem hospedada.", + "Connect a wallet that supports this inscription reveal to finish the launch.": "Conecte uma carteira compatível com a revelação desta inscrição para concluir o lançamento." } diff --git a/apps/web/src/locales/ru.json b/apps/web/src/locales/ru.json index 3f56063..aabe80e 100644 --- a/apps/web/src/locales/ru.json +++ b/apps/web/src/locales/ru.json @@ -1298,5 +1298,7 @@ "No expiry": "Бессрочно", "Burned": "Сожжено", "Includes tokens sent to the burn address and explicitly destroyed tokens.": "Включает токены, отправленные на адрес сжигания, и токены, уничтоженные явно.", - "Destroyed": "Уничтожено" + "Destroyed": "Уничтожено", + "Use a wallet that supports image inscriptions, or launch with a hosted image.": "Используйте кошелёк с поддержкой записи изображений в блокчейн или запустите токен с изображением на сервере.", + "Connect a wallet that supports this inscription reveal to finish the launch.": "Подключите кошелёк с поддержкой раскрытия этой записи, чтобы завершить запуск." } diff --git a/apps/web/src/locales/uk.json b/apps/web/src/locales/uk.json index fb5577e..2fdfe3d 100644 --- a/apps/web/src/locales/uk.json +++ b/apps/web/src/locales/uk.json @@ -1298,5 +1298,7 @@ "No expiry": "Безстроково", "Burned": "Спалено", "Includes tokens sent to the burn address and explicitly destroyed tokens.": "Включає токени, надіслані на адресу спалювання, і токени, знищені явно.", - "Destroyed": "Знищено" + "Destroyed": "Знищено", + "Use a wallet that supports image inscriptions, or launch with a hosted image.": "Використовуйте гаманець із підтримкою запису зображень у блокчейн або запустіть токен із зображенням на сервері.", + "Connect a wallet that supports this inscription reveal to finish the launch.": "Підключіть гаманець із підтримкою розкриття цього запису, щоб завершити запуск." } diff --git a/apps/web/src/locales/zh-hk.json b/apps/web/src/locales/zh-hk.json index 2e6ef82..a378487 100644 --- a/apps/web/src/locales/zh-hk.json +++ b/apps/web/src/locales/zh-hk.json @@ -1298,5 +1298,7 @@ "No expiry": "永不過期", "Burned": "已銷毀", "Includes tokens sent to the burn address and explicitly destroyed tokens.": "包括傳送到銷毀地址的代幣及透過銷毀交易銷毀的代幣。", - "Destroyed": "已銷毀" + "Destroyed": "已銷毀", + "Use a wallet that supports image inscriptions, or launch with a hosted image.": "請使用支援圖片銘文的錢包,或使用託管圖片發行。", + "Connect a wallet that supports this inscription reveal to finish the launch.": "請連接支援此銘文揭示交易的錢包以完成發行。" } diff --git a/apps/web/src/locales/zh-tw.json b/apps/web/src/locales/zh-tw.json index 188c616..0baded2 100644 --- a/apps/web/src/locales/zh-tw.json +++ b/apps/web/src/locales/zh-tw.json @@ -1298,5 +1298,7 @@ "No expiry": "永不過期", "Burned": "已銷毀", "Includes tokens sent to the burn address and explicitly destroyed tokens.": "包括傳送到銷毀地址的代幣和透過銷毀交易銷毀的代幣。", - "Destroyed": "已銷毀" + "Destroyed": "已銷毀", + "Use a wallet that supports image inscriptions, or launch with a hosted image.": "請使用支援圖片銘文的錢包,或使用託管圖片發行。", + "Connect a wallet that supports this inscription reveal to finish the launch.": "請連接支援此銘文揭示交易的錢包以完成發行。" } diff --git a/apps/web/src/locales/zh.json b/apps/web/src/locales/zh.json index 8c16fc9..8f3ba6d 100644 --- a/apps/web/src/locales/zh.json +++ b/apps/web/src/locales/zh.json @@ -1298,5 +1298,7 @@ "No expiry": "永不过期", "Burned": "已销毁", "Includes tokens sent to the burn address and explicitly destroyed tokens.": "包括发送到销毁地址的代币和通过销毁交易销毁的代币。", - "Destroyed": "已销毁" + "Destroyed": "已销毁", + "Use a wallet that supports image inscriptions, or launch with a hosted image.": "请使用支持图片铭文的钱包,或使用托管图片发行。", + "Connect a wallet that supports this inscription reveal to finish the launch.": "请连接支持此铭文揭示交易的钱包以完成发行。" } diff --git a/browser-tests/casatookan/connect.spec.mjs b/browser-tests/casatookan/connect.spec.mjs new file mode 100644 index 0000000..69611ca --- /dev/null +++ b/browser-tests/casatookan/connect.spec.mjs @@ -0,0 +1,221 @@ +import { expect, test } from '@playwright/test' +import { secp256k1 } from '@noble/curves/secp256k1' +import { legacyMessageHash } from '@xcp/wallet-sdk' + +const route = '/es/profile' +const enabled = process.env.CASATOOKAN_TEST_ENABLED !== 'false' +const address = 'bc1qvux25709r4uw6rzc8wyl7wwecjdhrx085hm5ty' +const publicKey = '0256b328b30c8bf5839e24058747879408bdb36241dc9c2e7c619faa12b2920967' +const taproot = 'bc1pfqarxerx6v0zh6zcj2f3a3np8jselxn8vgefdx95c0n328qpzg4ssw6kv5' +const unsupported = '3N722gUQ2YmphkTzm9nsgD8rkxh1xvukYC' +const header = page => page.getByRole('banner') +const connect = page => header(page).getByRole('button', { name: 'Conectar', exact: true }).first() +const account = page => header(page).getByText(/bc1q.*5ty/).filter({ visible: true }).first() + +async function bridge(page, options = {}) { + await page.exposeFunction('signCasaProof', message => { + const signature = secp256k1.sign(legacyMessageHash(message), new Uint8Array(32).fill(9)) + return Buffer.from([31 + signature.recovery, ...signature.toCompactRawBytes()]).toString('base64') + }) + await page.addInitScript(({ address, publicKey, taproot, unsupported, options }) => { + const listeners = new Map() + const state = { + requests: 0, signatures: 0, psbts: 0, reject: false, unsupported: false, + granted: sessionStorage.getItem('casa-grant') === 'yes', + revoke() { + state.granted = false + sessionStorage.removeItem('casa-grant') + for (const fn of listeners.get('accountsChanged') ?? []) fn([]) + }, + inject() { + Object.assign(window, { + ...(options.marker === false ? {} : { casaTookan: { isCasaTookan: true } }), + unisat: { + requestAccounts: async () => { + state.requests++ + if (state.reject) throw { code: 4001, message: 'User rejected connection' } + state.granted = true + sessionStorage.setItem('casa-grant', 'yes') + return [state.unsupported ? unsupported : options.taproot ? taproot : address] + }, + getAccounts: async () => state.granted ? [state.unsupported ? unsupported : options.taproot ? taproot : address] : [], + getPublicKey: async () => publicKey, + getNetwork: async () => 'livenet', + signMessage: async message => { state.signatures++; return window.signCasaProof(message) }, + signPsbt: async () => { state.psbts++; throw new Error('Unexpected transaction signing') }, + on: (event, fn) => { + if (!listeners.has(event)) listeners.set(event, new Set()) + listeners.get(event).add(fn) + }, + removeListener: (event, fn) => listeners.get(event)?.delete(fn), + }, + }) + }, + } + window.casaTest = state + if (!options.delay) state.inject() + if (options.competing) window.HorizonWalletProvider = { request: async () => { throw new Error('Wrong wallet selected') } } + }, { address, publicKey, taproot, unsupported, options }) + for (const host of ['api.xcp.io', 'api.xcp.fun', 'mempool.space', 'api.digirare.com']) { + await page.route('https://' + host + '/**', route => route.fulfill({ status: 503, json: { error: 'API disabled in wallet connection test' } })) + } +} + +async function disconnect(page) { + if (await page.getByRole('button', { name: 'Desconectar', exact: true }).count() === 0) await account(page).click() + await page.getByRole('button', { name: 'Desconectar', exact: true }).click() +} + +test('pilot remains absent when disabled', async ({ page }) => { + test.skip(enabled) + await bridge(page) + await page.goto(route) + await connect(page).click() + await expect(page.getByText('Casa Tookan', { exact: true })).toHaveCount(0) + expect(await page.evaluate(() => window.casaTest.requests)).toBe(0) +}) + +test.describe('enabled pilot', () => { + test.skip(!enabled) + + test('connect verifies the proof, restores silently, and disconnects', async ({ page }) => { + await bridge(page) + await page.goto(route) + await connect(page).click() + await expect(account(page)).toBeVisible() + await expect.poll(() => page.evaluate(() => window.casaTest.signatures)).toBe(1) + + await page.reload() + await expect(account(page)).toBeVisible() + expect(await page.evaluate(() => window.casaTest.requests)).toBe(0) + await disconnect(page) + await expect(connect(page)).toBeVisible() + await page.reload() + await expect(connect(page)).toBeVisible() + expect(await page.evaluate(() => window.casaTest.psbts)).toBe(0) + }) + + test('rejection and unsupported accounts show an error and permit retry', async ({ page }) => { + await bridge(page) + await page.goto(route) + await page.evaluate(() => { window.casaTest.reject = true }) + await connect(page).click() + await expect(header(page).getByRole('alert')).toBeVisible() + await page.evaluate(() => { window.casaTest.reject = false; window.casaTest.unsupported = true }) + await connect(page).click() + await expect(header(page).getByRole('alert')).toContainText('Legacy, Native SegWit or BIP-86 Taproot') + await page.evaluate(() => { window.casaTest.unsupported = false }) + await connect(page).click() + await expect(account(page)).toBeVisible() + await expect(header(page).getByRole('alert')).toHaveCount(0) + }) + + test('Taproot connects with its declared proof dialect', async ({ page }) => { + await bridge(page, { taproot: true }) + await page.goto(route) + await connect(page).click() + await expect(page.getByText(/bc1p.*kv5/).filter({ visible: true }).first()).toBeVisible() + }) + + test('revocation clears both live and restored connections', async ({ page }) => { + await bridge(page) + await page.goto(route) + await connect(page).click() + await expect(account(page)).toBeVisible() + await page.evaluate(() => window.casaTest.revoke()) + await expect(connect(page)).toBeVisible() + await connect(page).click() + await expect(account(page)).toBeVisible() + await page.evaluate(() => sessionStorage.removeItem('casa-grant')) + await page.reload() + await expect(connect(page)).toBeVisible() + expect(await page.evaluate(() => window.casaTest.requests)).toBe(0) + }) + + test('chooser selects Casa when another wallet is installed', async ({ page }) => { + await bridge(page, { competing: true }) + await page.goto(route) + await connect(page).click() + await page.getByRole('listitem').filter({ hasText: 'Casa Tookan' }).getByRole('button', { name: 'Conectar', exact: true }).click() + await expect(account(page)).toBeVisible() + }) + + test('mobile connect discovers a late bridge without an install detour', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await bridge(page, { delay: true }) + await page.goto(route) + await expect(connect(page)).toBeVisible() + await page.evaluate(() => window.casaTest.inject()) + await connect(page).click() + await expect(account(page)).toBeVisible() + await expect(page.getByText('Solo en computadora, por ahora', { exact: true })).toHaveCount(0) + expect(await header(page).evaluate(el => el.scrollWidth <= window.innerWidth)).toBe(true) + await page.screenshot({ path: test.info().outputPath('mobile-connected.png') }) + await disconnect(page) + await expect(connect(page)).toBeVisible() + }) + + test('Unisat alone is not detected as Casa', async ({ page }) => { + await bridge(page, { marker: false }) + await page.goto(route) + await connect(page).click() + expect(await page.evaluate(() => window.casaTest.requests)).toBe(0) + await expect(page.getByRole('listitem').filter({ hasText: 'Casa Tookan' }).getByRole('button', { name: 'Conectar', exact: true })).toHaveCount(0) + }) +}) + +test('app metadata and missing paths preserve JSON/404 and CORS', async ({ request }) => { + const manifest = await request.get('/.well-known/wallet-app.json') + expect(manifest.status()).toBe(200) + expect(manifest.headers()['access-control-allow-origin']).toBe('*') + expect((await manifest.json()).wallet.version).toBe('1') + const missing = await request.get('/.well-known/missing-casa-test.json') + expect(missing.status()).toBe(404) + expect(missing.headers()['access-control-allow-origin']).toBe('*') +}) + +for (const width of [1280, 390]) { + test(`Taproot inscription is unavailable before upload or signing at ${width}px`, async ({ page }) => { + test.skip(!enabled) + await page.setViewportSize({ width, height: 900 }) + await bridge(page, { taproot: true }) + let uploads = 0 + await page.route('**/api/launches', route => { + if (route.request().method() === 'POST') uploads++ + return route.fulfill({ status: 503, json: {} }) + }) + await page.goto('/es/create') + await connect(page).click() + await expect(page.getByText(/bc1p.*kv5/).filter({ visible: true }).first()).toBeVisible() + await expect(page.getByRole('checkbox')).toBeDisabled() + await expect(page.getByText('Usa una billetera compatible con inscripciones de imágenes o lanza con una imagen alojada.')).toBeVisible() + await expect(page.locator('input[type=file]')).toBeAttached() + expect(uploads).toBe(0) + expect(await page.evaluate(() => window.casaTest.psbts)).toBe(0) + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) + }) +} + +for (const signed of [false, true]) { + test(`keeps pending inscription and allows resume only for signed reveal (${signed})`, async ({ page }) => { + test.skip(!enabled) + await bridge(page, { taproot: true }) + const pending = { + version: 1, address: taproot, asset: 'TESTLAUNCH', startBlock: 999999, + revealScript: '51', tapInternalKey: '11'.repeat(32), commitTxid: '22'.repeat(32), + commitVout: 0, commitAmount: 10000, commitRawTx: '00', recipient: taproot, + feeRate: 1, createdAt: Date.now(), ...(signed ? { revealRawTx: '00' } : {}), + } + await page.addInitScript(record => localStorage.setItem('xcp.fun:pending-reveal:' + record.address, JSON.stringify(record)), pending) + await page.goto('/es/create') + await connect(page).click() + const finish = page.getByRole('button', { name: /TESTLAUNCH/ }) + if (signed) await expect(finish).toBeEnabled() + else { + await expect(finish).toBeDisabled() + await expect(page.getByText('Conecta una billetera compatible con la revelación de esta inscripción para finalizar el lanzamiento.')).toBeVisible() + } + expect(await page.evaluate(address => JSON.parse(localStorage.getItem('xcp.fun:pending-reveal:' + address)), taproot)).toEqual(pending) + expect(await page.evaluate(() => window.casaTest.psbts)).toBe(0) + }) +} diff --git a/docs/casatookan.md b/docs/casatookan.md new file mode 100644 index 0000000..ecf018e --- /dev/null +++ b/docs/casatookan.md @@ -0,0 +1,61 @@ +# Casa Tookan app + +The app manifest is served at `/.well-known/wallet-app.json`. The PNGs under `/wallet-app/` provide its icon, banner and splash. Paths are root-relative so they resolve outside `/.well-known/`. + +The manifest makes this site discoverable when a user saves it in Casa Tookan. It does not register the site in a curated directory or enable new signing methods. Bitcoin support is optional because browsing needs no wallet. The splash uses Casa Tookan's automatic readiness timeout. + +Wallet connection support is an opt-in pilot; see below. + +## Deployment check + +- Fetch the manifest without cookies and confirm JSON, HTTP 200 and `Access-Control-Allow-Origin: *`. +- Fetch a missing `/.well-known/` path and a missing `/wallet-app/` image: each must return 404 with CORS, not the homepage. +- Fetch all three images: 1024×1024 opaque icon, 1200×630 banner, 200×200 splash. +- Open the site in the native Casa Tookan browser, save it and reopen its app card. Check the title, artwork and splash dismissal. + +Headers are configured in both Next (SSR/404) and Cloudflare Static Assets (public files). Production framing and CSP policies are unchanged; the development iframe bridge is not installed. + +Format: https://docs.casatookan.com/apps.html + +## Wallet connection pilot + +Build with `NEXT_PUBLIC_CASATOOKAN_ENABLED=true` to include Casa Tookan in the SDK chooser. It is off by default. The SDK is pinned to `089ca36869ab72440292c3064143e7dc0ea8e75c` (XCP/wallet-sdk#13), including revoked-grant recovery and session-listener restart fixes. + +Mainnet Legacy, Native SegWit and BIP-86 Taproot accounts are supported. Ordinary compose flows use the SDK PSBT fallback. The adapter supports selected inputs, foreign signatures, non-ALL authorizations and one approval for up to 20 PSBTs. Every PSBT is limited to 64 KiB, with explicit prevouts. Nested SegWit, multisig and script-path commit/reveal remain unsupported. Casa does not validate application intents; existing site validation remains required. + +`npm run test:casatookan` exercises the actual site connection UI with a deterministic test bridge, including signed proofs where the site requests them, rejection/retry, unsupported accounts, chooser selection, mobile late injection, disconnect and revoked grants after reload. `CASATOOKAN_TEST_ENABLED=false npm run test:casatookan` checks the disabled pilot (set the environment variable in the syntax for your shell). Tests use an unfunded fixture and never broadcast. + +The SDK has separate live web-development-signer evidence in its PR. These site regressions are simulated-provider tests, not installed iOS/Android or funded-trade verification. Those checks remain required before enabling production. + +## Local QA — 2026-10-20 + +Seven enabled browser checks and two disabled-pilot checks passed on `/es/profile`. The desktop and mobile header connects, signs the proof, restores, rejects unsupported accounts and disconnects. Web typecheck, ESLint, numeric checks and all locale checks passed; 26 session/signature tests passed. All lockfiles pass `npm ci --dry-run --ignore-scripts`. + +The browser fixture signs deterministic test messages. Public API calls are blocked, so these runs do not prove live backend authentication or transaction composition. No production deployment or broadcast was performed. + +An earlier local development run self-redirected `/profile`. Production checks subsequently returned `/profile` 200 (English), `/en/profile` 308 to `/profile`, and `/es/profile` 200 (Spanish). No production routing defect is established and this PR does not change the locale proxy. + +## Expanded SDK support + +Pinned SDK `089ca36869ab72440292c3064143e7dc0ea8e75c` passed 481 unit tests and a real dev-wallet check of Taproot proof signing, a shared PSBT v2 transaction, a reusable authorization and a twenty-transaction batch. Evidence is in XCP/wallet-sdk#13. These are unfunded signing checks; installed-phone lifecycle and funded settlement remain unverified. + +Expanded site browser suite: 8 passing checks, including Taproot connection, restore/revocation and mobile discovery. The browser suite uses a deterministic injected bridge; separate live-wallet evidence is recorded in the SDK PR. + +Web typecheck, ESLint, numeric checks and all locale checks pass; the pinned lockfile passes an npm ci dry run. + +## Inscription boundary + +The real Casa signer rejects both script-path variants and tree-bearing key-path +recovery. The create page requires the wallet's `commit-and-reveal` capability +before allowing a new inscription, and rechecks before uploading metadata. +Casa users can launch normally with a hosted image. A selected inscription is +not silently converted into a hosted launch on wallet change. + +Pending launches remain stored. An unsigned reveal requires a capable wallet; +an already-signed reveal can still be resumed without asking Casa to sign it. + +Inscription follow-up validation: 34 safety/recovery unit tests and four new +browser tests pass against the updated SDK. Browser coverage includes desktop +and mobile restrictions before upload/signing, retained unsigned reveals, and +resuming already-signed reveals. Web typecheck, ESLint, numeric checks, all ten +locale checks, and the lockfile dry run pass. diff --git a/package-lock.json b/package-lock.json index 788374c..3fb55ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "packages/*" ], "devDependencies": { + "@noble/curves": "1.9.7", + "@playwright/test": "1.63.0", "@scure/bip32": "^2.3.0", "esbuild": "0.28.2", "happy-dom": "20.14.0", @@ -42,7 +44,7 @@ "@noble/hashes": "^1.8.0", "@scure/base": "^2.2.0", "@scure/btc-signer": "^2.2.0", - "@xcp/wallet-sdk": "github:XCP/wallet-sdk#5a056ce83dc3a999f9384a7cb625094471d19c91", + "@xcp/wallet-sdk": "github:XCP/wallet-sdk#089ca36869ab72440292c3064143e7dc0ea8e75c", "babel-plugin-react-compiler": "^1.0.0", "bignumber.js": "^9.3.1", "next": "16.3.4", @@ -3923,6 +3925,23 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -7809,8 +7828,7 @@ }, "node_modules/@xcp/wallet-sdk": { "version": "0.5.0", - "resolved": "git+ssh://git@github.com/XCP/wallet-sdk.git#5a056ce83dc3a999f9384a7cb625094471d19c91", - "integrity": "sha512-lDrfqg+AcJYaPt1QDEiYbMYVJRMIKoCvxnI5CB9/UNHGH21EiNzvh8X7vBgsdP+5JWAKVybl4jUOk+enOcuGBQ==", + "resolved": "git+ssh://git@github.com/XCP/wallet-sdk.git#089ca36869ab72440292c3064143e7dc0ea8e75c", "license": "MIT", "dependencies": { "@noble/curves": "^1.9.7", @@ -13285,6 +13303,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index cdde981..95da419 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,12 @@ "deploy:web": "npm run deploy --workspace apps/web", "deploy:api": "npm run deploy --workspace apps/api && npm run smoke", "migrate:api:local": "npm run migrate:local --workspace apps/api", - "migrate:api:remote": "npm run migrate:remote --workspace apps/api" + "migrate:api:remote": "npm run migrate:remote --workspace apps/api", + "test:casatookan": "playwright test -c playwright.casatookan.config.mjs" }, "devDependencies": { + "@noble/curves": "1.9.7", + "@playwright/test": "1.63.0", "@scure/bip32": "^2.3.0", "esbuild": "0.28.2", "happy-dom": "20.14.0", diff --git a/playwright.casatookan.config.mjs b/playwright.casatookan.config.mjs new file mode 100644 index 0000000..d8e164f --- /dev/null +++ b/playwright.casatookan.config.mjs @@ -0,0 +1,14 @@ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './browser-tests/casatookan', + timeout: 60_000, + workers: 1, + use: { baseURL: 'http://127.0.0.1:3411', trace: 'retain-on-failure', hasTouch: true }, + webServer: { + command: 'npm run dev --workspace apps/web -- --hostname 127.0.0.1 --port 3411', + url: 'http://127.0.0.1:3411/.well-known/wallet-app.json', + timeout: 180_000, + env: { NEXT_PUBLIC_CASATOOKAN_ENABLED: process.env.CASATOOKAN_TEST_ENABLED ?? 'true' }, + }, +})