Skip to content
Open
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
9 changes: 7 additions & 2 deletions example-apps/dashmint-lab/public/dashmint-lite.html
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,19 @@ <h2>Browse cards</h2>
// The token-enabled "card" data contract is already published on testnet by
// the React app. Anyone querying with the same contract id hits the same
// documents.
const CONTRACT_ID = 'GDBN1h52Zcs8hSSKBoz67WDyWmUwcRyCSJjXBgKPty94';
const CONTRACT_ID = '5hK6SMfN4m2vU1t9qhvngUUQjsXeMNwr8MZdFeGBH8Aa';
const DOC_TYPE = 'card';

// Connect to testnet. testnetTrusted() uses the SDK's bundled list of trusted
// nodes — no node URL or config needed. connect() does the gRPC handshake
// + initial sync. No identity or signing is required for read-only queries.
//
// Workaround: pin the platform protocol version for evo-sdk dev.6 so the
// SDK doesn't ask testnet for a newer protocol it can't decode. Mirrors
// PLATFORM_VERSION_OVERRIDE in setupDashClient-core.mjs. Remove once a
// fixed SDK release lands.
async function connectSdk() {
const sdk = EvoSDK.testnetTrusted();
const sdk = EvoSDK.testnetTrusted({ version: 11 });
await sdk.connect();
return sdk;
}
Expand Down
8 changes: 8 additions & 0 deletions example-apps/dashmint-lab/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@ function App() {
else if (status === "browsing") setSubTab("all");
}, [status]);

// Re-fetch token balance whenever the Mint tab becomes active. The balance
// effect in SessionContext only runs on login/logout/contract change, so
// without this prompt the value could be stale (read-after-write lag from
// a recent mint elsewhere, or simply a value from many minutes ago).
useEffect(() => {
if (tab === "mint" && status === "authenticated") refreshBalance();
}, [tab, status, refreshBalance]);

// Load cards for the current sub-tab whenever dependencies change.
// Keeps any previously-cached results visible while refetching so tab
// switches don't tear down the grid — only the first load shows the
Expand Down
119 changes: 107 additions & 12 deletions example-apps/dashmint-lab/src/components/MintForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,23 @@
* Mint-a-card form. Calls src/dash/mintCard directly; session provides
* sdk, keyManager, and the contract ID.
*/
import { useState, type FormEvent } from "react";
import { useEffect, useState, type FormEvent } from "react";
import { drawStarterPack, STARTER_PACK_SIZE } from "../data/starterPack";
import { errorMessage } from "../dash/logger";
import { mintCard } from "../dash/mintCard";
import { DASHMINT_TOKEN_COST } from "../dash/dashMintToken";
import {
DASHMINT_TOKEN_COST,
DASHMINT_TOKEN_SUPPLY,
fetchCardsMintedCount,
} from "../dash/dashMintToken";
import { useSession } from "../session/useSession";
import { OddsTable } from "./OddsTable";

// Module-level cache so the supply count survives tab unmounts. When the
// user navigates back to Mint we render the last known value immediately
// and silently refetch — no "—" placeholder flash.
const mintedCountCache = new Map<string, bigint>();

