market: stop advertising unlisted pile members as tradeState:"inactive"

RE of the FUT front-end closed the question the Actions-panel investigation left
open, and the answer retracts Q2 rather than completing it.

`tradeState` reaches exactly ONE native branch in CardsDLL — `cmp …,0x4` at
`0x18013e619`, "is it closed?" — and `inactive`(2) and `expired`(3) take the same
edge, producing bit-identical `flagA`/`flagB` (exhaustive 22-site census of
`[reg+0x88]` reads across the PE; confirmed live, both classes read glow=0
inbox=0). The value is then handed to the movie verbatim as the Flash property
`STATE`, and the action gate lives in the APT/ActionScript FUT front-end: the
trade-pile class partitions rows with `getCardsInAuction`/`isInActiveAuction`
(traces `initPile() - IN AUCTION:` / `- NOT IN AUCTION:`) and only auction rows
reach `PreCheckCardOptions` -> `handleTradeCardAction`. A non-auction row renders
and can never be acted on, which is exactly what the operator saw.

So the rows were never usable. "LIVE-CONFIRMED" established that they RENDER,
which is not the same claim, and I treated it as if it were.

The corpus said this before any of it was built —
`plan-2026-08-06-transfer-market.md:731-733`: "`inactive` decodes but no client
path treats it specially; do not emit it." The earlier note explaining that the
warning "was written about the PRESENTATION function" was motivated reasoning.
This also fires the corpus's own pre-registered falsifier E3 (:368-373).

Removed: the `inactive` projection from `GET …/tradePile` and `…/trade/status`,
`UnlistedCandidate`, `resolve_unlisted_pile`, `unlisted_record`,
`Server::resolve_trade_pile`, and the two helpers that existed only to feed them
(`MarketStore::blocking_core_items`, `Fifa17IdentityResolver::wire_for_owned_id`).
Unlisted trade-pile membership is now internal state with no wire expression.

Nothing is stranded: `/club` excludes only items with an ACTIVE listing, so an
unlisted pile member stays visible in the club, which is where the client can act
on it. Verified live after deploy — `/tradePile` total 7 -> 1 with zero `inactive`
rows, `/trade/status` resolving only the real auction, coins unchanged at
29,843,976, and all six former rows present in `/club` (1965 items).

Tests: 126 pass, fmt + clippy clean. Two guards replace the three tests that
pinned the old behaviour: `the_trade_pile_advertises_only_real_auctions` and
`trade_status_answers_only_about_real_auctions`.

