feat(anthemus): dual asset pickers + honest exec state in Swap

Swap was a single pair-select + reverse button, and called the demo
placeOrder() stub — market orders got marked "filled" instantly with no
chain interaction, which reads as "swap is broken".

- SwapPanel.tsx: rewritten as dual asset pickers (You pay [X ▾] / You
  receive [Y ▾]). Receive options are filtered to tickers connected to
  the current pay ticker (receivableFor); no direct pair → picker
  disabled. Default is BCH → USD.
- engine.ts: added ASSETS, pairFor(pay, receive), receivableFor(pay),
  isPairBroadcastable(pairId). No PAIRS or engine-behaviour changes.
- AnthemusScreen.tsx: passes session down to <SwapPanel/>.

Execution honesty (fixes the "swap does nothing" complaint):
- Pair NOT broadcastable in this build (missing env config) → demo
  banner up-front + local-only recording. No more silent fake fills.
- Pair broadcastable → a ⌁ Broadcast on-chain OFF/ON toggle appears
  (needs unlocked wallet). ON routes through placeOrderWired and
  reports the actual txid or the reason it couldn't broadcast.

To flip broadcast ON for a pair: set VITE_ANTHEMUS_PAIR_CATEGORIES
(JSON with baseCategory/quoteCategory hex per pair) and
VITE_ANTHEMUS_PLATFORM_LB (hex) at build. The chipnet taker script
scripts/chipnet-anthemus-taker.mjs shows the LB shape used in
validation.

Also fixes a stale AgoraScreen.tsx header comment ("Two sub-tabs";
code has three: My Assets | Potidaea | Anthemus) and logs both this
and the Fiat 3-step change in WALLET-COORDINATION.md.
This commit is contained in:
Local Dev 2026-09-06 18:23:13 +02:00
parent 503f45809e
commit 63ae6cda5d
5 changed files with 251 additions and 44 deletions

View file

@ -360,3 +360,45 @@ started; **core + shell tsc clean** after. Please read if you re-open these:
the lists. `ArtemisionScreen` create-wizard name/ticker defaults `'Nova'/'NOVA'` → `''`. Agora demo
banner reworded ("unlock a wallet…"). tsc clean. FYI `ActivityScreen.tsx` still has a couple of mock
"Nova" *activity-feed* entries (not asset fixtures) — left as-is; flag if you want those cleared too.
- **Uniform-Fiat deposit/withdraw → three-step flow (user-directed, YYYY-MM-DD).**
Both flows now pick **1. Currency → 2. Stablecoin → 3. Network** as three separate
dropdowns (aligned with `Anthemus/DepositPanel`). Deposit was 2-step (combined
issuer·chain); Withdraw was a one-step mini nested inside the Hold card.
Files touched — flagging per the currency-picker precedent:
- `screens/UniformFiat/DepositPanel.tsx` — split combined `cellCode` state into
separate `issuer` + `chain` state; cascade defaults keep valid on change.
- `screens/UniformFiat/WithdrawPanel.tsx` — **NEW** file, symmetric with
DepositPanel; uses `applyWithdraw` (unchanged in engine).
- `screens/UniformFiat/UniformFiatScreen.tsx` — added `'withdraw'` to the `Mode`
union + top-level tab; removed the nested `WithdrawMini` (replaced by a
one-line pointer to the Withdraw tab from the Hold detail card); cleaned
imports (`applyWithdraw`/`parseAmt`/`depositableCells` no longer used here).
Also fixed a stale comment on `AgoraScreen.tsx` header (said "Two sub-tabs";
code has three — My Assets | Potidaea | Anthemus). No engine changes.
tsc not run in this session (sandbox); please verify with `rtk npx tsc --noEmit -p shells/app`.
- **Anthemus Swap → dual asset pickers + honest exec state (user-directed).**
The panel was a single pair-select + ⇅ reverse button (Uniswap-expert UX) and
called the demo `placeOrder` stub — so market swaps got marked "filled"
instantly with no chain interaction. Users read that as "broken".
Files touched:
- `screens/Anthemus/SwapPanel.tsx` — rewritten: **You pay [asset ▾] / You
receive [asset ▾]**. The receive picker only shows tickers connected to the
chosen pay ticker (`receivableFor`); no direct pair → picker disabled with
"no direct pair". Default is BCH → USD. ⇅ swaps the two pickers cleanly.
- `screens/Anthemus/engine.ts` — added `ASSETS`, `pairFor(pay, receive)`,
`receivableFor(pay)`, and `isPairBroadcastable(pairId)`. No PAIRS changes,
no engine-behaviour changes.
- `screens/Anthemus/AnthemusScreen.tsx` — passes `session` down to
`<SwapPanel/>` (needed for the broadcast path).
**Execution honesty:**
- Pair NOT broadcastable in this build (no `VITE_ANTHEMUS_PAIR_CATEGORIES`
entry or no `VITE_ANTHEMUS_PLATFORM_LB`) → **demo banner up-front** + swap
records locally (Phase A). No silent fake fills.
- Pair broadcastable → a **⌁ Broadcast on-chain OFF/ON** toggle appears
(disabled without an unlocked wallet). When ON, the swap uses
`placeOrderWired(order, { session, dryRun: false })` and reports the txid
or the reason it couldn't broadcast.
**To flip broadcast ON for a pair** (Phase B): set the two env vars at build:
`VITE_ANTHEMUS_PAIR_CATEGORIES='{"BCH/USD":{"baseCategory":"<hex>","quoteCategory":"<hex>"}}'`
and `VITE_ANTHEMUS_PLATFORM_LB=<hex>`. The chipnet taker script at
`scripts/chipnet-anthemus-taker.mjs:39` shows the LB shape used in validation.

