diff --git a/AGENTS.md b/AGENTS.md index a7412ad..870515e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Build a secure, evidence-backed STRK20-funded Vickrey auction house for escrowed - Behavioral changes follow strict RED -> GREEN -> REFACTOR. - Use CodeGraph before investigation/edits and after source path changes. Never commit `.codegraph/`. - Preserve the approved privacy language: equal public cap collateral; actual bid sealed until reveal; no claim that a variable note amount remains encrypted after leaving the pool. -- The app never receives, persists, exports, or logs wallet private keys, viewing keys, bid secrets, claim secrets, or recovery plaintext. +- Wallet private keys, viewing keys, private notes, and session material never enter the app. For the approved sprint Wallet API demo, bid/claim credentials may exist only in active browser memory and a mandatory password-encrypted downloaded recovery bundle; plaintext is never persisted, exported, logged, or sent to a server. - `privacy_invoke` accepts only the configured STRK20 pool and accounts for collateral from observed balance delta. - Mainnet writes require fresh human approval with exact budget, addresses, and expected state. - `strk20.json` contains only real read-back-verified successful mainnet hashes/addresses. diff --git a/context/architecture.md b/context/architecture.md index 8248af3..2b780fc 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -1,16 +1,14 @@ # Architecture ```text -Browser - -> Starknet RPC (read-only auction/event/receipt state) - -CipherBid Vault CLI (user-operated; no browser bridge) - -> Starknet RPC (independent auction verification) - -> Privacy SDK / dedicated account (keys, note discovery, proving) - -> STRK20 pool -> CipherBidAuctionHouse.privacy_invoke (private uniform-cap bid ingress only) - -> CipherBidAuctionHouse.reveal / claim (direct public lifecycle calls) +Browser UI + -> get-starknet v6 discovery -> privacy-capable wallet -> WalletAccountV6 + -> Starknet RPC (auction/event/receipt reads and readback) + -> CipherBidAuctionHouse.create_auction / reveal_bid / settle_auction (standard wallet calls) + -> STRK20 pool -> CipherBidAuctionHouse.privacy_invoke (uniform-cap bid ingress and all monetary claims) -> ERC-721 contract (custody and winner delivery) - -> local OS-protected credential store + -> user-downloaded password-encrypted recovery bundle + -> Atomic Delivery Receipt (public RPC-derived verification) ``` -The Cairo auction house is authoritative for auction lifecycle and accounting. CipherBid Vault is the user-operated custodian of its dedicated execution account, STRK20 viewing key, private notes, bid nonce, and encrypted offline claim bundle. The browser owns only public read-only state and may display a public vault receipt; it never prepares or submits auction actions, or handles recovery plaintext or any credential secret. The user's normal privacy wallet remains separate and may fund the vault profile outside the CipherBid website. There is no CipherBid custodial backend or database. +The Cairo auction house is authoritative for ERC-721 custody, auction lifecycle, Vickrey settlement, and value accounting. A supported user wallet owns its private key, viewing key, private notes, proof generation, signing, and transaction submission. The CipherBid UI connects through the Wallet API boundary, displays the exact transaction target/cap/commitment, holds bidder and seller claim credentials in memory only, requires password-encrypted recovery export/import verification, and reads back every receipt and expected state transition before showing confirmation. There is no CipherBid custodial backend, database, local-vault daemon, or alternate sprint transaction path. The authoritative product boundary is `docs/evidence/winning-product-scope.md`. diff --git a/context/decisions/0002-cipherbid-vault-custody.md b/context/decisions/0002-cipherbid-vault-custody.md index ce57868..564aab0 100644 --- a/context/decisions/0002-cipherbid-vault-custody.md +++ b/context/decisions/0002-cipherbid-vault-custody.md @@ -1,32 +1,34 @@ -# Decision 0002: Keep auction credentials in a user-operated local vault +# Decision 0002: Use a direct Wallet API route for the sprint demo -**Status:** Accepted +**Status:** Accepted sprint authority; supersedes the local-vault MVP decision ## Decision -CipherBid adopts `cipherbid-vault`, a user-operated local CLI, as the sole owner of the vault-profile execution-account key, vault viewing key, vault-private notes, auction bid nonce, encrypted credential record, and offline claim private key. +For the sprint demo, CipherBid uses the selected STRK20 starter-kit Wallet API integration in the browser: get-starknet v6 discovery, `WalletAccountV6.connect`, Wallet API capability detection at `>= 0.10.3`, `strk20PrepareInvoke`, `strk20InvokeTransaction`, bounded receipt polling, and readback-confirmed state transitions. -The web app remains a public-data-only reader. It may display public descriptors and read-back receipts, but it never submits auction actions. It must not receive the sealed bid amount, bid nonce, claim private key, vault master key, execution-account key, viewing key, private notes, or recovery plaintext. +The privacy-capable wallet remains the sole owner of its wallet private key, viewing key, notes, proving, signing, and transaction submission. The CipherBid UI collects a bid amount, generates app-specific bidder and seller claim credentials in memory, builds the reviewed auction actions, and asks the connected wallet to sign/submit them. Reveal and every monetary claim use the matching in-memory credential or a user-imported encrypted recovery bundle. -The vault uses the maintained Privacy SDK for its own dedicated account to submit STRK20 equal-cap ingress, and that account also sends direct public reveal/claim calls. It never imports the user's normal wallet key or viewing key. Users fund the vault profile independently of the CipherBid website. +The UI must require a password-protected encrypted recovery download and successful local import verification before bidder submission and before seller auction creation. It may hold bid amount, bid nonce, bidder claim secret, or seller claim secret only for the active interaction; it must never persist plaintext to browser storage, cookies, URLs, logs, analytics, crash reports, clipboard, Git, or any server. -## Rationale +## Rationale and accepted tradeoff -The installed Wallet API 0.10.3 supports STRK20 action preparation and invocation, but no reviewed capability owns a dapp-specific bid/recovery credential end to end. Browser-side generation or encrypted storage would violate CipherBid's hard browser boundary, and browser-built actions can be modified before a wallet prompt. The Privacy SDK route would place a viewing key and a signing key into a browser runtime, which is unsuitable; it is acceptable only in the separately distributed, user-operated vault. +Demo-day requirements require a bidder to connect a supported wallet and place the bid from the CipherBid UI. The Wallet API keeps wallet keys, viewing keys, notes, proving, and submission in the wallet while providing the STRK20 integration route already extracted from the starter kit. -A user-operated companion makes the new trust assumption explicit, local, and auditable without introducing a CipherBid cloud custodian or backend. +The browser must necessarily receive the user-entered bid amount and app-specific bid/claim credentials long enough to construct the commitment and recovery bundle. This is a deliberate, documented relaxation of the former hard browser boundary. A compromised browser can alter a dapp-built action before the wallet prompt; CipherBid mitigates but cannot eliminate that risk with independent UI summaries, exact target/cap/commitment checks, explicit wallet confirmation, no plaintext persistence, required recovery export, and contract-side validation. ## Consequences -- Bid creation remains disabled until the vault and an authenticated onchain claim path exist. -- The auction protocol will replace `claimSecret` with an offline claim key and onchain public-key claim authorization. -- The vault is Windows-first in the MVP and must use maintained OS-protection and encrypted-backup libraries rather than custom cryptography. -- No localhost daemon, native browser bridge, cloud backup, telemetry, or automatic clipboard export is allowed. -- Public reveal/claim submissions from the dedicated execution account may be linkable to each other. Product copy must disclose this limitation. -- The vault profile is local user custody with account-key compromise, offline-claim-bundle recovery, funding, and rotation responsibilities; it is not a lightweight browser helper. +- Bid creation and seller auction creation are enabled only after successful wallet/network checks and the required encrypted recovery export/import round trip. +- The auction protocol uses `claim_handle = Poseidon("CIPHERBID_CLAIM_V1", claim_secret)` and requires the matching one-time secret for bidder and seller monetary claims. +- The app never asks for, receives, persists, or exports a wallet private key, seed phrase, viewing key, private note, or wallet session material. +- No localhost daemon, browser extension bridge, native-messaging integration, cloud backup, server credential storage, telemetry of private payloads, or automatic clipboard export is allowed. +- Seller creation and direct reveal activity link to the connected wallet account and must be disclosed. Bid ingress and monetary claims rely on STRK20 relayed submission, while timing and public amounts remain observable. +- A separate local-vault route remains a post-sprint hardening option; it is not a second sensitive transaction path in this sprint. -## Reference +## Required proof before mainnet -The detailed protocol, storage boundary, user flows, contract revision, threats, and acceptance criteria are in: - -`docs/superpowers/specs/2026-08-24-cipherbid-vault-custody-design.md` +1. Supported-wallet discovery and `WalletAccountV6` connection work in a clean browser. +2. Capability detection uses `supportedWalletApi()` and never probes private balances merely to feature-detect. +3. Every STRK20 path constructs the exact reviewed action sequence, preserves literal protocol placeholders, prepares before submit, and distinguishes rejected/submitted/confirmed/reverted/timeout states. +4. Tests prove no plaintext bid/claim credential enters persistent browser storage, logs, analytics, URLs, errors, or public receipts. +5. A real Sepolia then mainnet two-bidder lifecycle—including NFT custody and all monetary claims—is read back from chain before public claims are made. diff --git a/docs/superpowers/specs/2026-08-24-auction-bid-page-design.md b/docs/superpowers/specs/2026-08-24-auction-bid-page-design.md index ecd7dde..5cec17f 100644 --- a/docs/superpowers/specs/2026-08-24-auction-bid-page-design.md +++ b/docs/superpowers/specs/2026-08-24-auction-bid-page-design.md @@ -2,9 +2,11 @@ **Date:** 2026-08-24 +**Status:** Superseded for interactive behavior by the 2026-08-25 direct Wallet API submission plan; its original visual/accessibility constraints remain reusable. + **Route:** `/auctions/[auctionId]` -**Scope:** Presentational design only; no auction reads, wallet actions, bid secrets, transaction preparation, or submission +**Scope:** Historical presentational baseline. The current submission plan adds verified chain reads, Starter-Kit wallet connection, and real bid/reveal/claim controls through a separately tested implementation slice. ## Objective diff --git a/docs/superpowers/specs/2026-08-24-cipherbid-vault-custody-design.md b/docs/superpowers/specs/2026-08-24-cipherbid-vault-custody-design.md index d942efd..bcf0835 100644 --- a/docs/superpowers/specs/2026-08-24-cipherbid-vault-custody-design.md +++ b/docs/superpowers/specs/2026-08-24-cipherbid-vault-custody-design.md @@ -1,15 +1,18 @@ # CipherBid Vault — Bid-Credential Custody Design -**Status:** Approved architecture; documentation-only slice. -**Scope:** Resolve custody for the Vickrey bid nonce and claim authority. This does not implement a vault binary, modify Cairo, enable bidding, or submit a transaction. +**Status:** Superseded for the sprint demo by `context/decisions/0002-cipherbid-vault-custody.md` on 2026-08-25; retained as a post-sprint hardening alternative. +**Scope:** Historical local-vault custody proposal. It does not authorize a second sensitive transaction route during the direct Wallet API sprint implementation. -## Decision +> [!CAUTION] +> **Historical and non-normative. Do not implement any requirement below for the sprint product.** In particular, `cipherbid-vault`, claim signing/public keys, claim signatures, dedicated execution accounts, DPAPI profiles, and CLI-only recovery are superseded. The current authority is `docs/evidence/winning-product-scope.md`, Decision 0002, and the reviewed claim-secret fixtures. -CipherBid will use a **user-operated local CLI**, `cipherbid-vault`, to own and use all auction-specific secret material outside the browser application. The web dapp stays a public-data-only reader and status surface. +## Historical decision (superseded) -The vault is an explicitly user-operated **dedicated privacy-account companion**. It owns a separate Starknet execution account, a separate STRK20 viewing key, and private notes sent to that account. It retains the auction bid nonce only in the active profile so it can reveal. Each bid has a separate claim signing key retained only in a mandatory encrypted offline claim bundle. The vault creates, protects, recovers, and uses these values locally to submit bid ingress, reveal, and claim operations itself. It never imports the user's normal wallet key or viewing key. The browser does not submit auction actions through the Wallet API. +The superseded proposal would have used a **user-operated local CLI**, `cipherbid-vault`, to own and use all auction-specific secret material outside the browser application, leaving the web dapp as a public-data-only reader and status surface. -## Why this boundary +That historical vault was specified as an explicitly user-operated **dedicated privacy-account companion**. It would have owned a separate Starknet execution account, a separate STRK20 viewing key, and private notes sent to that account. It retained the auction bid nonce only in the active profile so it could reveal. Each bid used a separate claim signing key retained only in a mandatory encrypted offline claim bundle. The vault would have created, protected, recovered, and used these values locally to submit bid ingress, reveal, and claim operations itself. It never imported the user's normal wallet key or viewing key. Under that superseded route, the browser did not submit auction actions through the Wallet API. + +## Historical rationale The installed Wallet API surface (`@starknet-io/types-js` 0.10.3) provides `wallet_strk20PrepareInvoke` and `wallet_strk20InvokeTransaction` over dapp-provided action arrays. It has no reviewed method to create, retain, or later use an app-specific bid nonce or claim credential. Any browser-built action can also be altered by a compromised website before the wallet prompt, so a public envelope cannot safely authorize equal-cap collateral ingress by itself. @@ -17,14 +20,14 @@ Using the low-level Privacy SDK in the browser would require the app to manage a ## Actors and trust boundaries -| Actor | May hold | Must never hold | -| --- | --- | --- | -| CipherBid web app | public auction descriptor, public receipt/status, transaction hashes | bid amount, bid nonce, claim private key, vault master key, any viewing key, recovery plaintext | -| User's normal privacy wallet | its own signing key, viewing key, and notes | CipherBid vault key material or vault-private notes | -| CipherBid Vault CLI | vault execution-account key, vault viewing key, vault-private notes, bid amount, bid nonce, asset recipient, public claim key, encrypted records; offline claim private key only in an ephemeral claim process after local bundle decryption | user wallet private key, user wallet viewing key, browser session data | -| Offline claim bundle | claim private key and encrypted profile/credential recovery material | browser session data, CipherBid servers, telemetry systems | -| CipherBid auction contract | commitment, claim public key/handle, public lifecycle state, reveal data, used-claim state | unrevealed bid amount, bid nonce before reveal, claim private key | -| RPC / optional future relay | public signed transaction and public reveal/claim calldata | vault records, wallet viewing key, pre-reveal bid nonce | +| Actor | May hold | Must never hold | +| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| CipherBid web app | public auction descriptor, public receipt/status, transaction hashes | bid amount, bid nonce, claim private key, vault master key, any viewing key, recovery plaintext | +| User's normal privacy wallet | its own signing key, viewing key, and notes | CipherBid vault key material or vault-private notes | +| CipherBid Vault CLI | vault execution-account key, vault viewing key, vault-private notes, bid amount, bid nonce, asset recipient, public claim key, encrypted records; offline claim private key only in an ephemeral claim process after local bundle decryption | user wallet private key, user wallet viewing key, browser session data | +| Offline claim bundle | claim private key and encrypted profile/credential recovery material | browser session data, CipherBid servers, telemetry systems | +| CipherBid auction contract | commitment, claim public key/handle, public lifecycle state, reveal data, used-claim state | unrevealed bid amount, bid nonce before reveal, claim private key | +| RPC / optional future relay | public signed transaction and public reveal/claim calldata | vault records, wallet viewing key, pre-reveal bid nonce | The vault's dedicated account is distinct from the user's normal wallet. It signs STRK20 actions with the Privacy SDK and public reveal/claim calls. Its address is public whenever it performs a direct public lifecycle call and may correlate activity; CipherBid must disclose that limitation. Private notes held by the vault are user-controlled local custody, not browser or CipherBid-server custody. @@ -160,15 +163,15 @@ TypeScript, Cairo, and vault implementation must freeze cross-language vectors f ## Threat model and limits -| Threat | Required mitigation | Residual risk | -| --- | --- | --- | +| Threat | Required mitigation | Residual risk | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | Compromised website or malicious browser extension | no vault server/API and no browser-submitted auction action; vault independently verifies descriptor and submits ingress itself | attacker can still trick the user into choosing a different public descriptor; vault confirmation must make target clear | -| Malicious RPC | check chain ID, deployment class hash, full auction config; use two RPCs in high-assurance mode | correlated RPC failure can still mislead the vault | -| Lost device/profile | mandatory encrypted offline claim bundle includes the vault profile and credentials | loss of both device/profile and claim bundle loses control of vault notes and ability to reveal/claim | -| Current-user malware | OS-bound storage, signed binary, least privilege | malware running as the unlocked current user is out of scope | -| Execution-account correlation | use a dedicated profile; disclose direct ingress/reveal/claim account visibility in product UX | calls from the same account may be linkable | -| Insufficient gas or private balance | mandatory funding plan, summed lifecycle-fee reserve, live pool-fee readback, maturity/deadline checks, and pre-submit rechecks | fee spikes, RPC failure, proving failure, and network outage can still prevent timely submission | -| Secret leakage | allowlist diagnostics; redaction tests; no secret-bearing browser transport | user can deliberately expose secrets through unsafe manual handling | +| Malicious RPC | check chain ID, deployment class hash, full auction config; use two RPCs in high-assurance mode | correlated RPC failure can still mislead the vault | +| Lost device/profile | mandatory encrypted offline claim bundle includes the vault profile and credentials | loss of both device/profile and claim bundle loses control of vault notes and ability to reveal/claim | +| Current-user malware | OS-bound storage, signed binary, least privilege | malware running as the unlocked current user is out of scope | +| Execution-account correlation | use a dedicated profile; disclose direct ingress/reveal/claim account visibility in product UX | calls from the same account may be linkable | +| Insufficient gas or private balance | mandatory funding plan, summed lifecycle-fee reserve, live pool-fee readback, maturity/deadline checks, and pre-submit rechecks | fee spikes, RPC failure, proving failure, and network outage can still prevent timely submission | +| Secret leakage | allowlist diagnostics; redaction tests; no secret-bearing browser transport | user can deliberately expose secrets through unsafe manual handling | ## Acceptance criteria before enabling real bidding diff --git a/docs/superpowers/specs/2026-08-24-premium-protocol-console-design.md b/docs/superpowers/specs/2026-08-24-premium-protocol-console-design.md index ec9e915..2c5adf3 100644 --- a/docs/superpowers/specs/2026-08-24-premium-protocol-console-design.md +++ b/docs/superpowers/specs/2026-08-24-premium-protocol-console-design.md @@ -1,10 +1,10 @@ # CipherBid Premium Protocol Console Design -**Status:** Approved by user on 2026-08-24 +**Status:** Superseded for interactive behavior by the 2026-08-25 direct Wallet API submission plan; the visual direction remains reusable. ## Goal -Refresh the visual-only auction detail route into a premium dark protocol-console experience while preserving CipherBid's public-data-only boundary and every truthful privacy disclosure. +Refresh the auction detail route into a premium dark protocol-console experience while preserving truthful privacy disclosures. The current submission plan adds separately tested Wallet API interaction and live readback behavior. ## Visual direction diff --git a/web/public/cipherbid-mainnet-demo.mp4 b/web/public/cipherbid-mainnet-demo.mp4 new file mode 100644 index 0000000..be9ede6 Binary files /dev/null and b/web/public/cipherbid-mainnet-demo.mp4 differ diff --git a/web/src/features/auction/auctionLiveViewModel.ts b/web/src/features/auction/auctionLiveViewModel.ts index 7abec44..659a4ca 100644 --- a/web/src/features/auction/auctionLiveViewModel.ts +++ b/web/src/features/auction/auctionLiveViewModel.ts @@ -24,6 +24,7 @@ export function toAuctionLiveViewModel(manifest: DeploymentManifest, snapshot: A tokenId: snapshot.config.tokenId.toString(), reservePrice: snapshot.config.reservePrice.toString(), cap: snapshot.config.cap.toString(), + poolFee: snapshot.poolFee.toString(), biddingDeadline: snapshot.config.biddingDeadline.toString(), revealDeadline: snapshot.config.revealDeadline.toString(), bidderLimit: snapshot.config.bidderLimit, diff --git a/web/src/features/auction/auctionReader.ts b/web/src/features/auction/auctionReader.ts index 7287fa3..5035d5a 100644 --- a/web/src/features/auction/auctionReader.ts +++ b/web/src/features/auction/auctionReader.ts @@ -89,9 +89,10 @@ function sameFelt(left: string, right: string): boolean { } export async function readAndValidateDeployment(reader: ChainReader, manifest: DeploymentManifest) { - const [classHashRaw, houseResult] = await Promise.all([ + const [classHashRaw, houseResult, feeResult] = await Promise.all([ reader.getClassHashAt(manifest.auctionHouse), call(reader, { contractAddress: manifest.auctionHouse, entrypoint: 'get_house_config' }), + call(reader, { contractAddress: manifest.strk20Pool, entrypoint: 'get_fee_amount' }), ]) const classHash = normalizeHex(classHashRaw, 'Auction house class hash') if (!sameFelt(classHash, manifest.auctionHouseClassHash)) @@ -104,12 +105,16 @@ export async function readAndValidateDeployment(reader: ChainReader, manifest: D if (!sameFelt(pool, manifest.strk20Pool)) throw new Error('Configured STRK20 pool does not match manifest') if (!sameFelt(paymentToken, manifest.paymentToken)) throw new Error('Configured payment token does not match manifest') + expectLength(feeResult, 1, 'get_fee_amount') + const poolFee = felt(feeResult[0], 'Pool fee') + if (poolFee === 0n) throw new Error('Configured STRK20 pool fee is zero') return Object.freeze({ pool: manifest.strk20Pool, paymentToken: manifest.paymentToken, maxBidders, classHash: manifest.auctionHouseClassHash, + poolFee, }) } @@ -202,5 +207,12 @@ export async function readAuctionSnapshot(reader: ChainReader, manifest: Deploym const custodyValid = sameFelt(nftOwner, expectedOwner) if (!custodyValid) throw new Error('NFT custody does not match auction lifecycle state') - return Object.freeze({ config, state, bids: Object.freeze(bids), nftOwner, custodyValid }) + return Object.freeze({ + config, + state, + bids: Object.freeze(bids), + nftOwner, + custodyValid, + poolFee: deployment.poolFee, + }) } diff --git a/web/src/features/auction/ui/AuctionActions.tsx b/web/src/features/auction/ui/AuctionActions.tsx index a22de6c..69637af 100644 --- a/web/src/features/auction/ui/AuctionActions.tsx +++ b/web/src/features/auction/ui/AuctionActions.tsx @@ -39,6 +39,14 @@ function isHex(value: string): value is `0x${string}` { return /^0x[0-9a-fA-F]+$/.test(value) } +function sameFelt(left: string, right: string): boolean { + try { + return BigInt(left) === BigInt(right) + } catch { + return false + } +} + function phase(model: AuctionLiveViewModel): 'bidding' | 'reveal' | 'settle' | 'settled' { if (model.state.settled) return 'settled' const now = BigInt(Math.floor(Date.now() / 1000)) @@ -98,6 +106,28 @@ export function AuctionActions({ model, connection, onRefresh }: AuctionActionsP const [status, setStatus] = useState('') const currentPhase = phase(model) const enabled = connection !== null && connection.supportsStrk20 + const sellerConnected = connection !== null && sameFelt(connection.address, model.seller) + const sellerEntitlement = BigInt(model.state.sellerEntitlement) + const poolFee = BigInt(model.poolFee) + const sellerClaimReady = + enabled && + currentPhase === 'settled' && + model.state.sold && + !model.state.sellerClaimConsumed && + sellerEntitlement > 0n && + sellerConnected && + sellerCredential !== null + const sellerClaimGuidance = (() => { + if (model.state.sellerClaimConsumed) return 'Seller proceeds are already claimed.' + if (currentPhase !== 'settled' || !model.state.sold || sellerEntitlement === 0n) { + return 'Seller proceeds unlock only after a successful settlement.' + } + if (!connection) return 'Connect the exact onchain seller account to continue.' + if (!connection.supportsStrk20) return 'Connect a Ready wallet with STRK20 Wallet API 0.10.3 or newer.' + if (!sellerConnected) return "Connected account is not this auction's seller. Reconnect the onchain seller account." + if (!sellerCredential) return 'Enter the recovery password, then import the seller recovery bundle.' + return 'Ready. Approve two wallet prompts: first authorize the prepared open note, then submit the private claim.' + })() const displayedReceipts = useMemo(() => { const seen = new Set() return [...verifiedReceiptsForAuction(model.network, model.auctionId), ...receipts].filter((receipt) => { @@ -210,6 +240,7 @@ export function AuctionActions({ model, connection, onRefresh }: AuctionActionsP ) if (!matching) throw new Error('No matching credential') if (matching.role === 'seller') { + if (matching.claimHandle !== BigInt(model.sellerClaimHandle)) throw new Error('Seller claim handle mismatch') setSellerCredential(matching) } else { const index = model.bids.findIndex((bid) => BigInt(bid.commitment) === matching.commitment) @@ -423,18 +454,24 @@ export function AuctionActions({ model, connection, onRefresh }: AuctionActionsP +
+