export interface MintFormProps {
contractId: string;
dashMintTokenBalance?: bigint | null;
Expand All @@ -26,17 +35,47 @@ export function MintForm({
const [description, setDescription] = useState("");
const [submitting, setSubmitting] = useState(false);
const [mintingPack, setMintingPack] = useState(false);
const [mintedCount, setMintedCount] = useState<bigint | null>(
() => mintedCountCache.get(contractId) ?? null,
);
const starterPackTokenCost = BigInt(STARTER_PACK_SIZE);

useEffect(() => {
if (!session.sdk) return;
let cancelled = false;
fetchCardsMintedCount({ sdk: session.sdk, contractId })
.then((count) => {
mintedCountCache.set(contractId, count);
if (!cancelled) setMintedCount(count);
})
.catch(() => {
// Keep the previous (possibly cached) value on failure so the UI
// doesn't regress to "—" just because a refetch hiccuped.
});
return () => {
cancelled = true;
};
}, [session.sdk, contractId, submitting, mintingPack]);
Comment on lines +43 to +58
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Block mint actions while supply is still unknown.

On a cold load, mintedCount starts as null, and it stays null on the first fetch failure. In that state both actions are still enabled, so users can submit before the sold-out check has resolved. That defeats the new sold-out gating this PR is adding.

Suggested fix
 const starterPackTokenCost = BigInt(STARTER_PACK_SIZE);
+const supplyUnknown = mintedCount === null;

 async function handleSubmit(e: FormEvent) {
   e.preventDefault();
   if (!session.sdk || !session.keyManager) return;
-  if (submitting || mintingPack || soldOut || hasInsufficientTokensForCard) {
+  if (
+    supplyUnknown ||
+    submitting ||
+    mintingPack ||
+    soldOut ||
+    hasInsufficientTokensForCard
+  ) {
     return;
   }
   setSubmitting(true);
   try {
@@
 async function handleStarterPack() {
   if (!session.sdk || !session.keyManager) return;
   if (
+    supplyUnknown ||
     submitting ||
     mintingPack ||
     starterPackSoldOut ||
     hasInsufficientTokensForStarterPack
   ) {
@@
         <button
           type="submit"
           disabled={
+            supplyUnknown ||
             submitting ||
             mintingPack ||
             !name.trim() ||
             hasInsufficientTokensForCard
           }
@@
         <button
           type="button"
           onClick={handleStarterPack}
           disabled={
+            supplyUnknown ||
             submitting ||
             mintingPack ||
             starterPackSoldOut ||
             hasInsufficientTokensForStarterPack
           }

Also applies to: 76-78, 100-105, 240-245, 278-283

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@example-apps/dashmint-lab/src/components/MintForm.tsx` around lines 43 - 58,
The mint UI allows actions when mintedCount is null (unknown) after a failed
initial fetch; update the logic so actions are disabled while supply is unknown:
ensure the fetch in useEffect (using fetchCardsMintedCount, mintedCountCache,
and setMintedCount) sets a definitive cached fallback or a separate
"loading/unknown" flag and change any action-enabling checks to require
mintedCount !== null (or require !isUnknown) before enabling mint/sold-out
gating; apply the same guard to the other occurrences that reference mintedCount
(the blocks around the other noted locations) so actions remain blocked until a
concrete count or cached value is present.

const soldOut = mintedCount !== null && mintedCount >= DASHMINT_TOKEN_SUPPLY;
const starterPackSoldOut =
mintedCount !== null &&
DASHMINT_TOKEN_SUPPLY - mintedCount < starterPackTokenCost;
const hasInsufficientTokensForCard =
dashMintTokenBalance !== null && dashMintTokenBalance < DASHMINT_TOKEN_COST;
const hasInsufficientTokensForStarterPack =
dashMintTokenBalance !== null &&
dashMintTokenBalance < starterPackTokenCost;
const mintedPercent =
mintedCount === null
? 0
: Math.min(100, Number((mintedCount * 100n) / DASHMINT_TOKEN_SUPPLY));

async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!session.sdk || !session.keyManager) return;
if (submitting || mintingPack || hasInsufficientTokensForCard) return;
if (submitting || mintingPack || soldOut || hasInsufficientTokensForCard) {
return;
}
setSubmitting(true);
try {
await mintCard({
Expand All @@ -58,7 +97,12 @@ export function MintForm({

async function handleStarterPack() {
if (!session.sdk || !session.keyManager) return;
if (submitting || mintingPack || hasInsufficientTokensForStarterPack) {
if (
submitting ||
mintingPack ||
starterPackSoldOut ||
hasInsufficientTokensForStarterPack
) {
return;
}
setMintingPack(true);
Expand All @@ -83,6 +127,41 @@ export function MintForm({
}
}

const supplyBlock = (
<div className="rounded-md border border-line bg-bg px-3 py-2">
<div className="text-[10px] font-semibold uppercase tracking-[0.12em] text-ink-4">
Supply
</div>
<div className="mt-1 font-mono text-[14px] text-ink">
{mintedCount === null
? "—"
: `${mintedCount.toString()} / ${DASHMINT_TOKEN_SUPPLY.toString()} minted`}
</div>
{mintedCount !== null && (
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-surface-2">
<div
className={`h-full ${soldOut ? "bg-danger" : "bg-accent"}`}
style={{ width: `${mintedPercent}%` }}
/>
</div>
)}
{soldOut && (
<p className="mt-2 rounded-md border border-[oklch(30%_0.08_25)] bg-[oklch(22%_0.04_25)] px-3 py-2 text-[12px] font-medium leading-[1.45] text-danger">
All cards have been minted.
</p>
)}
</div>
);

if (soldOut) {
return (
<div className="flex flex-col gap-5 rounded-xl border border-line bg-surface p-5">
{supplyBlock}
<OddsTable />
</div>
);
}

return (
<div className="flex flex-col gap-5 rounded-xl border border-line bg-surface p-5">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
Expand All @@ -93,9 +172,11 @@ export function MintForm({
</p>
</div>

{supplyBlock}

<div className="rounded-md border border-line bg-bg px-3 py-2">
<div className="text-[10px] font-semibold uppercase tracking-[0.12em] text-ink-4">
DashMint tokens
Your DashMint token balance
</div>
<div className="mt-1 font-mono text-[14px] text-ink">
{dashMintTokenBalance === null
Expand Down Expand Up @@ -176,23 +257,37 @@ export function MintForm({
Starter Pack
</h2>
<p className="text-[12px] leading-[1.55] text-ink-3">
Mint a random set of sample cards from the tutorial collection. Costs{" "}
{STARTER_PACK_SIZE} DashMint tokens.
Mint {STARTER_PACK_SIZE} random cards from the tutorial collection.
Costs {STARTER_PACK_SIZE} DashMint tokens (1 per card).
</p>
{hasInsufficientTokensForStarterPack && (
{starterPackSoldOut ? (
<p className="rounded-md border border-[oklch(30%_0.08_25)] bg-[oklch(22%_0.04_25)] px-3 py-2 text-[12px] font-medium leading-[1.45] text-danger">
You need {STARTER_PACK_SIZE} DashMint tokens to open a Starter Pack.
Not enough remaining supply for a Starter Pack.
</p>
) : (
hasInsufficientTokensForStarterPack && (
<p className="rounded-md border border-[oklch(30%_0.08_25)] bg-[oklch(22%_0.04_25)] px-3 py-2 text-[12px] font-medium leading-[1.45] text-danger">
You need {STARTER_PACK_SIZE} DashMint tokens to open a Starter
Pack.
</p>
)
)}
<button
type="button"
onClick={handleStarterPack}
disabled={
submitting || mintingPack || hasInsufficientTokensForStarterPack
submitting ||
mintingPack ||
starterPackSoldOut ||
hasInsufficientTokensForStarterPack
}
className="self-start rounded-md border border-line-2 px-4 py-2 text-[13px] font-semibold text-ink transition hover:border-accent-dim hover:text-ink disabled:cursor-not-allowed disabled:border-line disabled:text-ink-4"
className="h-10 rounded-md border border-line-2 px-[18px] text-[13px] font-semibold text-ink transition hover:border-accent-dim hover:text-ink disabled:cursor-not-allowed disabled:border-line disabled:text-ink-4"
>
{mintingPack ? "Minting…" : "Open Starter Pack"}
{mintingPack
? "Minting…"
: starterPackSoldOut
? "Sold out"
: "Open Starter Pack"}
</button>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion example-apps/dashmint-lab/src/dash/contractStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const STORAGE_KEY = "dashmint-lab.contractId";
* the Settings modal or register their own.
*/
export const DEFAULT_CONTRACT_ID =
"GDBN1h52Zcs8hSSKBoz67WDyWmUwcRyCSJjXBgKPty94";
"5hK6SMfN4m2vU1t9qhvngUUQjsXeMNwr8MZdFeGBH8Aa";

export function loadStoredContractId(): string | null {
return localStorage.getItem(STORAGE_KEY) ?? DEFAULT_CONTRACT_ID;
Expand Down
18 changes: 18 additions & 0 deletions example-apps/dashmint-lab/src/dash/dashMintToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,21 @@ export async function fetchDashMintTokenBalance({
const balances = await sdk.tokens.identityBalances(identityId, [tokenId]);
return balances.get(tokenId) ?? 0n;
}

// Every mint burns exactly one DashMint token (manual burns/mints are locked
// in the contract), so cards minted = SUPPLY - current circulating supply.
export async function fetchCardsMintedCount({
sdk,
contractId,
}: {
sdk: DashSdk;
contractId: string;
}): Promise<bigint> {
const tokenId = await sdk.tokens.calculateId(
contractId,
DASHMINT_TOKEN_POSITION,
);
const supply = await sdk.tokens.totalSupply(tokenId);
const remaining = supply?.totalSupply ?? DASHMINT_TOKEN_SUPPLY;
return DASHMINT_TOKEN_SUPPLY - remaining;
}
3 changes: 3 additions & 0 deletions example-apps/dashmint-lab/src/dash/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ export interface DashSdk {
identityId: string,
tokenIds: string[],
): Promise<Map<string, bigint>>;
totalSupply(
tokenId: string,
): Promise<{ totalSupply: bigint; tokenId: string } | undefined>;
};
dpns: {
username(identityId: string): Promise<string | null | undefined>;
Expand Down
Loading