View file

@ -12,7 +12,9 @@ import type {
} from '@core/wallet/display.js';
// Agora — the marketplace hub (glossary: renamed from "Assets"; *see + participate*).
// Two sub-tabs: My Assets (holdings) | Anthemus (the market venue). My Assets has a
// Three sub-tabs: My Assets (holdings) | Potidaea (counterparty-free Orbital Swap,
// BCH↔MBA and MBA↔MBA at protocol rate — the wallet-side surface of the AsmCore
// engine) | Anthemus (the P2P market venue with book/AMM/Deposit). My Assets has a
// MBA | Tokens | Fiat filter (user-directed reorg, 2026-07-22): backed → MBA,
// unbacked → Tokens, and Fiat embeds the Uniform-Fiat surface (USD/EUR balances +
// Deposit + Swap) — no separate Fiat tab. Katalogos lives under Artemision.

View file

@ -32,7 +32,7 @@ export function AnthemusScreen({ bchAddress, session }: { bchAddress?: string; s
</div>
</div>
{mode === 'swap' ? <SwapPanel /> : mode === 'exchange' ? <ExchangePanel session={session} /> : <DepositPanel bchAddress={bchAddress} />}
{mode === 'swap' ? <SwapPanel session={session} /> : mode === 'exchange' ? <ExchangePanel session={session} /> : <DepositPanel bchAddress={bchAddress} />}
<div className="hint" style={{ marginTop: 12 }}>
Anthemus is a market — prices are discovered on a book/curve and every fee is a

View file

@ -1,47 +1,115 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { CoreSession } from '../../core/wallet';
import {
PAIRS, type Pair, ammQuote, demoReserves, fmt, pct, assetLabel, CURVE_FEE, placeOrder,
PAIRS, ASSETS, pairFor, receivableFor, ammQuote, demoReserves, fmt, pct,
assetLabel, CURVE_FEE, placeOrder, placeOrderWired, isPairBroadcastable,
} from './engine';
// SWAP — the Uniswap-style interface: token-in → token-out against the AMM maker.
// HONEST LABELING (ADR-0016 Decision 5): this is rendered as a "Market swap" with
// price impact + fee as separate disclosed lines — never as a counterparty-free
// protocol swap. The AMM is a maker on the same book as the Exchange panel.
// SWAP — DUAL asset pickers ("You pay X → You receive Y"). The pair (and whether
// the direction is reversed) is resolved from the two tickers, matching how users
// think about swaps in every mainstream wallet. If no direct pair connects the
// chosen tickers, the receive picker only offers what's routable.
//
// HONEST LABELING (ADR-0016 Decision 5): rendered as a "Market swap" — price
// impact and curve fee are separate disclosed lines, never folded into "rate".
//
// EXECUTION HONESTY:
// • When VITE_ANTHEMUS_PAIR_CATEGORIES + VITE_ANTHEMUS_PLATFORM_LB are set for
// the selected pair, a "⌁ broadcast on" toggle appears; when on, the swap
// goes through `placeOrderWired` and hits chipnet.
// • When the pair is NOT broadcastable in this build, a demo banner is shown
// up-front and the swap only records to localStorage (Phase A). No silent
// fake fills.
const SLIPPAGE_OPTS = [0.001, 0.005, 0.01];
export function SwapPanel({ onFilled }: { onFilled?: () => void }) {
const [pairId, setPairId] = useState(PAIRS[0].id);
const [reversed, setReversed] = useState(false); // false: sell base → get quote
export function SwapPanel({
session,
onFilled,
}: {
session?: CoreSession | null;
onFilled?: () => void;
}) {
// 1. Asset selection — separate pay / receive tickers.
const [pay, setPay] = useState<string>(ASSETS[0]);
const [receive, setReceive] = useState<string>(() => {
// Prefer BCH → USD as the default (the most common wallet swap).
const withBch = ASSETS.includes('BCH') && ASSETS.includes('USD');
return withBch ? 'USD' : receivableFor(ASSETS[0])[0] ?? ASSETS[1] ?? '';
});
// If BCH is available, default pay to BCH so the initial view is "BCH → USD".
useEffect(() => {
if (ASSETS.includes('BCH')) setPay('BCH');
}, []);
const receiveOpts = useMemo(() => receivableFor(pay), [pay]);
// If the current receive isn't valid for the new pay, pick the first that is.
useEffect(() => {
if (!receiveOpts.includes(receive)) setReceive(receiveOpts[0] ?? '');
}, [receiveOpts, receive]);
const resolved = useMemo(() => pairFor(pay, receive), [pay, receive]);
const pair = resolved?.pair ?? null;
const reversed = resolved?.reversed ?? false;
const broadcastable = pair ? isPairBroadcastable(pair.id) : false;
// 2. Amount + quote.
const [amountStr, setAmountStr] = useState('');
const [slippage, setSlippage] = useState(0.005);
const [done, setDone] = useState<string | null>(null);
const pair = PAIRS.find((p) => p.id === pairId) as Pair;
const from = reversed ? pair.quote : pair.base;
const to = reversed ? pair.base : pair.quote;
const amount = Number(amountStr) || 0;
const quote = useMemo(() => {
if (!pair) return null;
const { rIn, rOut } = demoReserves(pair);
// reserves are (base, quote); a reversed swap feeds the quote side in.
return reversed ? ammQuote(rOut, rIn, amount) : ammQuote(rIn, rOut, amount);
}, [pair, reversed, amount]);
const canSwap = quote !== null && amount > 0;
// 3. Slippage + broadcast toggle.
const [slippage, setSlippage] = useState(0.005);
const [broadcastOn, setBroadcastOn] = useState(false);
useEffect(() => { if (!broadcastable) setBroadcastOn(false); }, [broadcastable]);
const doSwap = () => {
if (!quote) return;
// MVP surface: record the fill locally (the covenant leg mirrors the validated
// chipnet driver once the pair's categories are live in the registry).
placeOrder({
const [note, setNote] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const canSwap = !!pair && quote !== null && amount > 0 && !busy;
const flipDirection = () => {
const nextPay = receive;
const nextReceive = pay;
setPay(nextPay);
setReceive(nextReceive);
setAmountStr('');
setNote(null);
};
const doSwap = async () => {
if (!pair || !quote) return;
const order = {
pairId: pair.id,
side: reversed ? 'buy' : 'sell',
kind: 'market',
side: (reversed ? 'buy' : 'sell') as 'buy' | 'sell',
kind: 'market' as const,
price: quote.execPrice,
qty: amount,
});
setDone(`Swapped ${fmt(amount, 2)} ${assetLabel(from)} → ${fmt(quote.out, 4)} ${assetLabel(to)}`);
};
if (broadcastOn && broadcastable && session) {
setBusy(true);
try {
const { built } = await placeOrderWired(order, { session, dryRun: false });
setNote(
built.txid
? `Broadcast ${assetLabel(pair.base)} / ${assetLabel(pair.quote)} — txid ${built.txid.slice(0, 12)}…`
: `Not broadcast: ${built.note}`,
);
} catch (e) {
setNote(`Broadcast failed: ${(e as Error).message}`);
} finally {
setBusy(false);
}
} else {
placeOrder(order);
setNote(
`Recorded (demo) ${fmt(amount, 2)} ${assetLabel({ ticker: pay, decimals: 2 })} → ` +
`${fmt(quote.out, 4)} ${assetLabel({ ticker: receive, decimals: 2 })}`,
);
}
setAmountStr('');
onFilled?.();
};
@ -54,12 +122,21 @@ export function SwapPanel({ onFilled }: { onFilled?: () => void }) {
<span className="anx-fee-tag">curve fee {pct(CURVE_FEE, 2)} → LPs</span>
</div>
{/* Honest state — up front, not buried under the CTA. */}
{!broadcastable && (
<div className="banner info" style={{ marginBottom: 8 }}>
Demo swap — this pair is not wired to the on-chain relay in this build.
Confirm still records the fill locally so the flow is testable.
</div>
)}
{/* You pay */}
<div className="anx-field">
<div className="anx-field-top">
<span>You pay</span>
<select className="anx-pair-select" value={pairId} onChange={(e) => { setPairId(e.target.value); setDone(null); }}>
{PAIRS.map((p) => (
<option key={p.id} value={p.id}>{assetLabel(p.base)} / {assetLabel(p.quote)}</option>
<select className="anx-pair-select" value={pay} onChange={(e) => { setPay(e.target.value); setNote(null); }}>
{ASSETS.map((a) => (
<option key={a} value={a}>{a}</option>
))}
</select>
</div>
@ -69,27 +146,55 @@ export function SwapPanel({ onFilled }: { onFilled?: () => void }) {
inputMode="decimal"
placeholder="0.00"
value={amountStr}
onChange={(e) => { setAmountStr(e.target.value.replace(/[^\d.]/g, '')); setDone(null); }}
onChange={(e) => { setAmountStr(e.target.value.replace(/[^\d.]/g, '')); setNote(null); }}
/>
<span className="anx-asset">{assetLabel(from)}</span>
<span className="anx-asset">{pay}</span>
</div>
</div>
<button className="anx-flip" aria-label="Reverse direction" onClick={() => { setReversed((r) => !r); setDone(null); }}>⇅</button>
<button className="anx-flip" aria-label="Reverse direction" onClick={flipDirection}>⇅</button>
{/* You receive */}
<div className="anx-field">
<div className="anx-field-top"><span>You receive</span></div>
<div className="anx-field-top">
<span>You receive</span>
<select
className="anx-pair-select"
value={receive}
onChange={(e) => { setReceive(e.target.value); setNote(null); }}
disabled={receiveOpts.length === 0}
>
{receiveOpts.length === 0 ? (
<option value="">no direct pair</option>
) : (
receiveOpts.map((a) => (
<option key={a} value={a}>{a}</option>
))
)}
</select>
</div>
<div className="anx-field-row">
<div className="anx-amount out">{quote ? fmt(quote.out, 4) : '—'}</div>
<span className="anx-asset">{assetLabel(to)}</span>
<span className="anx-asset">{receive || '—'}</span>
</div>
</div>
{/* the disclosed lines — price impact + fee are SEPARATE, never folded into "rate" */}
{/* Disclosed lines — price impact + fee separate, never folded into "rate". */}
<div className="anx-quote-lines">
<div className="anx-line"><span>Rate</span><span>{quote ? `1 ${from.ticker} ≈ ${fmt(quote.execPrice, 4)} ${to.ticker}` : '—'}</span></div>
<div className="anx-line"><span>Price impact</span><span className={quote && quote.priceImpact > 0.01 ? 'warn' : ''}>{quote ? pct(quote.priceImpact) : '—'}</span></div>
<div className="anx-line"><span>Curve fee (0.30% → LPs)</span><span>{quote ? `${fmt(quote.curveFee, 4)} ${from.ticker}` : '—'}</span></div>
<div className="anx-line">
<span>Rate</span>
<span>{quote ? `1 ${pay} ≈ ${fmt(quote.execPrice, 4)} ${receive}` : '—'}</span>
</div>
<div className="anx-line">
<span>Price impact</span>
<span className={quote && quote.priceImpact > 0.01 ? 'warn' : ''}>
{quote ? pct(quote.priceImpact) : '—'}
</span>
</div>
<div className="anx-line">
<span>Curve fee (0.30% → LPs)</span>
<span>{quote ? `${fmt(quote.curveFee, 4)} ${pay}` : '—'}</span>
</div>
<div className="anx-line">
<span>Slippage tolerance</span>
<span className="anx-slip">
@ -98,14 +203,37 @@ export function SwapPanel({ onFilled }: { onFilled?: () => void }) {
))}
</span>
</div>
<div className="anx-line"><span>Minimum received</span><span>{quote ? `${fmt(quote.minReceived(slippage), 4)} ${to.ticker}` : '—'}</span></div>
<div className="anx-line">
<span>Minimum received</span>
<span>{quote ? `${fmt(quote.minReceived(slippage), 4)} ${receive}` : '—'}</span>
</div>
</div>
{/* Broadcast toggle — only visible when the pair has env categories + platform LB. */}
{broadcastable && (
<div className="anx-line" style={{ marginTop: 4 }}>
<span>⌁ Broadcast on-chain</span>
<span className="anx-slip">
<button
className={`anx-slip-btn${broadcastOn ? ' on' : ''}`}
onClick={() => setBroadcastOn((v) => !v)}
disabled={!session}
title={session ? '' : 'Unlock a wallet to broadcast'}
>
{broadcastOn ? 'ON' : 'OFF'}
</button>
</span>
</div>
)}
<button className="anx-cta" disabled={!canSwap} onClick={doSwap}>
{canSwap ? `Swap ${assetLabel(from)} → ${assetLabel(to)}` : 'Enter an amount'}
{busy ? 'Broadcasting…'
: !pair ? 'Pick two connected assets'
: canSwap ? `Swap ${pay} → ${receive}${broadcastOn ? ' (on-chain)' : ''}`
: 'Enter an amount'}
</button>
{done && <div className="banner info" style={{ marginTop: 8 }}>{done}</div>}
{note && <div className="banner info" style={{ marginTop: 8 }}>{note}</div>}
<div className="hint" style={{ marginTop: 10 }}>
A market swap against pooled liquidity — the price is discovered on a curve, not a

View file

@ -50,6 +50,33 @@ export const PAIRS: Pair[] = [
export const CURVE_FEE = 0.003; // 0.30% AMM curve fee → LPs
export const TAKER_FEE = 0.0015; // 0.15% book taker fee (maker 0%)
// ── asset-picker helpers (Swap panel: dual "pay/receive" pickers) ─────────────
/** Distinct tickers listed across all pairs, e.g. ['EUR','USD','BCH','ASM','ASM2']. */
export const ASSETS: string[] = [
...new Set(PAIRS.flatMap((p) => [p.base.ticker, p.quote.ticker])),
];
/** Given a chosen (pay, receive) ticker pair, resolve to a PAIRS entry + whether the
* direction is reversed (i.e. selling the quote side). Returns null when no direct
* pair connects the two tickers — the UI can then disable the target option. */
export function pairFor(pay: string, receive: string): { pair: Pair; reversed: boolean } | null {
for (const p of PAIRS) {
if (p.base.ticker === pay && p.quote.ticker === receive) return { pair: p, reversed: false };
if (p.quote.ticker === pay && p.base.ticker === receive) return { pair: p, reversed: true };
}
return null;
}
/** Which tickers can I receive if I'm paying `pay`? (For enabling/disabling picker options.) */
export function receivableFor(pay: string): string[] {
const out = new Set<string>();
for (const p of PAIRS) {
if (p.base.ticker === pay) out.add(p.quote.ticker);
if (p.quote.ticker === pay) out.add(p.base.ticker);
}
return [...out];
}
// ── AMM quote (constant product x·y=k, fee on input — SharePairAMM semantics) ──
export interface AmmQuote {
out: number; // amount received (before slippage tolerance)
@ -414,6 +441,14 @@ export const PLATFORM_FEE_LOCKING_BYTECODE: string | null =
export const ANTHEMUS_ORDER_BOOK_ARTIFACT: Artifact = anthemusOrderBookArtifact as unknown as Artifact;
/** True when the pair has BOTH categories AND the platform fee sink set —
* the two things a broadcast path needs before it can build a real escrow
* tx. The Swap panel uses this to show/hide the "⌁ broadcast on" toggle. */
export function isPairBroadcastable(pairId: string): boolean {
const cats = PAIR_CATEGORIES[pairId];
return !!cats?.baseCategory && !!cats?.quoteCategory && !!PLATFORM_FEE_LOCKING_BYTECODE;
}
export interface PlacementContext {
session: CoreSession;
/** Default TRUE — Phase A never broadcasts. Phase B flips this per pair. */