{sellerClaimGuidance}

+

+ {poolFee > sellerEntitlement + ? `${formatTokenAmount(poolFee, 18)} STRK pool fee exceeds the ${formatTokenAmount(sellerEntitlement, 18)} STRK proceeds. Completing the claim spends more private STRK than it recovers.` + : `Current pool fee: ${formatTokenAmount(poolFee, 18)} STRK. Seller proceeds: ${formatTokenAmount(sellerEntitlement, 18)} STRK.`} +

+

Claim completion uses two wallet prompts: open-note authorization, then private claim.

+

Ready X remains authoritative for spendable private balance; CipherBid never probes it.

+

{status}

diff --git a/web/src/features/auction/ui/AuctionLivePage.tsx b/web/src/features/auction/ui/AuctionLivePage.tsx index c1cc9c0..33f0917 100644 --- a/web/src/features/auction/ui/AuctionLivePage.tsx +++ b/web/src/features/auction/ui/AuctionLivePage.tsx @@ -31,6 +31,7 @@ export type AuctionLiveViewModel = Readonly<{ tokenId: string reservePrice: string cap: string + poolFee: string biddingDeadline: string revealDeadline: string bidderLimit: number diff --git a/web/tests/unit/AuctionActions.test.tsx b/web/tests/unit/AuctionActions.test.tsx index 5d1c356..592a205 100644 --- a/web/tests/unit/AuctionActions.test.tsx +++ b/web/tests/unit/AuctionActions.test.tsx @@ -22,6 +22,7 @@ const model: AuctionLiveViewModel = { tokenId: '99', reservePrice: '2000000000000000000', cap: '5000000000000000000', + poolFee: '6000000000000000000', biddingDeadline: `${Math.floor(Date.now() / 1000) + 3600}`, revealDeadline: `${Math.floor(Date.now() / 1000) + 7200}`, bidderLimit: 2, @@ -106,6 +107,78 @@ describe('AuctionActions', () => { expect(screen.queryByText('seller recovery imported')).not.toBeInTheDocument() }, 10_000) + it('explains every seller-claim prerequisite and enables only the exact seller recovery', async () => { + const user = userEvent.setup() + const password = 'correct horse battery staple' + const seller = createSellerCredential({ + network: 'mainnet', + chainId: BigInt(MAINNET_CHAIN_ID), + auctionHouse: BigInt(model.auctionHouse), + auctionId: BigInt(model.auctionId), + claimSecret: 0x123456789abcdefn, + }) + const settledModel: AuctionLiveViewModel = { + ...model, + network: 'mainnet', + chainId: MAINNET_CHAIN_ID, + sellerClaimHandle: `0x${seller.claimHandle.toString(16)}`, + state: { + ...model.state, + settled: true, + sold: true, + sellerEntitlement: '2000000000000000000', + }, + } + const sellerConnection = { ...connection, chainId: MAINNET_CHAIN_ID } + const bundle = await createVerifiedRecoveryBundle([seller], password) + const recoveryFile = new File([bundle.serialized], 'seller.recovery.json', { type: 'application/json' }) + Object.defineProperty(recoveryFile, 'text', { value: async () => bundle.serialized }) + + const { rerender } = render() + expect(screen.getByText(/Connect the exact onchain seller account/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Claim seller proceeds privately/i })).toBeDisabled() + + rerender() + expect(screen.getByText(/Connected account is not this auction's seller/i)).toBeInTheDocument() + + rerender() + expect(screen.getByText(/Enter the recovery password, then import the seller recovery bundle/i)).toBeInTheDocument() + expect(screen.getByText(/6 STRK pool fee exceeds the 2 STRK proceeds/i)).toBeInTheDocument() + expect(screen.getByText(/two wallet prompts/i)).toBeInTheDocument() + + await user.type(screen.getByLabelText('Recovery password'), password) + await user.upload(screen.getByLabelText('Import encrypted recovery bundle'), recoveryFile) + + expect(await screen.findByText('seller recovery imported')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Claim seller proceeds privately/i })).toBeEnabled() + }, 15_000) + + it('rejects a seller recovery whose claim handle does not match the onchain auction', async () => { + const user = userEvent.setup() + const password = 'correct horse battery staple' + const settledModel: AuctionLiveViewModel = { + ...model, + state: { ...model.state, settled: true, sold: true, sellerEntitlement: '2000000000000000000' }, + } + const wrongSeller = createSellerCredential({ + network: model.network, + chainId: BigInt(model.chainId), + auctionHouse: BigInt(model.auctionHouse), + auctionId: BigInt(model.auctionId), + claimSecret: 0x999n, + }) + const bundle = await createVerifiedRecoveryBundle([wrongSeller], password) + const recoveryFile = new File([bundle.serialized], 'wrong-seller.recovery.json', { type: 'application/json' }) + Object.defineProperty(recoveryFile, 'text', { value: async () => bundle.serialized }) + + render() + await user.type(screen.getByLabelText('Recovery password'), password) + await user.upload(screen.getByLabelText('Import encrypted recovery bundle'), recoveryFile) + + expect(await screen.findByText('recovery import failed')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Claim seller proceeds privately/i })).toBeDisabled() + }, 15_000) + it('rehydrates only the verified mainnet lifecycle receipts after a clean refresh', () => { const verifiedModel: AuctionLiveViewModel = { ...model, diff --git a/web/tests/unit/AuctionLivePage.test.tsx b/web/tests/unit/AuctionLivePage.test.tsx index 960698a..884aadf 100644 --- a/web/tests/unit/AuctionLivePage.test.tsx +++ b/web/tests/unit/AuctionLivePage.test.tsx @@ -17,6 +17,7 @@ const model: AuctionLiveViewModel = { tokenId: '99', reservePrice: '2000000000000000000', cap: '5000000000000000000', + poolFee: '6000000000000000000', biddingDeadline: '100', revealDeadline: '200', bidderLimit: 2, diff --git a/web/tests/unit/AuctionPageClient.test.tsx b/web/tests/unit/AuctionPageClient.test.tsx index a45f45b..81f6811 100644 --- a/web/tests/unit/AuctionPageClient.test.tsx +++ b/web/tests/unit/AuctionPageClient.test.tsx @@ -25,6 +25,7 @@ function model(auctionId: string): AuctionLiveViewModel { tokenId: '99', reservePrice: '1000000000000000000', cap: '4000000000000000000', + poolFee: '6000000000000000000', biddingDeadline: '4102444800', revealDeadline: '4102445100', bidderLimit: 2, diff --git a/web/tests/unit/auctionLiveViewModel.test.ts b/web/tests/unit/auctionLiveViewModel.test.ts index a37ff67..86dda5b 100644 --- a/web/tests/unit/auctionLiveViewModel.test.ts +++ b/web/tests/unit/auctionLiveViewModel.test.ts @@ -43,6 +43,7 @@ const snapshot = { ], nftOwner: '0x333', custodyValid: true, + poolFee: 6_000_000_000_000_000_000n, } satisfies Awaited> describe('auction live view model', () => { @@ -62,6 +63,7 @@ describe('auction live view model', () => { tokenId: '99', reservePrice: '1000000000000000000', cap: '4000000000000000000', + poolFee: '6000000000000000000', biddingDeadline: '100', revealDeadline: '200', bidderLimit: 2, diff --git a/web/tests/unit/auctionReader.test.ts b/web/tests/unit/auctionReader.test.ts index c57b741..a692b17 100644 --- a/web/tests/unit/auctionReader.test.ts +++ b/web/tests/unit/auctionReader.test.ts @@ -13,8 +13,9 @@ const manifest: DeploymentManifest = { } function reader(overrides: Partial> = {}): ChainReader { - const results: Record = { + const results: Partial> = { '0x123:get_house_config': [manifest.strk20Pool, manifest.paymentToken, '0x20'], + [`${manifest.strk20Pool}:get_fee_amount`]: ['0x53444835ec580000'], '0x123:get_auction_config': ['0x7', '0x777', '0xabc', '0x999', '0x63', '0x0', '0x2', '0x5', '0x64', '0xc8', '0x2'], '0x123:get_auction_state': ['0x1', '0x1', '0x1', '0x222', '0x888', '0x3', '0x3', '0x903', '0x0'], '0x123:get_bid_count': ['0x2'], @@ -42,6 +43,7 @@ describe('auction reader', () => { paymentToken: manifest.paymentToken, maxBidders: 32, classHash: manifest.auctionHouseClassHash, + poolFee: 6_000_000_000_000_000_000n, }) await expect(readAuctionSnapshot(reader(), manifest, 7n)).resolves.toMatchObject({ @@ -69,6 +71,7 @@ describe('auction reader', () => { ], nftOwner: '0x888', custodyValid: true, + poolFee: 6_000_000_000_000_000_000n, }) }) diff --git a/web/tests/unit/submissionManifest.test.ts b/web/tests/unit/submissionManifest.test.ts new file mode 100644 index 0000000..d0ae712 --- /dev/null +++ b/web/tests/unit/submissionManifest.test.ts @@ -0,0 +1,29 @@ +import { existsSync, readFileSync, statSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +const repoRoot = path.resolve(process.cwd(), '..') +const videoUrl = 'https://youtu.be/pYZk6KXko7o' + +describe('final STRK20 submission manifest', () => { + it('binds verified contracts, transactions, demo, and a real public video asset', () => { + const manifest = JSON.parse(readFileSync(path.join(repoRoot, 'strk20.json'), 'utf8')) as { + transactions: string[] + contracts: string[] + demo_url: string + demo_video: string + } + const videoPath = path.resolve(process.cwd(), 'public', 'cipherbid-mainnet-demo.mp4') + + expect(manifest.transactions).toHaveLength(5) + expect(new Set(manifest.transactions).size).toBe(5) + expect(manifest.transactions.every((value) => /^0x[0-9a-f]+$/.test(value))).toBe(true) + expect(manifest.contracts).toHaveLength(2) + expect(manifest.demo_url).toBe('https://sourcesenseitherealone.github.io/cipherbid/auction/?id=1788040057342') + expect(manifest.demo_video).toBe(videoUrl) + expect(existsSync(videoPath)).toBe(true) + expect(statSync(videoPath).size).toBeGreaterThan(1_000_000) + expect(statSync(videoPath).size).toBeLessThan(25_000_000) + expect(readFileSync(videoPath).subarray(4, 8).toString('ascii')).toBe('ftyp') + }) +})