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
56 changes: 56 additions & 0 deletions crates/core/src/chain/backends.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8707,6 +8707,62 @@ impl ChainBackend for RealSellerBackend {
)))
}

async fn seller_offer_outcome_since(
&self,
tc: &TokenContract,
since: u64,
) -> Result<Option<SellOfferOutcome>, ChainError> {
// This path is used by a durable submission marker after a restart.
// Do not read `offer_post_started_at` here: it belongs to the former
// process and therefore cannot establish an event boundary safely.
let ob = retry_seller_read("seller marker outcome order-book address", || async {
self.chain
.inference_orderbook_address(&self.note, &self.model_hash, self.tick_size)
.await
.map_err(map_err)
})
.await?;
let tc_addr = parse_tc(tc)?;
let events = retry_seller_read("seller marker-bounded outcome events", || async {
self.chain
.seller_offer_events_since(&self.note, &ob, &tc_addr, since)
.await
.map_err(map_err)
})
.await?;
let matched_state = retry_seller_read("seller marker immediate-match state", || async {
self.read_openable_match_once(tc).await
})
.await?
.is_some();
match classify_seller_offer_outcome(events, matched_state) {
Ok(outcome) => Ok(outcome),
Err(ChainError::DuplicateSell(_)) => {
let latch =
retry_seller_read("seller marker TokenContract offer latch", || async {
self.chain
.token_contract_offer(&tc_addr)
.await
.map_err(map_err)
})
.await?;
Err(duplicate_sell_from_offer_latch(&tc_addr, latch))
}
Err(other) => Err(other),
}
}

async fn seller_offer_latch(
&self,
token_contract: &TokenContract,
) -> Result<Option<DealOfferLatch>, ChainError> {
let tc = parse_tc(token_contract)?;
retry_seller_read("seller TokenContract offer latch", || async {
self.chain.token_contract_offer(&tc).await.map_err(map_err)
})
.await
}

