fix(fifa17): map market resource ids to authoritative Core card ids

Closes the market correctness gap: handle_market_list recorded listing.card_id
from the raw FIFA wire resourceId, so a synthetic buy minted a card_id Core
could not resolve — it survived the immediate response but Core's content
preflight rejected it on reboot.

- catalog.rs: keep the by_resource reverse index (was built then discarded) and
  expose `card_id_for_resource(resource_id) -> Option<&str>` — exact reverse of
  the card_id->asset catalog, no heuristics, unknown => None.
- lib.rs: `impl MarketCardResolver for Fifa17IdentityResolver` delegates to the
  same catalog /club shaping uses; Core never sees a FIFA resource id.
- market_store.rs: listings now carry BOTH `card_id` (authoritative Core content,
  what a buy MINTS) and `wire_resource_id` (the FIFA wire id, echoed in the
  auction record). New column; create_listing takes both; row/Listing updated.
- market.rs: `MarketCardResolver` trait; handle_market_list resolves resourceId
  -> Core card_id and fails closed (persists nothing) on an unmappable resource;
  auction_record emits `resourceId` from wire_resource_id. Dispatch passes the
  resolver.

Tests: list_unknown_resource_fails_closed_no_listing (B),
list_persists_core_card_and_wire_resource_across_reopen (C), catalog reverse
lookup; and the dispatch E2E now RESTORES the full Core+store restart
(economy_full_sequence_through_dispatch_and_restart) — the synthetic buy mints a
real reverse-mapped card_id, so Core's content preflight passes on reboot (A+D).
market 23 lib + catalog 15 + 2 integration green; clippy -D warnings + fmt clean.
This commit is contained in:
OpenFUT Agent
2026-08-13 21:43:48 +00:00
parent 884ecbba64
commit fe72f0def2
5 changed files with 217 additions and 51 deletions
+25 -4
View File
@@ -863,6 +863,17 @@ impl SquadWireResolver for Fifa17IdentityResolver {
}
}
/// Reverse a FIFA wire `resourceId` to the authoritative Core `card_id`, via the
/// same catalog `/club` shaping uses — so a synthetic market buy mints real Core
/// content, never the raw FIFA number. Out-of-range or unmapped → `None`
/// (fail closed; Core never sees a FIFA resource id).
impl crate::market::MarketCardResolver for Fifa17IdentityResolver {
fn card_id_for_resource(&self, resource_id: i64) -> Option<String> {
let rid = u32::try_from(resource_id).ok()?;
self.catalog.card_id_for_resource(rid).map(str::to_string)
}
}
// ───────────────────────────── /club handler ────────────────────────────────
/// Safe, structured summary of a handled `/club` request (no auth/session/device
@@ -1914,12 +1925,22 @@ impl Server {
})
}
EconomyRoute::MarketList => {
let (bridge, market, econ) =
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
let (bridge, market, econ, resolver) = (
svc.bridge.clone(),
svc.market.clone(),
svc.econ.clone(),
self.resolver.clone(),
);
let (m, body) = (method.to_string(), body.to_vec());
bridge.block_on(async move {
crate::market::handle_market_list(&m, &body, econ.as_ref(), market.as_ref())
.await
crate::market::handle_market_list(
&m,
&body,
econ.as_ref(),
market.as_ref(),
resolver.as_ref(),
)
.await
})
}
EconomyRoute::MarketQuery => {
+122 -20
View File
@@ -68,14 +68,9 @@ fn trade_id_from_path(path: &str) -> Option<String> {
/// from durable listing state rather than a hardcoded sample pool.
fn auction_record(l: &Listing) -> Value {
let trade_id: i64 = l.listing_id.parse().unwrap_or(0);
// resourceId is the card definition when numeric; fall back to the wire
// item id. Never a fabricated FIFA asset — 0 means "no art", a valid int.
let resource = l
.card_id
.parse::<i64>()
.ok()
.or(l.wire_item_id)
.unwrap_or(0);
// resourceId is the FIFA wire identity the client listed (never the Core
// card id). 0 means "no art", a valid int — never a fabricated FIFA asset.
let resource = l.wire_resource_id.or(l.wire_item_id).unwrap_or(0);
let item_id = l.wire_item_id.unwrap_or(trade_id);
let (trade_state, item_state, bid_state, current_bid) = match l.state.as_str() {
"active" => ("active", "forSale", "none", 0),
@@ -124,18 +119,27 @@ fn credits_or_zero(econ: &dyn CoreEconomy) -> i64 {
off_runtime(|| econ.balance()).unwrap_or(0)
}
/// Maps a FIFA wire `resourceId` to the authoritative Core `card_id` a synthetic
/// buy mints. Backed by the FIFA17 catalog reverse index; unknown → `None`
/// (fail closed, never fabricated). Core stays unaware of FIFA resource ids.
pub trait MarketCardResolver: Send + Sync {
fn card_id_for_resource(&self, resource_id: i64) -> Option<String>;
}
/// `/auctionhouse` — search (GET), list-for-sale (POST FutISStart), relist (PUT).
///
/// * GET returns the durable active auctions plus the FutGetAuctionCount ints,
/// in the oracle's `_market_body` shape.
/// * POST lists an owned club item and returns `{"id": tradeId}`; the listing is
/// persisted so a later buy/cancel is durable.
/// * POST lists a club item: resolve its wire `resourceId` to the authoritative
/// Core `card_id`, persist both, and return `{"id": tradeId}`. An unmappable
/// resource fails closed (persists nothing).
/// * PUT (relist-all) is an ack `{}`.
pub async fn handle_market_list(
method: &str,
body: &[u8],
econ: &dyn CoreEconomy,
store: &MarketStore,
mapper: &dyn MarketCardResolver,
) -> WireResponse {
match method {
"POST" => {
@@ -151,13 +155,19 @@ pub async fn handle_market_list(
// No item to list: mirror the oracle's fresh-id ack, persist nothing.
return ok_json(&json!({ "id": TRADE_ID_BASE }));
};
// Card definition, if the client sent the full item; else the wire id
// string (a user listing does not drive the synthetic-seller mint).
let card_id = item_data
// Resolve the FIFA wire resourceId to the authoritative Core card id
// the synthetic buy will MINT. Fail closed on an unmappable resource:
// persist nothing (a non-existent listing cannot be bought), so a bad
// resource never becomes a mint Core's content preflight would reject.
let Some(resource_id) = item_data
.and_then(|d| d.get("resourceId"))
.and_then(Value::as_i64)
.map(|r| r.to_string())
.unwrap_or_else(|| item_id.to_string());
else {
return ok_json(&json!({ "id": TRADE_ID_BASE }));
};
let Some(core_card_id) = mapper.card_id_for_resource(resource_id) else {
return ok_json(&json!({ "id": TRADE_ID_BASE }));
};
// Trade-id space is offset from the wire item id, so each owned item
// maps to a unique, stable auction id (no modular wraparound).
let trade_id = TRADE_ID_BASE + item_id;
@@ -166,9 +176,10 @@ pub async fn handle_market_list(
match store
.create_listing(
&listing_id,
&card_id,
&core_card_id,
None,
Some(item_id),
Some(resource_id),
start,
buy_now,
seller,
@@ -514,6 +525,24 @@ mod tests {
}
}
/// Permissive test resolver: maps any wire resourceId to its own string, so
/// the existing list tests keep their prior card-id semantics. A dedicated
/// test covers the unknown-resource fail-closed path.
struct AllowAllResolver;
impl MarketCardResolver for AllowAllResolver {
fn card_id_for_resource(&self, resource_id: i64) -> Option<String> {
Some(resource_id.to_string())
}
}
/// Test resolver that maps nothing (every resourceId is unknown).
struct DenyAllResolver;
impl MarketCardResolver for DenyAllResolver {
fn card_id_for_resource(&self, _resource_id: i64) -> Option<String> {
None
}
}
async fn store_at(tag: &str) -> (MarketStore, TempDb) {
let db = TempDb::new(tag);
let store = MarketStore::open(db.path()).await.unwrap();
@@ -522,7 +551,7 @@ mod tests {
async fn seed_listing(store: &MarketStore, id: &str, buy_now: i64) {
store
.create_listing(id, "169193", None, None, 400, buy_now, None)
.create_listing(id, "169193", None, None, Some(169193), 400, buy_now, None)
.await
.unwrap();
}
@@ -539,14 +568,21 @@ mod tests {
let econ = CountingEconomy::with_balance(10_000);
let body = json!({ "itemData": { "id": 100004617, "resourceId": 169193 },
"startingBid": 300, "buyNowPrice": 2500 });
let resp = handle_market_list("POST", body.to_string().as_bytes(), &econ, &store).await;
let resp = handle_market_list(
"POST",
body.to_string().as_bytes(),
&econ,
&store,
&AllowAllResolver,
)
.await;
assert_eq!(resp.status, 200);
let trade_id = parse(&resp)["id"].as_i64().unwrap();
assert_eq!(trade_id, TRADE_ID_BASE + 100004617);
// Persisted + browsable.
let listed = store.get_listing(&trade_id.to_string()).await.unwrap();
assert_eq!(listed.buy_now_price, 2500);
let browse = handle_market_list("GET", b"", &econ, &store).await;
let browse = handle_market_list("GET", b"", &econ, &store, &AllowAllResolver).await;
let b = parse(&browse);
assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1);
assert_eq!(b["credits"], 10_000);
@@ -557,11 +593,77 @@ mod tests {
async fn list_put_is_ack() {
let (store, _d) = store_at("put").await;
let econ = CountingEconomy::with_balance(0);
let resp = handle_market_list("PUT", b"", &econ, &store).await;
let resp = handle_market_list("PUT", b"", &econ, &store, &AllowAllResolver).await;
assert_eq!(resp.status, 200);
assert_eq!(parse(&resp), json!({}));
}
#[tokio::test]
async fn list_unknown_resource_fails_closed_no_listing() {
// An unmappable wire resourceId must NOT create a listing (a synthetic buy
// would otherwise mint a card id Core cannot resolve). Acks neutrally.
let (store, _d) = store_at("deny").await;
let econ = CountingEconomy::with_balance(10_000);
let body = json!({ "itemData": { "id": 100004617, "resourceId": 424242 },
"startingBid": 300, "buyNowPrice": 2500 });
let resp = handle_market_list(
"POST",
body.to_string().as_bytes(),
&econ,
&store,
&DenyAllResolver,
)
.await;
assert_eq!(resp.status, 200);
assert_eq!(parse(&resp)["id"].as_i64().unwrap(), TRADE_ID_BASE);
// Nothing persisted at the would-be trade id: not buyable.
assert!(matches!(
store
.get_listing(&(TRADE_ID_BASE + 100004617).to_string())
.await,
Err(MarketError::NotFound)
));
}
#[tokio::test]
async fn list_persists_core_card_and_wire_resource_across_reopen() {
// The listing carries BOTH the authoritative Core card_id (for the mint)
// and the FIFA wire resourceId (for the auction record), durably.
let db = TempDb::new("reopen-map");
let trade_id;
{
let store = MarketStore::open(db.path()).await.unwrap();
let econ = CountingEconomy::with_balance(10_000);
// A resolver that maps resourceId 20801 -> Core card_id "card_pl_042".
struct FixedResolver;
impl MarketCardResolver for FixedResolver {
fn card_id_for_resource(&self, resource_id: i64) -> Option<String> {
(resource_id == 20801).then(|| "card_pl_042".to_string())
}
}
let body = json!({ "itemData": { "id": 100004900, "resourceId": 20801 },
"startingBid": 300, "buyNowPrice": 2500 });
let resp = handle_market_list(
"POST",
body.to_string().as_bytes(),
&econ,
&store,
&FixedResolver,
)
.await;
trade_id = parse(&resp)["id"].as_i64().unwrap();
}
// Reopen from the same file: both identities survive.
let reopened = MarketStore::open(db.path()).await.unwrap();
let l = reopened.get_listing(&trade_id.to_string()).await.unwrap();
assert_eq!(l.card_id, "card_pl_042", "Core card_id minted on buy");
assert_eq!(
l.wire_resource_id,
Some(20801),
"wire resourceId for the record"
);
}
#[tokio::test]
async fn query_returns_active_pile() {
let (store, _d) = store_at("query").await;
+16 -4
View File
@@ -76,12 +76,17 @@ fn db(e: sqlx::Error) -> MarketError {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Listing {
pub listing_id: String,
/// Authoritative Core content/card id — what a synthetic buy MINTS. Distinct
/// from the FIFA wire `resourceId` (see `wire_resource_id`).
pub card_id: String,
/// Set for a seller-listed owned item; `None` for a synthetic-seller
/// listing (the buy path mints a fresh Core item instead of transferring).
pub core_item_id: Option<String>,
/// The FIFA wire item id of a seller-listed owned item, if any.
pub wire_item_id: Option<i64>,
/// The FIFA wire `resourceId` (versioned) the client listed, echoed back in
/// the auction record. Never used to mint — the mint uses `card_id`.
pub wire_resource_id: Option<i64>,
pub start_price: i64,
pub buy_now_price: i64,
/// Opaque seller identity; `None` for synthetic listings.
@@ -97,6 +102,7 @@ const CREATE_LISTINGS: &str = "CREATE TABLE IF NOT EXISTS listings (
card_id TEXT NOT NULL,
core_item_id TEXT,
wire_item_id INTEGER,
wire_resource_id INTEGER,
start_price INTEGER NOT NULL,
buy_now_price INTEGER NOT NULL,
owner TEXT,
@@ -119,6 +125,7 @@ fn row_to_listing(row: &sqlx::sqlite::SqliteRow) -> Listing {
card_id: row.get("card_id"),
core_item_id: row.get("core_item_id"),
wire_item_id: row.get("wire_item_id"),
wire_resource_id: row.get("wire_resource_id"),
start_price: row.get("start_price"),
buy_now_price: row.get("buy_now_price"),
owner: row.get("owner"),
@@ -177,6 +184,7 @@ impl MarketStore {
card_id: &str,
core_item_id: Option<&str>,
wire_item_id: Option<i64>,
wire_resource_id: Option<i64>,
start_price: i64,
buy_now_price: i64,
owner: Option<&str>,
@@ -189,13 +197,14 @@ impl MarketStore {
.map_err(db)?;
let res = sqlx::query(
"INSERT INTO listings (listing_id, card_id, core_item_id, wire_item_id, \
start_price, buy_now_price, owner, state, created_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?)",
wire_resource_id, start_price, buy_now_price, owner, state, created_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?)",
)
.bind(listing_id)
.bind(card_id)
.bind(core_item_id)
.bind(wire_item_id)
.bind(wire_resource_id)
.bind(start_price)
.bind(buy_now_price)
.bind(owner)
@@ -213,6 +222,7 @@ impl MarketStore {
card_id: card_id.to_string(),
core_item_id: core_item_id.map(str::to_string),
wire_item_id,
wire_resource_id,
start_price,
buy_now_price,
owner: owner.map(str::to_string),
@@ -425,7 +435,7 @@ mod tests {
async fn seed(store: &MarketStore, id: &str) -> Listing {
store
.create_listing(id, "card_pl_001", None, None, 900, 2500, None)
.create_listing(id, "card_pl_001", None, None, None, 900, 2500, None)
.await
.unwrap()
}
@@ -456,7 +466,7 @@ mod tests {
seed(&store, "900000001").await;
assert!(matches!(
store
.create_listing("900000001", "card_pl_002", None, None, 1, 2, None)
.create_listing("900000001", "card_pl_002", None, None, None, 1, 2, None)
.await,
Err(MarketError::Conflict)
));
@@ -516,6 +526,7 @@ mod tests {
"card_pl_001",
None,
None,
None,
900,
2500,
Some("alice"),
@@ -586,6 +597,7 @@ mod tests {
"card_pl_001",
Some("core-7"),
Some(100004617),
Some(169193),
900,
2500,
Some("alice"),