NOT fixed here, deliberately: `itemData.itemState: "listFS"` is not a FIFA 17
token (0 occurrences in CardsDLL md5 4de3493131d7d2ff7f8b360c5ac9b655, 0 in
4.26 GiB of process memory, decodes to -1; the real value is `forSale` = 5, and
the Python oracle emits `listFS` too — which is why the differential never caught
it). `CARD_OFFERSTATE` is one of three unresolved action-gate candidates and
every actionable row observed carried -1, so that change ships alone with its own
live A/B.
This commit is contained in:
funman300
2026-08-17 23:14:35 +00:00
parent 3ce69f8951
commit f9ca901a50
6 changed files with 299 additions and 467 deletions
+70 -3
View File
@@ -399,7 +399,7 @@ Tests: `returning_an_expired_listing_to_the_club_ends_its_auction`,
---
## Q2 representation — narrowed to ONE candidate by elimination (2026-08-17)
## Q2 representation — narrowed to ONE candidate by elimination (2026-08-17) — **SUPERSEDED, see the final section**
Re-entry discriminator result: **CONFIRMED BUG.** After fully leaving and re-entering
FUT, the active auction reconstructs correctly under LISTED ITEMS, but the unlisted
@@ -469,7 +469,7 @@ test, not the immediate post-mutation response.
---
## Q2 — LIVE-CONFIRMED: `tradeState: "inactive"` is the unlisted representation
## Q2 — LIVE-CONFIRMED: `tradeState: "inactive"` is the unlisted representation — **SUPERSEDED, see the final section**
Operator-observed in the real FIFA 17 client, 2026-08-17, with the bounded one-item
probe (`OPENFUT_FIFA17_UNLISTED_PROBE`). Fixture:
@@ -534,7 +534,7 @@ mixed) has been observed — it remains an open question whether FIFA 17 expects
---
## Q2 status downgrade + the "Expired" differential EXPLAINED (2026-08-17)
## Q2 status downgrade + the "Expired" differential EXPLAINED (2026-08-17) — **SUPERSEDED, see the final section**
Phase C was a PARTIAL pass. Corrected conclusions:
@@ -588,3 +588,70 @@ touched and neither is implicated. If the actions still do not appear, those rem
the next candidates — and `itemState`'s vocabulary should be dumped statically with
`fifa17-recon/tools/vocab_dump.py` (already validated against the tradeState table)
rather than guessed.
---
## Q2 — RETRACTED, and CLOSED as unrepresentable (2026-08-17, RE of the FUT front-end)
**Everything in the two Q2 sections above is superseded.** The `tradeState:"inactive"`
projection has been removed from `GET …/tradePile` and `…/trade/status`, and the
`UnlistedCandidate` machinery deleted. Live after the fix: `total` 7 → 1, only the real
auction, `inactive` rows 0, coins unchanged, and all six former rows still present in
`/club` (invariant intact).
### Why the earlier conclusion was wrong
It was `LIVE-CONFIRMED` that the row **renders**. That was never in doubt, and it is
not the same claim as the row being usable. The rows were never actionable, and could
not have been:
| Fact | Confidence |
|---|---|
| `tradeState` reaches ONE native branch, `cmp …,0x4` at `0x18013e619` ("is it `closed`?"). `inactive`(2) and `expired`(3) take the same edge → bit-identical `flagA`/`flagB`. Exhaustive 22-site census of `[reg+0x88]` reads across the PE. | CONFIRMED |
| `tradeState` is handed to the movie verbatim as the Flash property `STATE` (publisher `0x1801bf030`); the action gate is in the APT/ActionScript FUT front-end, not CardsDLL. | CONFIRMED |
| The trade-pile movie class partitions rows with `getCardsInAuction`/`isInActiveAuction` (traces `initPile() - IN AUCTION:` / `- NOT IN AUCTION:`), and only auction rows reach `PreCheckCardOptions``handleTradeCardAction`. | CONFIRMED (interned AS symbols + trace strings, live) |
| A client rule excludes non-auction pile rows from the action path (Category C). | STRONGLY SUPPORTED |
| The gate term is `STATE == 2`. | **NOT PROVEN**`STATE`, `CARD_OFFERSTATE` and the `RESERVEDPRICE`/`MAX_CREDITS` pair all differ together in every row we can observe |
| In the shipped game, unlisted pile rows were a CLIENT-SIDE transient built by the movie from a `TO_TRADEPILE` Flash message (`FUN_1801be6a0`), never server-delivered. | STRONGLY SUPPORTED |
This fires the corpus's own pre-registered falsifier E3
(`plan-2026-08-06-transfer-market.md:368-373`): the native predicates all pass
(`IS_TRADING_ENABLED=1`, `TRADE_PILE_SIZE=100`, `tradeable=1`) and an `expired` row in
the same section IS actionable, so "the Flash layer has its own gate and CardsDLL is no
longer the right place to look" is now an observation.
**The corpus said so before any of this was written**, at
`plan-2026-08-06-transfer-market.md:731-733`: "`inactive` decodes but no client path
treats it specially; **do not emit it**." The projection was built anyway, and the
earlier reasoning above (that the warning "was written about the PRESENTATION
function") was motivated rather than evidenced.
**Consequence for the product:** a server cannot put an unlisted item on the Transfer
List in an actionable state. `/tradePile` carries only twelve-atom auction records and
`pile` has no deserializer arm, so anything rendered there must be a real auction.
Unlisted trade-pile membership is now internal state with no wire expression; those
cards live in `/club`, which is where the client can act on them.
### Open: `itemData.itemState: "listFS"` is not a FIFA 17 token
Independent of the gate, and CONFIRMED: `listFS` occurs **0 times** in
`CardsDLL_Win64_retail.dll` (md5 `4de3493131d7d2ff7f8b360c5ac9b655`) and 0 times in
4.26 GiB of live process memory; it decodes to **1** through `FUN_180166660`, observed
live on our listed row. FIFA 17's value for a listed item is `forSale` (5), from the
12-row table at `0x180229cc0`. We emit `listFS` on `/tradePile` and `/trade/status`, and
so does the Python oracle (`utas_server.py:3921`) — which is exactly why the
differential never caught it: **oracle parity is necessary but not sufficient.**
Deliberately NOT fixed in the same change: `CARD_OFFERSTATE` (itemState) is one of the
three unresolved gate candidates, and every actionable row observed so far carried
`itemState = 1`. Flipping it to `forSale` could perturb the one flow that currently
works (expired → Return to Club), so it ships alone and gets its own live A/B.
### Proving the gate (not yet done)
Disassemble the AVM1 action buffer of `tradepile.isInActiveAuction` (and
`PreCheckCardOptions`): either walk from the interned symbol at `0xb84d7498` to its APT
function object in the live process, or extract
`external/ion_fut/screens/trading/tradepile` from `ui.sb` + the `cas` archives offline.
Nothing else in reach separates `STATE` from `CARD_OFFERSTATE` from the price pair,
because all three differ together in every observable row.
@@ -0,0 +1,34 @@
{
"date": "2026-08-17",
"change": "removed tradeState:'inactive' projection from /tradePile and /trade/status",
"why": "RE of the FUT front-end: the action gate is in the APT/ActionScript layer; only rows classified as auctions (getCardsInAuction/isInActiveAuction) reach PreCheckCardOptions -> handleTradeCardAction, so an 'inactive' row renders and can never be acted on. The corpus already said 'do not emit it' (plan-2026-08-06-transfer-market.md:731-733).",
"before": {
"total": 7,
"by_state": {
"expired": 1,
"inactive": 6
},
"credits": 29843976,
"trade_status_returned": 7
},
"after": {
"total": 1,
"by_state": {
"expired": 1
},
"credits": 29843976,
"trade_status_returned": 1
},
"club_invariant": {
"formerly_inactive_members_visible_in_club": 6,
"of": 6,
"stranded": 0,
"club_items": 1965
},
"not_fixed_here": {
"itemState": "listFS",
"finding": "0 occurrences in CardsDLL (md5 4de3493131d7d2ff7f8b360c5ac9b655) and 0 in 4.26 GiB of process memory; decodes to -1. FIFA 17 value is forSale (5).",
"reason_deferred": "CARD_OFFERSTATE is one of three unresolved action-gate candidates; every actionable row observed carried itemState -1, so changing it could perturb the working expired -> Return-to-Club flow. Ships alone with its own live A/B."
},
"gate_status": "Category C, STRONGLY SUPPORTED. Gate term NOT PROVEN: STATE, CARD_OFFERSTATE and RESERVEDPRICE/MAX_CREDITS all differ together in every observable row. Proof needs AVM1 disassembly of tradepile.isInActiveAuction."
}
+4 -1
View File
@@ -44,7 +44,10 @@ impl ClientDataStore {
/// The stored blob for `<persona>:<key>`, or `None` if never written.
pub fn get(&self, persona: i64, key: &str) -> Option<Value> {
self.map.lock().get(&Self::compound_key(persona, key)).cloned()
self.map
.lock()
.get(&Self::compound_key(persona, key))
.cloned()
}
/// Store `value` under `<persona>:<key>` and persist the whole map to disk.
+54 -89
View File
@@ -1052,21 +1052,6 @@ impl Fifa17IdentityResolver {
.unwrap_or(None)
}
/// The wire item id ALREADY mapped to a Core owned instance, or `None`.
///
/// NEVER allocates, unlike the resolver's forward path: enumerating the trade
/// pile must not mint identities for items the client has never seen, or a
/// read would quietly consume wire ids.
pub fn wire_for_owned_id(&self, core_id: &str) -> Option<i64> {
self.store
.external_for(
Fifa17WireItemIdPolicy::GAME,
Fifa17WireItemIdPolicy::OWNED_ITEM_KIND,
core_id,
)
.unwrap_or(None)
}
/// The FIFA `cardsubtypeid` for an owned item's definition (0 if unknown /
/// a player), from the catalog — used by club-stats family aggregation.
pub fn subtype_of(&self, item: &CoreOwnedItem) -> i64 {
@@ -1133,7 +1118,6 @@ impl SquadWireResolver for Fifa17IdentityResolver {
}
}
// ───────────────────────────── /club handler ────────────────────────────────
/// Safe, structured summary of a handled `/club` request (no auth/session/device
@@ -1200,7 +1184,12 @@ fn paginate_items(items: &[Value], offset: Option<i64>, limit: Option<i64>) -> (
let total = items.len() as i64;
let off = offset.unwrap_or(0).max(0) as usize;
let paged: Vec<Value> = match limit {
Some(l) => items.iter().skip(off).take(l.max(0) as usize).cloned().collect(),
Some(l) => items
.iter()
.skip(off)
.take(l.max(0) as usize)
.cloned()
.collect(),
None => items.iter().skip(off).cloned().collect(),
};
(paged, total)
@@ -2208,39 +2197,6 @@ impl Server {
self
}
/// Resolve every Core instance in the `trade` pile to a shaped unlisted
/// candidate, for the routes that must agree about those ids.
///
/// Three phases in this order for a reason: the pile read is async, the
/// identity/Core resolvers are NOT `Send` so they cannot cross an await, and the
/// caller's response build is async again.
///
/// Both `/tradePile` and `/trade/status` use this. They MUST: the client polls
/// `/trade/status` for the row it is showing, and answering there with an empty
/// body while `/tradePile` advertises the id makes the client degrade the row to
/// "Expired" with no actions. A tradeId has to resolve on every route that can be
/// asked about it.
fn resolve_trade_pile(&self, svc: &EconomyServices) -> Vec<crate::market::UnlistedCandidate> {
let piles = svc.piles.clone();
let trade_ids = svc
.bridge
.block_on(async move { piles.list_by_pile("trade").await })
.unwrap_or_default();
if trade_ids.is_empty() {
return Vec::new();
}
let lookup = crate::economy_store::CoreItemLookup {
core: self.core.as_ref(),
};
crate::market::resolve_unlisted_pile(
&trade_ids,
|core_id| self.resolver.wire_for_owned_id(core_id),
self.resolver.as_ref(),
&lookup,
self.entities.as_ref(),
)
}
/// Dispatch a FIFA17 economy route to its Rust handler, or `None` if the path
/// is not an economy route (or no economy services are wired). This is the
/// handler-wiring entry point exercised by the integration harness; it is
@@ -2385,48 +2341,34 @@ impl Server {
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
let m = method.to_string();
bridge.block_on(async move {
crate::market::handle_market_list(
&m,
resolved,
econ.as_ref(),
market.as_ref(),
)
.await
crate::market::handle_market_list(&m, resolved, econ.as_ref(), market.as_ref())
.await
})
}
EconomyRoute::MarketQuery => {
let (bridge, market, econ) =
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
let unlisted = self.resolve_trade_pile(svc);
bridge.block_on(async move {
crate::market::handle_market_query(
"active",
econ.as_ref(),
market.as_ref(),
&unlisted,
)
.await
crate::market::handle_market_query("active", econ.as_ref(), market.as_ref())
.await
})
}
EconomyRoute::MarketCounts => {
let (bridge, market) = (svc.bridge.clone(), svc.market.clone());
bridge
.block_on(async move { crate::market::handle_market_counts(market.as_ref()).await })
bridge.block_on(async move {
crate::market::handle_market_counts(market.as_ref()).await
})
}
EconomyRoute::MarketStatus => {
let (bridge, market, econ) =
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
// The raw query carries `tradeIds`; `path` is already stripped.
let q = target.split_once('?').map(|(_, q)| q.to_string());
// ISViewTrade is asked about the same ids /tradePile advertises, so it
// needs the same pile enumeration or an unlisted row degrades.
let unlisted = self.resolve_trade_pile(svc);
bridge.block_on(async move {
crate::market::handle_market_status(
q.as_deref(),
econ.as_ref(),
market.as_ref(),
&unlisted,
)
.await
})
@@ -2436,14 +2378,8 @@ impl Server {
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
let (m, p, body) = (method.to_string(), path.to_string(), body.to_vec());
bridge.block_on(async move {
crate::market::handle_market_buy(
&m,
&p,
&body,
econ.as_ref(),
market.as_ref(),
)
.await
crate::market::handle_market_buy(&m, &p, &body, econ.as_ref(), market.as_ref())
.await
})
}
EconomyRoute::MarketCancel => {
@@ -2673,7 +2609,10 @@ impl Server {
coins,
ents.len()
);
json_status(200, &non_economy::account_sync_body(&req, coins, ents.len()))
json_status(
200,
&non_economy::account_sync_body(&req, coins, ents.len()),
)
}
/// `GET/PUT/POST …/clientdata/<key>` — opaque per-user client blob store. GET
@@ -2737,7 +2676,10 @@ impl Server {
json_status(200, &body)
}
Err(e) => {
eprintln!("utas-host owner=RUST route=userMassInfo status={} error=core", e.status);
eprintln!(
"utas-host owner=RUST route=userMassInfo status={} error=core",
e.status
);
e
}
}
@@ -2753,7 +2695,10 @@ impl Server {
json_status(200, &json!({ "userInfo": user_info }))
}
Err(e) => {
eprintln!("utas-host owner=RUST route=user status={} error=core", e.status);
eprintln!(
"utas-host owner=RUST route=user status={} error=core",
e.status
);
e
}
}
@@ -2896,7 +2841,10 @@ impl Server {
};
let (bridge, market) = (svc.bridge.clone(), svc.market.clone());
match bridge.block_on(async move { market.query_listings("active").await }) {
Ok(listings) => listings.into_iter().filter_map(|l| l.core_item_id).collect(),
Ok(listings) => listings
.into_iter()
.filter_map(|l| l.core_item_id)
.collect(),
Err(e) => {
eprintln!("utas-host WARN market read failed (club shows all): {e}");
std::collections::HashSet::new()
@@ -2994,7 +2942,10 @@ impl Server {
.and_then(|s| s.econ.balance().ok())
.unwrap_or(0);
eprintln!("utas-host owner=RUST route=watchlist method=GET status=200 credits={credits}");
json_status(200, &json!({ "auctionInfo": [], "credits": credits, "total": 0 }))
json_status(
200,
&json!({ "auctionInfo": [], "credits": credits, "total": 0 }),
)
}
/// `GET …/{season,tournament,champion,clubUser,user/list}` — FUT modes and the
@@ -4075,8 +4026,16 @@ mod tests {
("GET", "/ut/game/fifa17/tradePile", Some(MarketQuery)),
("GET", "/ut/game/fifa17/tradepile", Some(MarketQuery)),
// The tally is a DISTINCT deserializer from the listing list.
("GET", "/ut/game/fifa17/tradePile/counts", Some(MarketCounts)),
("GET", "/ut/game/fifa17/tradepile/counts", Some(MarketCounts)),
(
"GET",
"/ut/game/fifa17/tradePile/counts",
Some(MarketCounts),
),
(
"GET",
"/ut/game/fifa17/tradepile/counts",
Some(MarketCounts),
),
("POST", "/ut/game/fifa17/trade/900000001", Some(MarketBuy)),
// The live-state poll MUST NOT land in the buy/view arm: `status` is
// not a trade id, so that arm answers every poll with an empty set.
@@ -4197,7 +4156,7 @@ mod tests {
("POST", "/ut/game/fifa17/season"), // FUT-mode reads are GET-only
("GET", "/ut/game/fifa17/user/club"), // mutating rename stays Python
("POST", "/ut/game/fifa17/item/resource"), // item-defs are GET-only
("GET", "/ut/game/fifa17/marketdatafoo"), // not the marketdata route
("GET", "/ut/game/fifa17/marketdatafoo"), // not the marketdata route
];
for (m, p) in proxied {
assert_eq!(classify(m, p), Route::Passthrough, "PROXY: {m} {p}");
@@ -4212,7 +4171,10 @@ mod tests {
fn item_defs_shape_matches_oracle() {
// Two ids: the one hardcoded card (asset 20801) + a placeholder.
assert_eq!(extract_long_ints("resourceId=20801"), vec![20801]);
assert_eq!(extract_long_ints("idList=200389,200104&x=12"), vec![200389, 200104]);
assert_eq!(
extract_long_ints("idList=200389,200104&x=12"),
vec![200389, 200104]
);
assert!(extract_long_ints("foo=ab").is_empty());
let body = non_economy::item_defs_body(&[20801, 200389]);
@@ -4237,7 +4199,10 @@ mod tests {
#[test]
fn marketdata_container_types_are_load_bearing() {
// /pricelimits MUST be a bare ARRAY (object-where-array froze a live client).
assert_eq!(extract_defid_param("defId=200389,200104"), vec![200389, 200104]);
assert_eq!(
extract_defid_param("defId=200389,200104"),
vec![200389, 200104]
);
let arr = non_economy::marketdata_pricelimits_body(&[200389, 200104]);
assert!(arr.is_array(), "pricelimits must be a bare array");
let arr = arr.as_array().unwrap();
+112 -343
View File
@@ -166,7 +166,11 @@ fn auction_record_as(l: &Listing, item_state: &str) -> Value {
/// Auction record for a market/search context (`itemState: forSale`), and for the
/// closed/sold echoes the buy path returns.
fn auction_record(l: &Listing) -> Value {
let state = if l.state == "active" { "forSale" } else { "free" };
let state = if l.state == "active" {
"forSale"
} else {
"free"
};
auction_record_as(l, state)
}
@@ -251,7 +255,10 @@ pub fn resolve_market_list<E: ReverseEntityResolver>(
resource_id,
start: b.get("startingBid").and_then(Value::as_i64).unwrap_or(150),
buy_now: b.get("buyNowPrice").and_then(Value::as_i64).unwrap_or(0),
seller: b.get("sellerName").and_then(Value::as_str).map(str::to_string),
seller: b
.get("sellerName")
.and_then(Value::as_str)
.map(str::to_string),
item_json,
// FIFA 17's ISStart body carries the listing duration in seconds. We
// previously dropped it and reported a frozen `expires`, so the client's
@@ -260,107 +267,6 @@ pub fn resolve_market_list<E: ReverseEntityResolver>(
})
}
/// A single unlisted trade-pile item to expose on `/tradePile` as a NON-ACTIVE
/// auction record — the bounded Q2 experiment (see
/// docs/FIFA17_TRANSFER_MARKET_WIRE.md).
///
/// Why this shape is forced rather than chosen: the CardsDLL route table has exactly
/// ONE trade-pile route, it carries only twelve-atom auction records, `pile` has no
/// deserializer arm (membership comes from the owning list), and of those atoms only
/// `tradeState` can express lifecycle. Its vocabulary is a four-row `{const char*,
/// int}` table dumped from the PE — `active=1 inactive=2 expired=3 closed=4` — so
/// `"inactive"` is the client's own token for "in the pile, not on the market".
pub struct UnlistedCandidate {
/// FIFA wire item id.
pub item_id: i64,
/// Core owned-instance id, used to confirm the pile and the absence of an auction.
pub core_id: String,
/// The full shaped card, so the row renders like any other.
pub item_json: Option<String>,
}
/// Resolve every Core owned instance sitting in the trade pile to an
/// [`UnlistedCandidate`]. Pure identity plus one Core inventory read each, so it runs
/// OFF the async runtime exactly like [`resolve_market_list`] (the trait-object
/// resolvers are not `Send`).
///
/// `wire_for_core` MUST be a non-allocating lookup: enumerating a pile is a READ, and
/// minting a wire id for an item the client has never seen would silently consume
/// identities. An item with no existing mapping, no Core record, or no resolvable
/// FIFA identity is skipped rather than faked.
pub fn resolve_unlisted_pile<E, F>(
core_ids: &[String],
wire_for_core: F,
resolver: &dyn ItemIdentityResolver,
items: &dyn OwnedItemLookup,
ent: &E,
) -> Vec<UnlistedCandidate>
where
E: ReverseEntityResolver,
F: Fn(&str) -> Option<i64>,
{
let mut out = Vec::with_capacity(core_ids.len());
for core_id in core_ids {
let Some(item_id) = wire_for_core(core_id) else {
continue;
};
let Some(owned) = items.owned_item(core_id) else {
continue;
};
let Some(item_json) = resolver
.resolve(&owned)
.map(|id| shape_item(&owned, id, ent))
.and_then(|card| serde_json::to_string(&card).ok())
else {
continue;
};
out.push(UnlistedCandidate {
item_id,
core_id: core_id.clone(),
item_json: Some(item_json),
});
}
out
}
/// The auction record for an item sitting in the trade pile with NO auction.
///
/// `tradeState: "inactive"` is the whole point; everything else is deliberately the
/// zero/neutral value so the row cannot render a countdown or a price. `tradeId`
/// reuses the item's stable auction id, because the client keys its record store on
/// `tradeId` and re-parents `itemData` onto an existing record — so listing this
/// item later UPDATES this row rather than creating a duplicate ghost.
fn unlisted_record(c: &UnlistedCandidate) -> Value {
let item_data = c
.item_json
.as_deref()
.and_then(|s| serde_json::from_str::<Value>(s).ok())
.filter(Value::is_object)
.map(|mut card| {
card["id"] = json!(c.item_id);
// `free` is what /club emits for an unheld card; there is no evidence of a
// distinct itemState for a pile-resident item, so do not invent one.
card["itemState"] = json!("free");
card["untradeable"] = json!(false);
card
})
.unwrap_or_else(|| json!({ "id": c.item_id, "itemState": "free", "untradeable": false }));
json!({
"tradeId": TRADE_ID_BASE + c.item_id,
"itemData": item_data,
"tradeState": "inactive",
"buyNowPrice": 0,
"startingBid": 0,
"currentBid": 0,
"bidState": "none",
"expires": 0,
"sellerName": non_economy::PERSONA_DISPLAY_NAME,
"sellerEstablished": 1,
"watched": false,
"coinsProcessed": 0,
})
}
/// `/auctionhouse` — search (GET), list-for-sale (POST FutISStart), relist (PUT).
///
/// * GET returns the durable active auctions plus the FutGetAuctionCount ints,
@@ -455,10 +361,7 @@ pub async fn handle_market_list(
Ok(l) => l,
Err(_) => return json_body(503, &json!({ "error": "market_store" })),
};
let auctions: Vec<Value> = listings
.iter()
.map(auction_record)
.collect();
let auctions: Vec<Value> = listings.iter().map(auction_record).collect();
ok_json(&json!({
"auctionInfo": auctions,
"credits": credits_or_zero(econ),
@@ -480,44 +383,31 @@ pub async fn handle_market_list(
/// This is the SELLER's own pile, so each card carries `itemState: "listFS"`
/// (list-for-sale) — the state the oracle stamps on a tradePile card, distinct
/// from the `forSale` used for market search results.
///
/// ONLY real auctions appear here. An item sitting in the trade pile with no
/// auction is deliberately absent: `plan-2026-08-06-transfer-market.md:731-733`
/// says of the `tradeState` vocabulary "`inactive` decodes but no client path
/// treats it specially; do not emit it", and RE of the FUT front-end confirmed
/// why — the trade-pile movie admits only rows it classifies as auctions
/// (`getCardsInAuction`/`isInActiveAuction`) into the action path, so an
/// `inactive` row renders and can never be acted on. In the shipped game those
/// rows were a client-side transient built by the movie itself from a
/// `TO_TRADEPILE` Flash message, never server-delivered. Nothing is stranded:
/// `/club` excludes only items with an ACTIVE listing, so an unlisted pile member
/// stays visible in the club.
pub async fn handle_market_query(
state: &str,
econ: &dyn CoreEconomy,
store: &MarketStore,
unlisted: &[UnlistedCandidate],
) -> WireResponse {
let listings = match store.query_listings(state).await {
Ok(l) => l,
Err(_) => return json_body(503, &json!({ "error": "market_store" })),
};
let mut auctions: Vec<Value> = listings
let auctions: Vec<Value> = listings
.iter()
.map(|l| auction_record_as(l, "listFS"))
.collect();
// Unlisted trade-pile members, as LIVE-CONFIRMED `tradeState: "inactive"` rows.
// The caller enumerates them FROM the pile, so membership is established; what
// must still be excluded is any item whose listing state forbids advertising it as
// an owned unlisted card. A pile row OUTLIVES its auction, so after a sale the
// seller's `trade` row is stale — filtering on the active set alone would
// re-advertise a SOLD card as an unlisted item (caught by the differential).
let blocked = store.blocking_core_items().await.unwrap_or_default();
let mut inactive = 0usize;
for c in unlisted {
if blocked.contains(&c.core_id) {
continue;
}
auctions.push(unlisted_record(c));
inactive += 1;
}
if !unlisted.is_empty() {
eprintln!(
"utas-host owner=RUST route=market-query active={} inactive={inactive} pile_candidates={}",
listings.len(),
unlisted.len()
);
}
// GetTradePile shares one deserializer (0x18013e7f0) with ISSearch and
// ISWatchList, over exactly four members: `auctionInfo` (array), `credits`
// (int), `duplicateItemIdList` (array of objects) and `total` (int). We were
@@ -587,7 +477,6 @@ pub async fn handle_market_status(
query: Option<&str>,
econ: &dyn CoreEconomy,
store: &MarketStore,
unlisted: &[UnlistedCandidate],
) -> WireResponse {
let ids = trade_ids_from_query(query);
let listings = if ids.is_empty() {
@@ -604,33 +493,10 @@ pub async fn handle_market_status(
}
found
};
let mut auctions: Vec<Value> = listings
let auctions: Vec<Value> = listings
.iter()
.map(|l| auction_record_as(l, "listFS"))
.collect();
// ISViewTrade MUST resolve the SAME tradeIds `/tradePile` advertises. The client
// polls the row it is displaying and, for an unlisted pile member, there is no
// listing row to find — so answering from the market store alone returned an empty
// body and the client DEGRADED the row (Time Remaining rendered "Expired", and no
// actions were offered). Observed live: `requested=1 returned=0` on repeat for the
// selected row. A tradeId must resolve on every route that can be asked about it.
let blocked = store.blocking_core_items().await.unwrap_or_default();
let wanted: Option<Vec<i64>> = if ids.is_empty() {
None
} else {
Some(ids.iter().filter_map(|s| s.parse::<i64>().ok()).collect())
};
for c in unlisted {
if blocked.contains(&c.core_id) {
continue;
}
let trade_id = TRADE_ID_BASE + c.item_id;
let asked = wanted.as_ref().is_none_or(|w| w.contains(&trade_id));
if asked && !auctions.iter().any(|a| a["tradeId"] == json!(trade_id)) {
auctions.push(unlisted_record(c));
}
}
eprintln!(
"utas-host owner=RUST route=market-status requested={} returned={} query={}",
ids.len(),
@@ -1019,61 +885,28 @@ mod tests {
}
#[tokio::test]
async fn unlisted_candidate_is_a_non_active_pile_row() {
// Q2: an item in the trade pile with NO auction. The route table has exactly
// one trade-pile route and it carries only auction records, `pile` has no
// deserializer arm, and of the twelve atoms only `tradeState` expresses
// lifecycle — whose PE string table is `active/inactive/expired/closed`. So
// the row must be `inactive`, with no timer and no prices.
let (store, _d) = store_at("unlisted").await;
async fn the_trade_pile_advertises_only_real_auctions() {
// Regression guard for a wrong answer we shipped: an item sitting in the
// trade pile with no auction was advertised as a synthetic
// `tradeState:"inactive"` record. The corpus already forbade it
// (plan-2026-08-06-transfer-market.md:731-733, "`inactive` decodes but no
// client path treats it specially; do not emit it"), and RE of the FUT
// front-end explained why: the trade-pile movie admits only rows it
// classifies as auctions into the action path, so such a row renders and
// can never be acted on. Only auctions belong in this body.
let (store, _d) = store_at("pileonlyauctions").await;
let econ = CountingEconomy::with_balance(10_000);
let c = UnlistedCandidate {
item_id: 100_000_059,
core_id: "core-unlisted".into(),
item_json: Some(r#"{"rating":93,"preferredPosition":"RW"}"#.into()),
};
// No candidates supplied -> nothing extra is advertised.
let body = parse(&handle_market_query("active", &econ, &store, &[]).await);
assert_eq!(
body["auctionInfo"].as_array().unwrap().len(),
0,
"an empty candidate set adds nothing"
);
// Nothing listed: an empty pile, whatever the item store holds.
let body = parse(&handle_market_query("active", &econ, &store).await);
assert_eq!(body["auctionInfo"].as_array().unwrap().len(), 0);
assert_eq!(body["total"], 0);
let body = parse(&handle_market_query("active", &econ, &store, std::slice::from_ref(&c)).await);
let recs = body["auctionInfo"].as_array().unwrap();
assert_eq!(recs.len(), 1, "now in the pile, so it is exposed");
let r = &recs[0];
assert_eq!(r["tradeState"], "inactive", "the PE's token for not-on-market");
assert_eq!(r["expires"], 0, "no countdown on an item that is not listed");
assert_eq!(r["buyNowPrice"], 0);
assert_eq!(r["startingBid"], 0);
assert_eq!(r["currentBid"], 0);
assert_eq!(r["bidState"], "none");
assert_eq!(r["itemData"]["id"], 100_000_059i64);
assert_eq!(r["itemData"]["itemState"], "free");
assert_eq!(r["itemData"]["rating"], 93, "the full card still renders");
// Stable tradeId: the client keys its record store on it, so listing this
// item later must UPDATE this row rather than add a duplicate ghost.
assert_eq!(r["tradeId"], TRADE_ID_BASE + 100_000_059);
// Still exactly the twelve atoms.
let mut keys: Vec<&str> = r.as_object().unwrap().keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(keys.len(), 12, "no extra atoms on the inactive row");
}
#[tokio::test]
async fn unlisted_candidate_never_duplicates_a_real_auction() {
// If the item already owns a listing, advertising it as `inactive` too would
// put two records with the same tradeId in one body.
let (store, _d) = store_at("unlistedduped").await;
let econ = CountingEconomy::with_balance(10_000);
store
.create_listing(
&(TRADE_ID_BASE + 100_000_059).to_string(),
"169193",
Some("core-unlisted"),
Some("core-listed"),
Some(100_000_059),
Some(169193),
150,
@@ -1084,94 +917,31 @@ mod tests {
)
.await
.unwrap();
let c = UnlistedCandidate {
item_id: 100_000_059,
core_id: "core-unlisted".into(),
item_json: None,
};
let body = parse(&handle_market_query("active", &econ, &store, std::slice::from_ref(&c)).await);
let body = parse(&handle_market_query("active", &econ, &store).await);
let recs = body["auctionInfo"].as_array().unwrap();
assert_eq!(recs.len(), 1, "the real auction only");
assert_eq!(recs.len(), 1, "the real auction, and nothing synthetic");
assert_eq!(recs[0]["tradeState"], "active");
}
#[tokio::test]
async fn a_sold_or_in_flight_card_is_never_advertised_as_an_unlisted_member() {
// A pile row OUTLIVES its auction: after a sale the seller's `trade` row is
// stale. Filtering only on ACTIVE listings therefore re-advertised a SOLD card
// as an owned unlisted item — caught by the differential, not by unit tests,
// because it needs the full list -> sell -> poll sequence.
let (store, _d) = store_at("soldpile").await;
let econ = CountingEconomy::with_balance(10_000);
let mk = |id: &str, core: &str| {
let store = store.clone();
let (id, core) = (id.to_string(), core.to_string());
async move {
store
.create_listing(
&id,
"169193",
Some(&core),
None,
None,
150,
2500,
None,
None,
None,
)
.await
.unwrap();
}
};
mk("900000070", "core-sold").await;
mk("900000071", "core-inflight").await;
mk("900000072", "core-cancelled").await;
store.reserve_listing("900000070").await.unwrap();
store.complete_sale("900000070").await.unwrap();
store.reserve_listing("900000071").await.unwrap();
store.cancel_listing("900000072", None).await.unwrap();
let cands: Vec<UnlistedCandidate> = ["core-sold", "core-inflight", "core-cancelled"]
.iter()
.enumerate()
.map(|(i, core)| UnlistedCandidate {
item_id: 100_000_200 + i as i64,
core_id: (*core).to_string(),
item_json: None,
})
.collect();
let body = parse(&handle_market_query("active", &econ, &store, &cands).await);
let recs = body["auctionInfo"].as_array().unwrap();
let states: Vec<&str> = recs
.iter()
.map(|r| r["tradeState"].as_str().unwrap())
.collect();
assert_eq!(
states,
vec!["inactive"],
"only the CANCELLED card returns to the pile as unlisted; sold and \
in-flight cards must not be advertised as owned"
);
assert_eq!(
recs[0]["itemData"]["id"], 100_000_202i64,
"and it is specifically the cancelled one"
);
// The store-level rule, asserted directly.
let blocked = store.blocking_core_items().await.unwrap();
assert!(blocked.contains("core-sold"));
assert!(blocked.contains("core-inflight"));
assert!(
!blocked.contains("core-cancelled"),
"a cancelled listing means the card is back in the pile"
recs.iter().all(|r| r["tradeState"] != "inactive"),
"`inactive` must never appear on the wire: {recs:#?}"
);
}
async fn seed_listing(store: &MarketStore, id: &str, buy_now: i64) {
store
.create_listing(id, "169193", None, None, Some(169193), 400, buy_now, None, None, None)
.create_listing(
id,
"169193",
None,
None,
Some(169193),
400,
buy_now,
None,
None,
None,
)
.await
.unwrap();
}
@@ -1220,7 +990,7 @@ mod tests {
assert_eq!(snap["preferredPosition"], "ST");
assert_eq!(snap["attributeList"].as_array().unwrap().len(), 6);
// tradePile embeds that full card and stamps the seller-pile state.
let pile = handle_market_query("active", &econ, &store, &[]).await;
let pile = handle_market_query("active", &econ, &store).await;
let rec = parse(&pile)["auctionInfo"][0].clone();
assert_eq!(rec["itemData"]["itemState"], "listFS");
assert_eq!(rec["itemData"]["rating"], 84);
@@ -1260,7 +1030,10 @@ mod tests {
&items,
&NoEntities,
);
assert!(resolved.is_none(), "unresolved item must not build a listing");
assert!(
resolved.is_none(),
"unresolved item must not build a listing"
);
let resp = handle_market_list("POST", resolved, &econ, &store).await;
assert_eq!(resp.status, 200);
assert_eq!(parse(&resp)["id"].as_i64().unwrap(), TRADE_ID_BASE);
@@ -1317,7 +1090,7 @@ mod tests {
let (store, _d) = store_at("query").await;
seed_listing(&store, "900000005", 2500).await;
let econ = CountingEconomy::with_balance(50);
let resp = handle_market_query("active", &econ, &store, &[]).await;
let resp = handle_market_query("active", &econ, &store).await;
let b = parse(&resp);
assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1);
assert_eq!(b["auctionInfo"][0]["tradeId"], 900000005i64);
@@ -1367,9 +1140,14 @@ mod tests {
let econ = CountingEconomy::with_balance(10_000);
seed_listing(&store, "900000030", 2500).await;
let body = parse(&handle_market_query("active", &econ, &store, &[]).await);
let body = parse(&handle_market_query("active", &econ, &store).await);
let rec = body["auctionInfo"][0].clone();
let mut got: Vec<&str> = rec.as_object().unwrap().keys().map(String::as_str).collect();
let mut got: Vec<&str> = rec
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
got.sort_unstable();
assert_eq!(
got,
@@ -1400,7 +1178,12 @@ mod tests {
// GetTradePile shares the IS-list body: four members, including the
// `duplicateItemIdList` we used to omit.
let mut env: Vec<&str> = body.as_object().unwrap().keys().map(String::as_str).collect();
let mut env: Vec<&str> = body
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
env.sort_unstable();
assert_eq!(
env,
@@ -1453,7 +1236,7 @@ mod tests {
seed_listing(&store, "900000031", 2500).await;
// No filter: answer with the player's own active pile.
let all = parse(&handle_market_status(None, &econ, &store, &[]).await);
let all = parse(&handle_market_status(None, &econ, &store).await);
assert_eq!(
all["auctionInfo"].as_array().unwrap().len(),
1,
@@ -1466,15 +1249,12 @@ mod tests {
assert!(all["credits"].is_i64());
// Explicit tradeIds filter returns exactly the requested auction.
let one = parse(
&handle_market_status(Some("tradeIds=900000031"), &econ, &store, &[]).await,
);
let one = parse(&handle_market_status(Some("tradeIds=900000031"), &econ, &store).await);
assert_eq!(one["auctionInfo"].as_array().unwrap().len(), 1);
assert_eq!(one["auctionInfo"][0]["tradeId"], 900_000_031i64);
// An unknown id is absent, not an error: the poll must never fail closed.
let miss =
parse(&handle_market_status(Some("tradeIds=900000099"), &econ, &store, &[]).await);
let miss = parse(&handle_market_status(Some("tradeIds=900000099"), &econ, &store).await);
assert_eq!(miss["auctionInfo"].as_array().unwrap().len(), 0);
// Garbage is skipped rather than poisoning the whole poll.
@@ -1485,50 +1265,24 @@ mod tests {
}
#[tokio::test]
async fn trade_status_resolves_the_unlisted_ids_tradepile_advertises() {
// A tradeId must resolve on EVERY route that can be asked about it. The client
// polls /trade/status for the row it is displaying; when /tradePile advertised
// an unlisted id but /trade/status answered with an empty body, the client
// DEGRADED the row — Time Remaining rendered "Expired" and no actions were
// offered. Observed live as `requested=1 returned=0` on repeat for the selected
// row, while the same id was present in /tradePile.
let (store, _d) = store_at("statusunlisted").await;
async fn trade_status_answers_only_about_real_auctions() {
// The companion to `the_trade_pile_advertises_only_real_auctions`: since the
// pile no longer advertises synthetic ids, ISVIEWTRADE has none to resolve.
// An id it knows nothing about is simply absent from the reply rather than
// being answered with an invented record.
let (store, _d) = store_at("statusonlyauctions").await;
let econ = CountingEconomy::with_balance(10_000);
let c = UnlistedCandidate {
item_id: 100_000_122,
core_id: "core-pile".into(),
item_json: Some(r#"{"rating":90,"preferredPosition":"CM"}"#.into()),
};
let cands = std::slice::from_ref(&c);
let trade_id = TRADE_ID_BASE + 100_000_122;
// Asked for explicitly: it must come back, not be silently absent.
let one = parse(
&handle_market_status(Some(&format!("tradeIds={trade_id}")), &econ, &store, cands)
.await,
);
let recs = one["auctionInfo"].as_array().unwrap();
assert_eq!(recs.len(), 1, "the polled unlisted id must resolve");
assert_eq!(recs[0]["tradeId"], trade_id);
assert_eq!(recs[0]["tradeState"], "inactive");
assert_eq!(recs[0]["expires"], 0);
// Unfiltered poll: still present.
let all = parse(&handle_market_status(None, &econ, &store, cands).await);
assert_eq!(all["auctionInfo"].as_array().unwrap().len(), 1);
// A DIFFERENT id was asked for: the unlisted row must not be volunteered.
let other = parse(
&handle_market_status(Some("tradeIds=900000999"), &econ, &store, cands).await,
let unknown = parse(
&handle_market_status(Some(&format!("tradeIds={trade_id}")), &econ, &store).await,
);
assert_eq!(
other["auctionInfo"].as_array().unwrap().len(),
unknown["auctionInfo"].as_array().unwrap().len(),
0,
"only the ids actually asked about are answered"
"an id with no auction resolves to nothing, not to a synthetic row"
);
// Once the item owns a real auction, the auction wins and the id is not
// duplicated by an inactive row.
store
.create_listing(
&trade_id.to_string(),
@@ -1544,12 +1298,13 @@ mod tests {
)
.await
.unwrap();
let listed = parse(
&handle_market_status(Some(&format!("tradeIds={trade_id}")), &econ, &store, cands)
.await,
let one = parse(
&handle_market_status(Some(&format!("tradeIds={trade_id}")), &econ, &store).await,
);
let recs = listed["auctionInfo"].as_array().unwrap();
assert_eq!(recs.len(), 1, "exactly one record for one tradeId");
let recs = one["auctionInfo"].as_array().unwrap();
assert_eq!(recs.len(), 1, "now there is a real auction to answer with");
assert_eq!(recs[0]["tradeId"], trade_id);
assert_eq!(recs[0]["tradeState"], "active");
}
@@ -1742,7 +1497,18 @@ mod tests {
let resolver = MapResolver::new(&[(1, "core-a"), (2, "core-b")]);
for (id, core) in [("900000060", "core-a"), ("900000061", "core-b")] {
market
.create_listing(id, "169193", Some(core), None, None, 400, 2500, None, None, None)
.create_listing(
id,
"169193",
Some(core),
None,
None,
400,
2500,
None,
None,
None,
)
.await
.unwrap();
}
@@ -1750,7 +1516,8 @@ mod tests {
market.reserve_listing("900000061").await.unwrap();
market.complete_sale("900000061").await.unwrap();
let body = json!({ "itemData": [{ "id": 1, "pile": "club" }, { "id": 2, "pile": "club" }] });
let body =
json!({ "itemData": [{ "id": 1, "pile": "club" }, { "id": 2, "pile": "club" }] });
handle_move_items(body.to_string().as_bytes(), &resolver, &piles, &market).await;
assert_eq!(
market.get_listing("900000060").await.unwrap().state,
@@ -1772,7 +1539,8 @@ mod tests {
let resolver = MapResolver::new(&[(100004617, "core-uuid-7")]);
let to_trade = json!({ "itemData": [{ "id": 100004617, "pile": "trade" }] });
let resp = handle_move_items(to_trade.to_string().as_bytes(), &resolver, &piles, &market).await;
let resp =
handle_move_items(to_trade.to_string().as_bytes(), &resolver, &piles, &market).await;
assert_eq!(resp.status, 200);
let b = parse(&resp);
assert_eq!(b["itemData"][0]["success"], true);
@@ -1785,7 +1553,8 @@ mod tests {
// Move back to the club.
let to_club = json!({ "itemData": [{ "id": 100004617, "pile": "club" }] });
let resp2 = handle_move_items(to_club.to_string().as_bytes(), &resolver, &piles, &market).await;
let resp2 =
handle_move_items(to_club.to_string().as_bytes(), &resolver, &piles, &market).await;
assert_eq!(parse(&resp2)["itemData"][0]["success"], true);
assert_eq!(
piles.get("core-uuid-7").await.unwrap().as_deref(),
+25 -31
View File
@@ -29,7 +29,6 @@
//! `CHECK` constraint even though it is a transient intermediate — omitting it
//! would make [`MarketStore::reserve_listing`] fail the constraint.
use std::collections::HashSet;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
@@ -285,10 +284,7 @@ impl MarketStore {
.iter()
.map(|r| r.get::<String, _>("name"))
.collect();
for (col, decl) in [
("item_json", "TEXT"),
("duration_secs", "INTEGER"),
] {
for (col, decl) in [("item_json", "TEXT"), ("duration_secs", "INTEGER")] {
if !existing.iter().any(|c| c == col) {
sqlx::query(&format!("ALTER TABLE listings ADD COLUMN {col} {decl}"))
.execute(&pool)
@@ -620,30 +616,6 @@ impl MarketStore {
.map_err(db)?
.rows_affected())
}
/// Core owned-instance ids whose listing state FORBIDS advertising them as an
/// unlisted pile member: `active` (a real auction is shown instead), `reserved`
/// (a sale is in flight) and `sold` (the card is gone).
///
/// `cancelled` is deliberately absent: a cancelled listing means the card came
/// back to the pile and IS an unlisted member again.
///
/// Needed because a pile row outlives its auction — after a sale the seller's
/// `trade` row is stale, and filtering on the active set alone would re-advertise
/// a sold card as an owned unlisted item.
pub async fn blocking_core_items(&self) -> Result<HashSet<String>, MarketError> {
let rows = sqlx::query(
"SELECT DISTINCT core_item_id FROM listings \
WHERE core_item_id IS NOT NULL AND state IN ('active','reserved','sold')",
)
.fetch_all(&self.pool)
.await
.map_err(db)?;
Ok(rows
.iter()
.filter_map(|r| r.get::<Option<String>, _>("core_item_id"))
.collect())
}
}
#[cfg(test)]
@@ -769,7 +741,18 @@ mod tests {
async fn seed(store: &MarketStore, id: &str) -> Listing {
store
.create_listing(id, "card_pl_001", None, None, None, 900, 2500, None, None, None)
.create_listing(
id,
"card_pl_001",
None,
None,
None,
900,
2500,
None,
None,
None,
)
.await
.unwrap()
}
@@ -800,7 +783,18 @@ mod tests {
seed(&store, "900000001").await;
assert!(matches!(
store
.create_listing("900000001", "card_pl_002", None, None, None, 1, 2, None, None, None)
.create_listing(
"900000001",
"card_pl_002",
None,
None,
None,
1,
2,
None,
None,
None
)
.await,
Err(MarketError::Conflict)
));