async fn sell_offer_terms(
&self,
token_contract: &TokenContract,
Expand Down
27 changes: 27 additions & 0 deletions crates/core/src/market/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,33 @@ pub trait ChainBackend: Send + Sync {
) -> Result<Option<SellOfferOutcome>, ChainError> {
Ok(None)
}
/// Read the owner-note placement/match facts for one exact TokenContract
/// from a durable event lower bound. This is intentionally separate from
/// `confirm_offer_outcome`: reconciliation after a service restart cannot
/// depend on a backend-local "post started" timestamp that vanished with
/// the old process. A backend which cannot make this authoritative read
/// must return an error so a persisted seller marker remains fail-closed.
async fn seller_offer_outcome_since(
&self,
_token_contract: &TokenContract,
_since_unix: u64,
) -> Result<Option<SellOfferOutcome>, ChainError> {
Err(ChainError::Chain(
"marker-bounded seller offer event reconciliation is not supported by this backend"
.to_string(),
))
}
/// Read the exact deal's post-offer latch. This is deliberately separate from a
/// book row: a successful `postSellOffer` can be accepted before an index/read
/// path catches up, and a returned placement value is not by itself a proof that
/// a successor post is safe. Backends which cannot read the live deal return
/// `None`; a persisted real-seller submission must then remain unconfirmed.
async fn seller_offer_latch(
&self,
_token_contract: &TokenContract,
) -> Result<Option<DealOfferLatch>, ChainError> {
Ok(None)
}
/// Read the authoritative sell-offer terms for a real per-deal `TokenContract`. The real seller path uses
/// this before posting an ask so CLI defaults/prompts cannot diverge from the already-deployed TC config.
/// Mock backends have no on-chain TC config, so they return `None`.
Expand Down
9 changes: 9 additions & 0 deletions crates/dexdo/src/cli/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,15 @@ pub(crate) struct SellerArgs {
/// to the legacy XDG/Windows config path. Real seller startup fails closed if missing or incomplete.
#[arg(long)]
pub(crate) policy: Option<PathBuf>,
/// Recovery escape hatch for a retained publication marker. The value must
/// exactly name the selected TokenContract and is accepted only with the
/// separate confirmation flag; service/timer units must never set it.
#[arg(long, value_name = "TOKEN_CONTRACT")]
pub(crate) recover_publication: Option<String>,
/// Acknowledge that `--recover-publication` creates an immutable audit and
/// permits exactly one fresh explicit seller post after exact-negative proof.
#[arg(long, requires = "recover_publication")]
pub(crate) confirm_recover_publication: bool,
}

impl SellerArgs {
Expand Down
99 changes: 88 additions & 11 deletions crates/dexdo/src/cli/seller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,9 @@ struct SellerPoolContext<'a> {
gateway_advertise: &'a str,
/// how a failed `advertised_gateway` self-probe is treated.
advertise_probe: dexdo::seller::liveness::AdvertiseProbePolicy,
/// Present only for a human-invoked seller command which supplied both
/// explicit recovery flags. Timer/controller startup constructs `None`.
recover_publication: Option<&'a str>,
}

fn save_pool_deal_handle(context: &SellerPoolContext<'_>, deal: &SellerPoolDeal) -> Result<()> {
Expand Down Expand Up @@ -2248,17 +2251,37 @@ where
dexdo::seller::SellerOfferInspection::Funded
| dexdo::seller::SellerOfferInspection::Vacant => None,
};
let startup = match dexdo::seller::liveness::prepare_seller_offer_with_liveness(
seller,
deal.chain.as_ref(),
&deal.cfg,
context.note_addr,
inspected_identity.as_ref(),
shutdown.as_mut(),
context.advertise_probe,
)
.await?
{
let manual_recovery = context.recover_publication.is_some_and(|token_contract| {
token_contract.eq_ignore_ascii_case(&deal.cfg.token_contract)
});
let startup = match if manual_recovery {
dexdo::seller::liveness::prepare_seller_offer_with_audited_manual_recovery_liveness(
seller,
deal.chain.as_ref(),
&deal.cfg,
context.note_addr,
inspected_identity.as_ref(),
&deal.watch.cursor_path,
context
.recover_publication
.expect("manual recovery was checked"),
shutdown.as_mut(),
context.advertise_probe,
)
.await
} else {
dexdo::seller::liveness::prepare_seller_offer_with_persisted_liveness(
seller,
deal.chain.as_ref(),
&deal.cfg,
context.note_addr,
inspected_identity.as_ref(),
&deal.watch.cursor_path,
shutdown.as_mut(),
context.advertise_probe,
)
.await
}? {
dexdo::seller::liveness::SellerStartupOutcome::Ready(startup) => {
// shutdown can win the startup select, then an already-matched SELL turns the
// result back into `Ready`. The completed `Fuse` is the retained witness that this
Expand Down Expand Up @@ -3720,6 +3743,33 @@ pub(crate) async fn run_seller_with_deal_gas_overhead(
// The manifest's frame_model (if any) is validated against `--model` inside `seller_real_backend`.
let (mut token_contract, mut market_frame_model, market_nonce) =
resolve_market_fields(args.market.as_deref(), args.token_contract.as_deref(), None)?;
match (
args.recover_publication.as_deref(),
args.confirm_recover_publication,
) {
(Some(_), false) => bail!(
"--recover-publication requires --confirm-recover-publication; automatic service/timer restarts must not use this escape hatch"
),
(Some(operator_tc), true) => {
// A market manifest and a human command may name the same account in
// different accepted address representations. Compare the parsed
// account, not its spelling; the recovery still binds to the selected
// manifest token contract below.
let operator_tc = dexdo_core::normalize_wallet_address(operator_tc).map_err(|error| {
anyhow::anyhow!("--recover-publication has an invalid TokenContract: {error}")
})?;
let selected_tc = dexdo_core::normalize_wallet_address(&token_contract).map_err(|error| {
anyhow::anyhow!("selected TokenContract is invalid: {error}")
})?;
if operator_tc != selected_tc {
bail!(
"--recover-publication does not match the selected TokenContract"
);
}
}
(None, true) => unreachable!("clap requires --recover-publication"),
_ => {}
}
let mut startup_market = args.market.as_deref().map(load_market).transpose()?;
// Review: the deal nonce comes from `--market` (the manifest) or the explicit `--nonce` flag --
// never both (the manifest is the single source of truth). The real-chain seller path requires
Expand Down Expand Up @@ -4420,6 +4470,13 @@ pub(crate) async fn run_seller_with_deal_gas_overhead(
frame_model,
gateway_advertise: &gateway_advertise,
advertise_probe: args.advertise_probe_policy(),
// The operator's spelling was checked above. Pass the selected runtime
// identity forward so the audited one-shot permit uses precisely the
// token contract that this seller will serve.
recover_publication: args
.recover_publication
.as_deref()
.map(|_| token_contract.as_str()),
};
if !args.mock.mock_chain {
sweep_configured_seller_model_books(&args, note_addr, context.frame_model, &token_contract)
Expand Down Expand Up @@ -4671,6 +4728,8 @@ mod tests {
allow_unverified_model: false,
models: root.path().join("missing-models.json"),
policy: Some(policy_path),
recover_publication: None,
confirm_recover_publication: false,
})
.await
.expect_err("second seller process for one note must fail on the production lock");
Expand Down Expand Up @@ -5135,6 +5194,7 @@ mod tests {
frame_model: "mock",
gateway_advertise: &config.gateway_advertise,
advertise_probe: dexdo::seller::liveness::AdvertiseProbePolicy::default(),
recover_publication: None,
};
let mut provisioner = |_: String, _: u64, _: u64, _: u64| {
futures::future::ready(Err::<
Expand Down Expand Up @@ -5437,6 +5497,7 @@ mod tests {
frame_model: "mock",
gateway_advertise: &gateway_advertise,
advertise_probe: dexdo::seller::liveness::AdvertiseProbePolicy::default(),
recover_publication: None,
};
let shutdown = futures::future::pending::<()>();
tokio::pin!(shutdown);
Expand Down Expand Up @@ -5734,6 +5795,8 @@ mod tests {
allow_unverified_model: false,
models: root.join("models.json"),
policy: None,
recover_publication: None,
confirm_recover_publication: false,
})
.await;
let error = match case {
Expand Down Expand Up @@ -6585,6 +6648,7 @@ mod tests {
frame_model,
gateway_advertise: &gateway,
advertise_probe: dexdo::seller::liveness::AdvertiseProbePolicy::default(),
recover_publication: None,
},
&pool_test_policy(2),
&mut provision,
Expand Down Expand Up @@ -6843,6 +6907,7 @@ mod tests {
frame_model: "openai/gpt-oss-20b",
gateway_advertise: &gateway,
advertise_probe: dexdo::seller::liveness::AdvertiseProbePolicy::default(),
recover_publication: None,
},
&pool_test_policy(1),
&mut provision,
Expand Down Expand Up @@ -7349,6 +7414,7 @@ mod tests {
frame_model: "openai/gpt-oss-20b",
gateway_advertise: &gateway,
advertise_probe: dexdo::seller::liveness::AdvertiseProbePolicy::default(),
recover_publication: None,
},
false,
shutdown.as_mut(),
Expand Down Expand Up @@ -7495,6 +7561,7 @@ mod tests {
frame_model,
gateway_advertise: &gateway,
advertise_probe: dexdo::seller::liveness::AdvertiseProbePolicy::default(),
recover_publication: None,
},
&pool_test_policy(3),
&mut provision,
Expand Down Expand Up @@ -7566,6 +7633,7 @@ mod tests {
frame_model,
gateway_advertise: gateway,
advertise_probe: dexdo::seller::liveness::AdvertiseProbePolicy::default(),
recover_publication: None,
}
}

Expand Down Expand Up @@ -8097,6 +8165,7 @@ mod tests {
// `unreachable` is a closed loopback port, so it is not public and the
// production default is still fatal -- the cascade under test is reached.
advertise_probe: dexdo::seller::liveness::AdvertiseProbePolicy::default(),
recover_publication: None,
},
&pool_test_policy(2),
&mut provision,
Expand Down Expand Up @@ -8198,6 +8267,8 @@ mod tests {
allow_unverified_model: false,
models: root.path().join("unused-models.json"),
policy: None,
recover_publication: None,
confirm_recover_publication: false,
};
assert_eq!(
args.checked_gateway_advertise_addr().unwrap(),
Expand Down Expand Up @@ -8393,6 +8464,8 @@ mod tests {
allow_unverified_model: false,
models: root.path().join("unused-models.json"),
policy: None,
recover_publication: None,
confirm_recover_publication: false,
};

let seller = super::run_seller(args);
Expand Down Expand Up @@ -8591,6 +8664,7 @@ mod tests {
frame_model: "mock",
gateway_advertise: &advertise,
advertise_probe: args.advertise_probe_policy(),
recover_publication: None,
},
&pool_test_policy(1),
&mut provision,
Expand Down Expand Up @@ -8697,6 +8771,7 @@ mod tests {
frame_model: "mock",
gateway_advertise: &advertise,
advertise_probe: args.advertise_probe_policy(),
recover_publication: None,
},
&pool_test_policy(1),
&mut provision,
Expand Down Expand Up @@ -8752,6 +8827,8 @@ mod tests {
allow_unverified_model: false,
models: root.join("unused-models.json"),
policy: None,
recover_publication: None,
confirm_recover_publication: false,
}
}

Expand Down
2 changes: 2 additions & 0 deletions crates/dexdo/src/cli/seller_1056_restart_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ fn issue_1056_seller_args(
allow_unverified_model: false,
models: root.join("unused-models.json"),
policy: None,
recover_publication: None,
confirm_recover_publication: false,
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/dexdo/src/cli/seller_1057_shutdown_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ impl Issue1057Pool {
frame_model: self.frame_model,
gateway_advertise: &self.gateway,
advertise_probe: dexdo::seller::liveness::AdvertiseProbePolicy::default(),
recover_publication: None,
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions crates/dexdo/src/cli/seller_1402_refusal_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ fn refusal_seller_args(
allow_unverified_model: false,
models: root.join("unused-models.json"),
policy: Some(policy),
recover_publication: None,
confirm_recover_publication: false,
}
}

Expand Down
Loading