commit 2a3643e65a1e8df98d6a99ecb263e1d908e4777f
parent 0570a2c155ab3488a66077fa1fbbf1113a606c4c
Author: triesap <tyson@radroots.org>
Date: Sat, 9 May 2026 18:29:04 +0000
cli: bind listing drafts to seller actors
Diffstat:
12 files changed, 930 insertions(+), 173 deletions(-)
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -2260,10 +2260,12 @@ pub struct ListingNewView {
#[serde(skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
- pub selected_account_id: Option<String>,
+ pub seller_account_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub seller_pubkey: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
+ pub seller_actor_source: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
pub farm_d_tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delivery_method: Option<String>,
@@ -2294,8 +2296,12 @@ pub struct ListingValidateView {
#[serde(skip_serializing_if = "Option::is_none")]
pub listing_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
+ pub seller_account_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
pub seller_pubkey: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
+ pub seller_actor_source: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
pub farm_d_tag: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub issues: Vec<ListingValidationIssueView>,
@@ -2343,8 +2349,12 @@ pub struct ListingSummaryView {
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
+ pub seller_account_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
pub seller_pubkey: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
+ pub seller_actor_source: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
pub farm_d_tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub location_primary: Option<String>,
@@ -2558,7 +2568,9 @@ pub struct ListingMutationView {
pub file: String,
pub listing_id: String,
pub listing_addr: String,
+ pub seller_account_id: String,
pub seller_pubkey: String,
+ pub seller_actor_source: String,
pub event_kind: u32,
#[serde(default)]
pub dry_run: bool,
@@ -2612,6 +2624,50 @@ impl ListingMutationView {
}
#[derive(Debug, Clone, Serialize)]
+pub struct ListingRebindView {
+ pub state: String,
+ pub source: String,
+ pub file: String,
+ pub listing_id: String,
+ pub dry_run: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub from_seller_account_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub from_seller_pubkey: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub from_seller_actor_source: Option<String>,
+ pub to_seller_account_id: String,
+ pub to_seller_pubkey: String,
+ pub to_seller_actor_source: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub seller_pubkey_changed: Option<bool>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub from_listing_addr: Option<String>,
+ pub to_listing_addr: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub listing_addr_changed: Option<bool>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub from_farm_d_tag: Option<String>,
+ pub to_farm_d_tag: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub farm_d_tag_changed: Option<bool>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub reason: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub actions: Vec<String>,
+}
+
+impl ListingRebindView {
+ pub fn disposition(&self) -> CommandDisposition {
+ match self.state.as_str() {
+ "unconfigured" => CommandDisposition::Unconfigured,
+ "error" => CommandDisposition::InternalError,
+ _ => CommandDisposition::Success,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
pub struct ListingMutationLocalReplicaView {
pub state: String,
pub store_state: String,
diff --git a/src/main.rs b/src/main.rs
@@ -231,6 +231,9 @@ fn execute_request(
TargetOperationRequest::ListingValidate(request) => {
execute_with(ListingOperationService::new(config), request)
}
+ TargetOperationRequest::ListingRebind(request) => {
+ execute_with(ListingOperationService::new(config), request)
+ }
TargetOperationRequest::ListingPublish(request) => {
execute_with(ListingOperationService::new(config), request)
}
diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs
@@ -1386,6 +1386,11 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati
| ListingCommand::Validate(args)
| ListingCommand::Publish(args)
| ListingCommand::Archive(args) => insert_path(&mut input, "file", &args.file),
+ ListingCommand::Rebind(args) => {
+ insert_path(&mut input, "file", &args.file);
+ insert_string(&mut input, "selector", &args.selector);
+ insert_string(&mut input, "farm_d_tag", &args.farm_d_tag);
+ }
ListingCommand::List => {}
},
TargetCommand::Market(args) => match &args.command {
@@ -1619,6 +1624,7 @@ target_operation_contracts! {
ListingList => (ListingListRequest, ListingListResult, "listing.list"),
ListingUpdate => (ListingUpdateRequest, ListingUpdateResult, "listing.update"),
ListingValidate => (ListingValidateRequest, ListingValidateResult, "listing.validate"),
+ ListingRebind => (ListingRebindRequest, ListingRebindResult, "listing.rebind"),
ListingPublish => (ListingPublishRequest, ListingPublishResult, "listing.publish"),
ListingArchive => (ListingArchiveRequest, ListingArchiveResult, "listing.archive"),
MarketRefresh => (MarketRefreshRequest, MarketRefreshResult, "market.refresh"),
@@ -1818,6 +1824,48 @@ mod tests {
}
#[test]
+ fn adapter_maps_listing_rebind_inputs() {
+ let parsed = TargetCliArgs::try_parse_from([
+ "radroots",
+ "listing",
+ "rebind",
+ "listing.toml",
+ "acct_test",
+ "--farm-d-tag",
+ "AAAAAAAAAAAAAAAAAAAAAw",
+ ])
+ .expect("target args parse");
+
+ let request = TargetOperationRequest::from_target_args(&parsed)
+ .expect("operation request from target args");
+ let TargetOperationRequest::ListingRebind(request) = request else {
+ panic!("expected listing rebind request")
+ };
+
+ assert_eq!(request.operation_id(), "listing.rebind");
+ assert_eq!(
+ request.payload.input.get("file").and_then(Value::as_str),
+ Some("listing.toml")
+ );
+ assert_eq!(
+ request
+ .payload
+ .input
+ .get("selector")
+ .and_then(Value::as_str),
+ Some("acct_test")
+ );
+ assert_eq!(
+ request
+ .payload
+ .input
+ .get("farm_d_tag")
+ .and_then(Value::as_str),
+ Some("AAAAAAAAAAAAAAAAAAAAAw")
+ );
+ }
+
+ #[test]
fn adapter_maps_order_fulfillment_update_input() {
let parsed = TargetCliArgs::try_parse_from([
"radroots",
diff --git a/src/operation_listing.rs b/src/operation_listing.rs
@@ -7,15 +7,15 @@ use crate::domain::runtime::{CommandDisposition, ListingMutationView};
use crate::operation_adapter::{
ListingArchiveRequest, ListingArchiveResult, ListingCreateRequest, ListingCreateResult,
ListingGetRequest, ListingGetResult, ListingListRequest, ListingListResult,
- ListingPublishRequest, ListingPublishResult, ListingUpdateRequest, ListingUpdateResult,
- ListingValidateRequest, ListingValidateResult, OperationAdapterError, OperationRequest,
- OperationRequestData, OperationRequestPayload, OperationResult, OperationResultData,
- OperationService,
+ ListingPublishRequest, ListingPublishResult, ListingRebindRequest, ListingRebindResult,
+ ListingUpdateRequest, ListingUpdateResult, ListingValidateRequest, ListingValidateResult,
+ OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload,
+ OperationResult, OperationResultData, OperationService,
};
use crate::runtime::RuntimeError;
use crate::runtime::config::RuntimeConfig;
use crate::runtime_args::{
- ListingCreateArgs, ListingFileArgs, ListingMutationArgs, RecordLookupArgs,
+ ListingCreateArgs, ListingFileArgs, ListingMutationArgs, ListingRebindArgs, RecordLookupArgs,
};
pub struct ListingOperationService<'a> {
@@ -144,6 +144,34 @@ impl OperationService<ListingValidateRequest> for ListingOperationService<'_> {
}
}
+impl OperationService<ListingRebindRequest> for ListingOperationService<'_> {
+ type Result = ListingRebindResult;
+
+ fn execute(
+ &self,
+ request: OperationRequest<ListingRebindRequest>,
+ ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
+ let args = ListingRebindArgs {
+ file: required_path(&request, "file")?,
+ selector: required_string(&request, "selector")?,
+ farm_d_tag: string_input(&request, "farm_d_tag"),
+ };
+ if request.context.dry_run {
+ let view = map_runtime(
+ request.operation_id(),
+ crate::runtime::listing::rebind_preflight(self.config, &args),
+ )?;
+ return serialized_operation_result::<ListingRebindResult, _>(&view);
+ }
+ require_approval(&request)?;
+ let view = map_runtime(
+ request.operation_id(),
+ crate::runtime::listing::rebind(self.config, &args),
+ )?;
+ serialized_operation_result::<ListingRebindResult, _>(&view)
+ }
+}
+
impl OperationService<ListingPublishRequest> for ListingOperationService<'_> {
type Result = ListingPublishResult;
@@ -367,7 +395,7 @@ mod tests {
};
#[test]
- fn listing_service_supports_create_dry_run_without_sell_path() {
+ fn listing_service_requires_seller_actor_for_create_dry_run() {
let dir = tempdir().expect("tempdir");
let config = sample_config(dir.path());
let service = OperationAdapter::new(ListingOperationService::new(&config));
@@ -378,16 +406,13 @@ mod tests {
ListingCreateRequest::from_data(data(&[("key", "eggs"), ("title", "Eggs")])),
)
.expect("listing create request");
- let envelope = service
+ let error = service
.execute(request)
- .expect("listing create result")
- .to_envelope(context.envelope_context("req_listing_create"))
- .expect("listing create envelope");
-
- assert_eq!(envelope.operation_id, "listing.create");
- assert_eq!(envelope.dry_run, true);
- assert_eq!(envelope.result["state"], "dry_run");
- assert_eq!(envelope.result["key"], "eggs");
+ .expect_err("listing create seller actor");
+ let output_error = error.to_output_error();
+
+ assert_eq!(output_error.code, "account_unresolved");
+ assert!(output_error.detail.expect("detail")["seller_actor_source"] == "resolved_account");
}
#[test]
diff --git a/src/operation_registry.rs b/src/operation_registry.rs
@@ -638,6 +638,21 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[
false
),
operation!(
+ "listing.rebind",
+ "radroots listing rebind",
+ "listing",
+ "listing_rebind",
+ "ListingRebindRequest",
+ "ListingRebindResult",
+ "Rebind a listing draft to an explicit seller actor.",
+ Seller,
+ true,
+ Required,
+ High,
+ false,
+ true
+ ),
+ operation!(
"listing.publish",
"radroots listing publish",
"listing",
@@ -1239,6 +1254,7 @@ mod tests {
"listing.list",
"listing.update",
"listing.validate",
+ "listing.rebind",
"listing.publish",
"listing.archive",
"market.refresh",
@@ -1293,6 +1309,7 @@ mod tests {
"farm.publish",
"listing.create",
"listing.update",
+ "listing.rebind",
"listing.publish",
"listing.archive",
"market.refresh",
@@ -1324,7 +1341,7 @@ mod tests {
.copied()
.collect::<BTreeSet<_>>();
assert_eq!(actual, expected);
- assert_eq!(OPERATION_REGISTRY.len(), 69);
+ assert_eq!(OPERATION_REGISTRY.len(), 70);
}
#[test]
@@ -1371,6 +1388,7 @@ mod tests {
"sync.push",
"farm.rebind",
"farm.publish",
+ "listing.rebind",
"listing.publish",
"listing.archive",
"order.submit",
diff --git a/src/runtime/accounts.rs b/src/runtime/accounts.rs
@@ -70,6 +70,18 @@ impl AccountRuntimeFailure {
)))
}
+ pub fn watch_only_with_detail(
+ account_id: impl fmt::Display,
+ detail: serde_json::Value,
+ ) -> Self {
+ Self::WatchOnly(AccountRuntimeFailureIssue::with_detail(
+ format!(
+ "resolved account `{account_id}` is watch_only and cannot sign because it is not secret-backed"
+ ),
+ detail,
+ ))
+ }
+
pub fn mismatch(message: impl Into<String>) -> Self {
Self::Mismatch(AccountRuntimeFailureIssue::new(message))
}
diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs
@@ -38,8 +38,8 @@ use serde_json::json;
use crate::domain::runtime::{
FindPriceView, FindQuantityView, FindResultProvenanceView, ListingGetView, ListingListView,
ListingMutationEventView, ListingMutationJobView, ListingMutationLocalReplicaView,
- ListingMutationView, ListingNewView, ListingSummaryView, ListingValidateView,
- ListingValidationIssueView, RelayFailureView, SyncFreshnessView,
+ ListingMutationView, ListingNewView, ListingRebindView, ListingSummaryView,
+ ListingValidateView, ListingValidationIssueView, RelayFailureView, SyncFreshnessView,
};
use crate::runtime::RuntimeError;
use crate::runtime::accounts;
@@ -54,7 +54,7 @@ use crate::runtime::farm_config;
use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority};
use crate::runtime::sync::freshness_from_executor;
use crate::runtime_args::{
- ListingCreateArgs, ListingFileArgs, ListingMutationArgs, RecordLookupArgs,
+ ListingCreateArgs, ListingFileArgs, ListingMutationArgs, ListingRebindArgs, RecordLookupArgs,
};
const DRAFT_KIND: &str = "listing_draft_v1";
@@ -64,6 +64,9 @@ const RELAY_LISTING_WRITE_SOURCE: &str = "direct Nostr relay publish · local ke
const RADROOTSD_LISTING_WRITE_SOURCE: &str = "radrootsd publish transport · signer session";
const RADROOTSD_BRIDGE_LISTING_PUBLISH_METHOD: &str = "bridge.listing.publish";
const LISTING_DRAFTS_DIR: &str = "listings/drafts";
+const LISTING_SELLER_ACTOR_SOURCE_FARM_CONFIG: &str = "farm_config";
+const LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT: &str = "resolved_account";
+const LISTING_SELLER_ACTOR_SOURCE_REBIND: &str = "listing_rebind";
static D_TAG_COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -73,6 +76,7 @@ struct ListingDraftDocument {
version: u32,
kind: String,
listing: ListingDraftMeta,
+ seller_actor: ListingDraftSellerActor,
product: ListingDraftProduct,
primary_bin: ListingDraftPrimaryBin,
inventory: ListingDraftInventory,
@@ -88,7 +92,14 @@ struct ListingDraftDocument {
struct ListingDraftMeta {
d_tag: String,
farm_d_tag: String,
- seller_pubkey: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ListingDraftSellerActor {
+ account_id: String,
+ pubkey: String,
+ source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -172,9 +183,6 @@ struct ListingDraftDiscount {
#[derive(Debug, Clone)]
struct ListingValidationContext {
- selected_account_id: Option<String>,
- selected_account_pubkey: Option<String>,
- selected_farm_d_tag: Option<String>,
farm_setup_action: String,
}
@@ -185,8 +193,9 @@ struct ListingAuthoringDefaults {
farm_next_action: Option<String>,
farm_reason: Option<String>,
farm_name: Option<String>,
- selected_account_id: Option<String>,
- selected_account_pubkey: Option<String>,
+ seller_account_id: String,
+ seller_pubkey: String,
+ seller_actor_source: String,
selected_farm_d_tag: Option<String>,
delivery_method: Option<String>,
location: Option<ListingDraftLocation>,
@@ -195,7 +204,9 @@ struct ListingAuthoringDefaults {
#[derive(Debug, Clone)]
struct CanonicalListingDraft {
listing_id: String,
+ seller_account_id: String,
seller_pubkey: String,
+ seller_actor_source: String,
farm_d_tag: String,
listing: RadrootsListing,
}
@@ -263,9 +274,6 @@ pub fn scaffold(
"radroots listing validate {}",
output_path.display()
)];
- if defaults.selected_account_pubkey.is_none() {
- actions.push("radroots account create".to_owned());
- }
if let Some(action) = &defaults.farm_next_action {
actions.push(action.clone());
}
@@ -276,8 +284,9 @@ pub fn scaffold(
file: output_path.display().to_string(),
listing_id: draft.listing.d_tag,
key: non_empty(draft.product.key.clone()),
- selected_account_id: defaults.selected_account_id,
- seller_pubkey: defaults.selected_account_pubkey,
+ seller_account_id: Some(defaults.seller_account_id),
+ seller_pubkey: Some(defaults.seller_pubkey),
+ seller_actor_source: Some(defaults.seller_actor_source),
farm_d_tag: defaults.selected_farm_d_tag,
delivery_method: non_empty(draft.delivery.method.clone()),
location_primary: non_empty(draft.location.primary.clone()),
@@ -298,9 +307,6 @@ pub fn scaffold_preflight(
"radroots listing validate {}",
output_path.display()
)];
- if defaults.selected_account_pubkey.is_none() {
- actions.push("radroots account create".to_owned());
- }
if let Some(action) = &defaults.farm_next_action {
actions.push(action.clone());
}
@@ -311,8 +317,9 @@ pub fn scaffold_preflight(
file: output_path.display().to_string(),
listing_id: draft.listing.d_tag,
key: non_empty(draft.product.key.clone()),
- selected_account_id: defaults.selected_account_id,
- seller_pubkey: defaults.selected_account_pubkey,
+ seller_account_id: Some(defaults.seller_account_id),
+ seller_pubkey: Some(defaults.seller_pubkey),
+ seller_actor_source: Some(defaults.seller_actor_source),
farm_d_tag: defaults.selected_farm_d_tag,
delivery_method: non_empty(draft.delivery.method.clone()),
location_primary: non_empty(draft.location.primary.clone()),
@@ -333,7 +340,11 @@ fn build_listing_draft(
listing: ListingDraftMeta {
d_tag: generate_d_tag(),
farm_d_tag: defaults.selected_farm_d_tag.clone().unwrap_or_default(),
- seller_pubkey: defaults.selected_account_pubkey.clone().unwrap_or_default(),
+ },
+ seller_actor: ListingDraftSellerActor {
+ account_id: defaults.seller_account_id.clone(),
+ pubkey: defaults.seller_pubkey.clone(),
+ source: defaults.seller_actor_source.clone(),
},
product: ListingDraftProduct {
key: args.key.clone().unwrap_or_default(),
@@ -481,8 +492,10 @@ pub fn validate(
file: args.file.display().to_string(),
valid: false,
listing_id: None,
- seller_pubkey: context.selected_account_pubkey.clone(),
- farm_d_tag: context.selected_farm_d_tag.clone(),
+ seller_account_id: None,
+ seller_pubkey: None,
+ seller_actor_source: None,
+ farm_d_tag: None,
issues: vec![ListingValidationIssueView {
field: "toml".to_owned(),
message: error.to_string(),
@@ -502,7 +515,7 @@ pub fn validate(
Err(error) => {
return Ok(invalid_validation_view(
args.file.as_path(),
- parsed.listing.d_tag.as_str(),
+ &parsed,
&context,
ListingValidationIssueView {
field: "listing".to_owned(),
@@ -512,6 +525,14 @@ pub fn validate(
));
}
};
+ if let Some(issue) = listing_bound_account_issue(config, &canonical, &contents)? {
+ return Ok(invalid_validation_view(
+ args.file.as_path(),
+ &parsed,
+ &context,
+ issue,
+ ));
+ }
let event = RadrootsNostrEvent {
id: String::new(),
author: canonical.seller_pubkey.clone(),
@@ -528,14 +549,16 @@ pub fn validate(
file: args.file.display().to_string(),
valid: true,
listing_id: Some(canonical.listing_id),
+ seller_account_id: Some(canonical.seller_account_id),
seller_pubkey: Some(canonical.seller_pubkey),
+ seller_actor_source: Some(canonical.seller_actor_source),
farm_d_tag: Some(canonical.farm_d_tag),
issues: Vec::new(),
actions: vec![format!("radroots listing publish {}", args.file.display())],
}),
Err(error) => Ok(invalid_validation_view(
args.file.as_path(),
- parsed.listing.d_tag.as_str(),
+ &parsed,
&context,
issue_from_trade_validation(error, &contents),
)),
@@ -543,7 +566,7 @@ pub fn validate(
}
Err(error) => Ok(invalid_validation_view(
args.file.as_path(),
- parsed.listing.d_tag.as_str(),
+ &parsed,
&context,
error.into_issue(),
)),
@@ -572,7 +595,7 @@ pub fn list(config: &RuntimeConfig) -> Result<ListingListView, RuntimeError> {
continue;
}
match load_listing_draft(path.as_path()) {
- Ok(loaded) => listings.push(summary_from_loaded(&loaded, context.as_ref())),
+ Ok(loaded) => listings.push(summary_from_loaded(config, &loaded, context.as_ref())),
Err(issue) => listings.push(summary_for_invalid_file(path.as_path(), issue)),
}
}
@@ -607,6 +630,174 @@ pub fn list(config: &RuntimeConfig) -> Result<ListingListView, RuntimeError> {
})
}
+pub fn rebind(
+ config: &RuntimeConfig,
+ args: &ListingRebindArgs,
+) -> Result<ListingRebindView, RuntimeError> {
+ rebind_inner(config, args, false)
+}
+
+pub fn rebind_preflight(
+ config: &RuntimeConfig,
+ args: &ListingRebindArgs,
+) -> Result<ListingRebindView, RuntimeError> {
+ rebind_inner(config, args, true)
+}
+
+fn rebind_inner(
+ config: &RuntimeConfig,
+ args: &ListingRebindArgs,
+ dry_run: bool,
+) -> Result<ListingRebindView, RuntimeError> {
+ let contents = fs::read_to_string(&args.file)?;
+ let mut draft = toml::from_str::<ListingDraftDocument>(&contents).map_err(|error| {
+ RuntimeError::Config(format!(
+ "invalid listing draft {}: {error}",
+ args.file.display()
+ ))
+ })?;
+ let listing_id = draft.listing.d_tag.trim().to_owned();
+ if !is_d_tag_base64url(&listing_id) {
+ return Err(RuntimeError::Config(format!(
+ "invalid listing draft {}: listing d_tag must be a 22-character base64url identifier",
+ args.file.display()
+ )));
+ }
+
+ let target_account = accounts::resolve_account_selector(config, args.selector.as_str())
+ .map_err(|error| listing_rebind_selector_error(args.selector.as_str(), error))?;
+ let from_seller_account_id = non_empty(draft.seller_actor.account_id.clone());
+ let from_seller_pubkey = non_empty(draft.seller_actor.pubkey.clone());
+ let from_seller_actor_source = non_empty(draft.seller_actor.source.clone());
+ let from_farm_d_tag = non_empty(draft.listing.farm_d_tag.clone());
+ let target_account_id = target_account.record.account_id.to_string();
+ let target_pubkey = target_account.record.public_identity.public_key_hex.clone();
+ let target_farm_d_tag = resolve_rebind_farm_d_tag(
+ config,
+ args,
+ from_seller_account_id.as_deref(),
+ from_farm_d_tag.as_deref(),
+ target_account_id.as_str(),
+ )?;
+ let from_listing_addr = from_seller_pubkey
+ .as_ref()
+ .map(|pubkey| listing_addr(pubkey, listing_id.as_str()));
+ let to_listing_addr = listing_addr(target_pubkey.as_str(), listing_id.as_str());
+ let seller_pubkey_changed = from_seller_pubkey
+ .as_deref()
+ .map(|pubkey| !pubkey.eq_ignore_ascii_case(target_pubkey.as_str()));
+ let listing_addr_changed = from_listing_addr
+ .as_deref()
+ .map(|addr| addr != to_listing_addr.as_str());
+ let farm_d_tag_changed = from_farm_d_tag
+ .as_deref()
+ .map(|d_tag| d_tag != target_farm_d_tag.as_str());
+
+ draft.seller_actor.account_id = target_account_id.clone();
+ draft.seller_actor.pubkey = target_pubkey.clone();
+ draft.seller_actor.source = LISTING_SELLER_ACTOR_SOURCE_REBIND.to_owned();
+ draft.listing.farm_d_tag = target_farm_d_tag.clone();
+
+ if !dry_run {
+ write_listing_draft(args.file.as_path(), &draft, true)?;
+ }
+
+ Ok(ListingRebindView {
+ state: if dry_run { "dry_run" } else { "rebound" }.to_owned(),
+ source: LISTING_SOURCE.to_owned(),
+ file: args.file.display().to_string(),
+ listing_id,
+ dry_run,
+ from_seller_account_id,
+ from_seller_pubkey,
+ from_seller_actor_source,
+ to_seller_account_id: target_account_id,
+ to_seller_pubkey: target_pubkey,
+ to_seller_actor_source: LISTING_SELLER_ACTOR_SOURCE_REBIND.to_owned(),
+ seller_pubkey_changed,
+ from_listing_addr,
+ to_listing_addr,
+ listing_addr_changed,
+ from_farm_d_tag,
+ to_farm_d_tag: target_farm_d_tag,
+ farm_d_tag_changed,
+ reason: Some(if dry_run {
+ "dry run requested; listing seller actor binding was not written".to_owned()
+ } else {
+ "listing seller actor binding updated".to_owned()
+ }),
+ actions: if dry_run {
+ vec![format!(
+ "radroots --approval-token approve listing rebind {} {}",
+ args.file.display(),
+ args.selector
+ )]
+ } else {
+ vec![format!("radroots listing validate {}", args.file.display())]
+ },
+ })
+}
+
+fn resolve_rebind_farm_d_tag(
+ config: &RuntimeConfig,
+ args: &ListingRebindArgs,
+ from_seller_account_id: Option<&str>,
+ from_farm_d_tag: Option<&str>,
+ target_account_id: &str,
+) -> Result<String, RuntimeError> {
+ if let Some(explicit) = args
+ .farm_d_tag
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ {
+ if !is_d_tag_base64url(explicit) {
+ return Err(RuntimeError::Config(
+ "listing rebind --farm-d-tag must be a 22-character base64url identifier"
+ .to_owned(),
+ ));
+ }
+ return Ok(explicit.to_owned());
+ }
+ if from_seller_account_id == Some(target_account_id)
+ && let Some(existing) = from_farm_d_tag
+ {
+ return Ok(existing.to_owned());
+ }
+ if let Some(resolved) = farm_config::load(config, None)?
+ && resolved.document.selection.account == target_account_id
+ {
+ return Ok(resolved.document.selection.farm_d_tag);
+ }
+ Err(RuntimeError::Config(format!(
+ "listing rebind requires --farm-d-tag when target account `{target_account_id}` is not bound by the selected farm config"
+ )))
+}
+
+fn listing_rebind_selector_error(selector: &str, error: RuntimeError) -> RuntimeError {
+ match error {
+ RuntimeError::Account(accounts::AccountRuntimeFailure::Unresolved(issue)) => {
+ accounts::AccountRuntimeFailure::unresolved_with_detail(
+ issue.message().to_owned(),
+ json!({
+ "seller_actor_source": LISTING_SELLER_ACTOR_SOURCE_REBIND,
+ "selector": selector,
+ "actions": [
+ "radroots account import <path>",
+ "radroots account create",
+ ],
+ }),
+ )
+ .into()
+ }
+ other => other,
+ }
+}
+
+fn listing_addr(seller_pubkey: &str, listing_id: &str) -> String {
+ format!("{KIND_LISTING}:{seller_pubkey}:{listing_id}")
+}
+
fn load_listing_draft(path: &Path) -> Result<LoadedListingDraft, ListingValidationIssueView> {
let contents = fs::read_to_string(path).map_err(|error| ListingValidationIssueView {
field: "file".to_owned(),
@@ -631,10 +822,13 @@ fn load_listing_draft(path: &Path) -> Result<LoadedListingDraft, ListingValidati
}
fn summary_from_loaded(
+ config: &RuntimeConfig,
loaded: &LoadedListingDraft,
context: Result<&ListingValidationContext, &String>,
) -> ListingSummaryView {
- let mut seller_pubkey = non_empty(loaded.document.listing.seller_pubkey.clone());
+ let mut seller_account_id = non_empty(loaded.document.seller_actor.account_id.clone());
+ let mut seller_pubkey = non_empty(loaded.document.seller_actor.pubkey.clone());
+ let mut seller_actor_source = non_empty(loaded.document.seller_actor.source.clone());
let mut farm_d_tag = non_empty(loaded.document.listing.farm_d_tag.clone());
let mut issues = Vec::new();
let mut state = "draft";
@@ -643,9 +837,16 @@ fn summary_from_loaded(
Ok(context) => {
match canonicalize_draft(&loaded.document, loaded.contents.as_str(), context) {
Ok(canonical) => {
+ seller_account_id = Some(canonical.seller_account_id.clone());
seller_pubkey = Some(canonical.seller_pubkey.clone());
+ seller_actor_source = Some(canonical.seller_actor_source.clone());
farm_d_tag = Some(canonical.farm_d_tag.clone());
issues = listing_ready_issues(&canonical, loaded.contents.as_str());
+ if let Ok(Some(issue)) =
+ listing_bound_account_issue(config, &canonical, loaded.contents.as_str())
+ {
+ issues.push(issue);
+ }
if issues.is_empty() {
state = "ready";
}
@@ -668,7 +869,9 @@ fn summary_from_loaded(
product_key: non_empty(loaded.document.product.key.clone()),
title: non_empty(loaded.document.product.title.clone()),
category: non_empty(loaded.document.product.category.clone()),
+ seller_account_id,
seller_pubkey,
+ seller_actor_source,
farm_d_tag,
location_primary: non_empty(loaded.document.location.primary.clone()),
updated_at_unix: loaded.updated_at_unix,
@@ -713,7 +916,9 @@ fn summary_for_invalid_file(path: &Path, issue: ListingValidationIssueView) -> L
product_key: None,
title: None,
category: None,
+ seller_account_id: None,
seller_pubkey: None,
+ seller_actor_source: None,
farm_d_tag: None,
location_primary: None,
updated_at_unix: modified_unix(path).unwrap_or_default(),
@@ -856,10 +1061,14 @@ fn mutate(
let mut canonical = canonicalize_draft(&parsed, &contents, &context).map_err(|error| {
let issue = match error {
ListingDraftValidationError::MissingSellerAccount(issue) => {
- return accounts::AccountRuntimeFailure::unresolved(format!(
- "{} ({})",
- issue.message, issue.field
- ))
+ return accounts::AccountRuntimeFailure::unresolved_with_detail(
+ format!("{} ({})", issue.message, issue.field),
+ json!({
+ "seller_actor_source": "listing_draft",
+ "listing_file": args.file.display().to_string(),
+ "actions": listing_bound_account_recovery_actions(args.file.as_path()),
+ }),
+ )
.into();
}
ListingDraftValidationError::Issue(issue) => issue,
@@ -871,6 +1080,7 @@ fn mutate(
issue.field
))
})?;
+ ensure_listing_bound_account(config, &canonical, args.file.as_path())?;
if matches!(operation, ListingMutationOperation::Archive) {
canonical.listing.availability = Some(RadrootsListingAvailability::Status {
@@ -928,7 +1138,9 @@ fn mutate(
file: args.file.display().to_string(),
listing_id: canonical.listing_id.clone(),
listing_addr: listing_addr.clone(),
+ seller_account_id: canonical.seller_account_id.clone(),
seller_pubkey: canonical.seller_pubkey.clone(),
+ seller_actor_source: canonical.seller_actor_source.clone(),
event_kind: KIND_LISTING,
dry_run: true,
deduplicated: false,
@@ -1203,6 +1415,18 @@ fn radrootsd_mutation_view(
.event_addr
.as_deref()
.and_then(daemon_listing_identity);
+ if let Some(identity) = daemon_identity.as_ref()
+ && (!identity
+ .seller_pubkey
+ .eq_ignore_ascii_case(canonical.seller_pubkey.as_str())
+ || identity.listing_id != canonical.listing_id)
+ {
+ return Err(RuntimeError::Config(format!(
+ "radrootsd listing publish returned event_addr identity `{}` that does not match listing draft `{}`",
+ radrootsd.event_addr.as_deref().unwrap_or_default(),
+ listing_addr
+ )));
+ }
let event_addr = radrootsd
.event_addr
.clone()
@@ -1212,10 +1436,7 @@ fn radrootsd_mutation_view(
.as_ref()
.map(|identity| identity.listing_id.clone())
.unwrap_or_else(|| canonical.listing_id.clone());
- let seller_pubkey = daemon_identity
- .as_ref()
- .map(|identity| identity.seller_pubkey.clone())
- .unwrap_or_else(|| canonical.seller_pubkey.clone());
+ let seller_pubkey = canonical.seller_pubkey.clone();
event.author = seller_pubkey.clone();
let job_status = radrootsd.status.clone();
let state = match operation {
@@ -1232,7 +1453,9 @@ fn radrootsd_mutation_view(
file: args.file.display().to_string(),
listing_id,
listing_addr: event_addr.clone(),
+ seller_account_id: canonical.seller_account_id.clone(),
seller_pubkey,
+ seller_actor_source: canonical.seller_actor_source.clone(),
event_kind: event_kind.unwrap_or(KIND_LISTING),
dry_run: false,
deduplicated: radrootsd.deduplicated,
@@ -1327,21 +1550,7 @@ fn scaffold_contents(draft: &ListingDraftDocument) -> Result<String, RuntimeErro
}
fn validation_context(config: &RuntimeConfig) -> Result<ListingValidationContext, RuntimeError> {
- let defaults = authoring_defaults(config)?;
- let selected_farm_d_tag = match (
- defaults.farm_config_present,
- defaults.selected_farm_d_tag,
- defaults.selected_account_pubkey.clone(),
- ) {
- (true, d_tag, _) => d_tag,
- (false, Some(d_tag), _) => Some(d_tag),
- (false, None, Some(pubkey)) => resolve_selected_farm_d_tag(config, pubkey.as_str())?,
- (false, None, None) => None,
- };
Ok(ListingValidationContext {
- selected_account_id: defaults.selected_account_id,
- selected_account_pubkey: defaults.selected_account_pubkey,
- selected_farm_d_tag,
farm_setup_action: farm_setup_action(config)?,
})
}
@@ -1359,9 +1568,6 @@ fn radrootsd_mutation_validation_context(
config: &RuntimeConfig,
) -> Result<ListingValidationContext, RuntimeError> {
Ok(ListingValidationContext {
- selected_account_id: None,
- selected_account_pubkey: None,
- selected_farm_d_tag: None,
farm_setup_action: farm_setup_action(config)?,
})
}
@@ -1369,7 +1575,7 @@ fn radrootsd_mutation_validation_context(
fn canonicalize_draft(
draft: &ListingDraftDocument,
contents: &str,
- context: &ListingValidationContext,
+ _context: &ListingValidationContext,
) -> Result<CanonicalListingDraft, ListingDraftValidationError> {
if draft.version != 1 {
return Err(issue_for_field(
@@ -1398,31 +1604,62 @@ fn canonicalize_draft(
.into());
}
- let seller_pubkey = if let Some(pubkey) = non_empty(draft.listing.seller_pubkey.clone()) {
- pubkey
- } else if let Some(pubkey) = context.selected_account_pubkey.clone() {
+ let seller_account_id =
+ if let Some(account_id) = non_empty(draft.seller_actor.account_id.clone()) {
+ account_id
+ } else {
+ return Err(ListingDraftValidationError::MissingSellerAccount(
+ issue_for_field(
+ contents,
+ "seller_actor.account_id",
+ "missing listing seller_actor account_id",
+ ),
+ ));
+ };
+
+ let seller_pubkey = if let Some(pubkey) = non_empty(draft.seller_actor.pubkey.clone()) {
pubkey
} else {
return Err(ListingDraftValidationError::MissingSellerAccount(
issue_for_field(
contents,
- "listing.seller_pubkey",
- "missing seller_pubkey and no resolved account pubkey is available",
+ "seller_actor.pubkey",
+ "missing listing seller_actor pubkey",
),
));
};
- let farm_d_tag = if let Some(d_tag) = non_empty(draft.listing.farm_d_tag.clone()) {
- d_tag
- } else if let Some(d_tag) = context.selected_farm_d_tag.clone() {
- d_tag
+ let seller_actor_source = if let Some(source) = non_empty(draft.seller_actor.source.clone()) {
+ source
} else {
+ return Err(ListingDraftValidationError::MissingSellerAccount(
+ issue_for_field(
+ contents,
+ "seller_actor.source",
+ "missing listing seller_actor source",
+ ),
+ ));
+ };
+ if !matches!(
+ seller_actor_source.as_str(),
+ LISTING_SELLER_ACTOR_SOURCE_FARM_CONFIG
+ | LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT
+ | LISTING_SELLER_ACTOR_SOURCE_REBIND
+ ) {
return Err(issue_for_field(
contents,
- "listing.farm_d_tag",
- "missing farm_d_tag and no selected farm config is available",
+ "seller_actor.source",
+ format!("unsupported listing seller_actor source `{seller_actor_source}`"),
)
.into());
+ }
+
+ let farm_d_tag = if let Some(d_tag) = non_empty(draft.listing.farm_d_tag.clone()) {
+ d_tag
+ } else {
+ return Err(
+ issue_for_field(contents, "listing.farm_d_tag", "missing listing farm_d_tag").into(),
+ );
};
if !is_d_tag_base64url(&farm_d_tag) {
return Err(issue_for_field(
@@ -1542,7 +1779,9 @@ fn canonicalize_draft(
Ok(CanonicalListingDraft {
listing_id,
+ seller_account_id,
seller_pubkey,
+ seller_actor_source,
farm_d_tag,
listing,
})
@@ -1725,17 +1964,134 @@ fn build_listing_discounts(
Ok((!discounts.is_empty()).then_some(discounts))
}
+fn listing_bound_account_issue(
+ config: &RuntimeConfig,
+ canonical: &CanonicalListingDraft,
+ contents: &str,
+) -> Result<Option<ListingValidationIssueView>, RuntimeError> {
+ let Some(account) = configured_account(config, &canonical.seller_account_id)? else {
+ return Ok(Some(issue_for_field(
+ contents,
+ "seller_actor.account_id",
+ format!(
+ "listing seller_actor account_id `{}` is not present in the local account store",
+ canonical.seller_account_id
+ ),
+ )));
+ };
+ let account_pubkey = account.record.public_identity.public_key_hex;
+ if !account_pubkey.eq_ignore_ascii_case(canonical.seller_pubkey.as_str()) {
+ return Ok(Some(issue_for_field(
+ contents,
+ "seller_actor.pubkey",
+ format!(
+ "listing seller_actor pubkey `{}` does not match account `{}` pubkey `{account_pubkey}`",
+ canonical.seller_pubkey, canonical.seller_account_id
+ ),
+ )));
+ }
+ Ok(None)
+}
+
+fn ensure_listing_bound_account(
+ config: &RuntimeConfig,
+ canonical: &CanonicalListingDraft,
+ file: &Path,
+) -> Result<(), RuntimeError> {
+ validate_invocation_account_matches_bound(config, canonical, file)?;
+ let Some(account) = configured_account(config, &canonical.seller_account_id)? else {
+ return Err(accounts::AccountRuntimeFailure::unresolved_with_detail(
+ format!(
+ "listing-bound seller account `{}` is not present in the local account store",
+ canonical.seller_account_id
+ ),
+ json!({
+ "seller_actor_source": canonical.seller_actor_source,
+ "listing_seller_account_id": canonical.seller_account_id,
+ "listing_file": file.display().to_string(),
+ "actions": listing_bound_account_recovery_actions(file),
+ }),
+ )
+ .into());
+ };
+ let account_pubkey = account.record.public_identity.public_key_hex;
+ if !account_pubkey.eq_ignore_ascii_case(canonical.seller_pubkey.as_str()) {
+ return Err(accounts::AccountRuntimeFailure::mismatch_with_detail(
+ format!(
+ "account mismatch: listing-bound seller account `{}` pubkey `{account_pubkey}` cannot sign listing seller_pubkey `{}`",
+ canonical.seller_account_id, canonical.seller_pubkey
+ ),
+ json!({
+ "seller_actor_source": canonical.seller_actor_source,
+ "listing_seller_account_id": canonical.seller_account_id,
+ "listing_seller_pubkey": canonical.seller_pubkey,
+ "account_pubkey": account_pubkey,
+ "listing_file": file.display().to_string(),
+ "actions": listing_bound_account_recovery_actions(file),
+ }),
+ )
+ .into());
+ }
+ Ok(())
+}
+
+fn validate_invocation_account_matches_bound(
+ config: &RuntimeConfig,
+ canonical: &CanonicalListingDraft,
+ file: &Path,
+) -> Result<(), RuntimeError> {
+ let Some(selector) = config
+ .account
+ .selector
+ .as_deref()
+ .map(str::trim)
+ .filter(|selector| !selector.is_empty())
+ else {
+ return Ok(());
+ };
+ let attempted = accounts::resolve_account_selector(config, selector)?;
+ if attempted.record.account_id.to_string() == canonical.seller_account_id {
+ return Ok(());
+ }
+ Err(accounts::AccountRuntimeFailure::mismatch_with_detail(
+ format!(
+ "account mismatch: listing draft is bound to seller account `{}`; invocation selected `{}`",
+ canonical.seller_account_id, attempted.record.account_id
+ ),
+ json!({
+ "seller_actor_source": canonical.seller_actor_source,
+ "listing_seller_account_id": canonical.seller_account_id,
+ "attempted_seller_account_id": attempted.record.account_id.to_string(),
+ "listing_file": file.display().to_string(),
+ "actions": listing_bound_account_recovery_actions(file),
+ }),
+ )
+ .into())
+}
+
+fn listing_bound_account_recovery_actions(file: &Path) -> Vec<String> {
+ vec![
+ "radroots account import <path>".to_owned(),
+ format!("radroots listing rebind {} <selector>", file.display()),
+ ]
+}
+
fn invalid_validation_view(
file: &Path,
- listing_id: &str,
+ draft: &ListingDraftDocument,
context: &ListingValidationContext,
issue: ListingValidationIssueView,
) -> ListingValidateView {
let mut actions = vec![format!("edit {}", file.display())];
- if context.selected_account_id.is_none() {
+ if draft.seller_actor.account_id.trim().is_empty() {
actions.push("radroots account create".to_owned());
+ } else {
+ actions.push(format!(
+ "radroots listing rebind {} <selector>",
+ file.display()
+ ));
}
- if context.selected_farm_d_tag.is_none() {
+ if draft.listing.farm_d_tag.trim().is_empty() {
actions.push(context.farm_setup_action.clone());
}
@@ -1744,9 +2100,11 @@ fn invalid_validation_view(
source: LISTING_SOURCE.to_owned(),
file: file.display().to_string(),
valid: false,
- listing_id: non_empty(listing_id.to_owned()),
- seller_pubkey: context.selected_account_pubkey.clone(),
- farm_d_tag: context.selected_farm_d_tag.clone(),
+ listing_id: non_empty(draft.listing.d_tag.clone()),
+ seller_account_id: non_empty(draft.seller_actor.account_id.clone()),
+ seller_pubkey: non_empty(draft.seller_actor.pubkey.clone()),
+ seller_actor_source: non_empty(draft.seller_actor.source.clone()),
+ farm_d_tag: non_empty(draft.listing.farm_d_tag.clone()),
issues: vec![issue],
actions,
}
@@ -1798,7 +2156,9 @@ fn radrootsd_preflight_view(
file: args.file.display().to_string(),
listing_id: canonical.listing_id.clone(),
listing_addr: listing_addr.clone(),
+ seller_account_id: canonical.seller_account_id.clone(),
seller_pubkey: canonical.seller_pubkey.clone(),
+ seller_actor_source: canonical.seller_actor_source.clone(),
event_kind: KIND_LISTING,
dry_run: false,
deduplicated: false,
@@ -1843,7 +2203,9 @@ fn direct_relay_error_view(
file: args.file.display().to_string(),
listing_id: canonical.listing_id.clone(),
listing_addr: listing_addr.clone(),
+ seller_account_id: canonical.seller_account_id.clone(),
seller_pubkey: canonical.seller_pubkey.clone(),
+ seller_actor_source: canonical.seller_actor_source.clone(),
event_kind: KIND_LISTING,
dry_run: false,
deduplicated: false,
@@ -1947,7 +2309,11 @@ fn resolve_listing_signing_identity(
config: &RuntimeConfig,
canonical: &CanonicalListingDraft,
) -> Result<accounts::AccountSigningIdentity, RuntimeError> {
- let signing = accounts::resolve_local_signing_identity(config)?;
+ let signing = accounts::resolve_local_signing_identity_for_account(
+ config,
+ canonical.seller_account_id.as_str(),
+ )
+ .map_err(|error| listing_bound_signing_error(error, canonical))?;
let account_pubkey = signing
.account
.record
@@ -1955,15 +2321,66 @@ fn resolve_listing_signing_identity(
.public_key_hex
.as_str();
if !account_pubkey.eq_ignore_ascii_case(canonical.seller_pubkey.as_str()) {
- return Err(accounts::AccountRuntimeFailure::mismatch(format!(
- "account mismatch: resolved account pubkey `{account_pubkey}` cannot sign listing seller_pubkey `{}`",
- canonical.seller_pubkey
- ))
+ return Err(accounts::AccountRuntimeFailure::mismatch_with_detail(
+ format!(
+ "account mismatch: listing-bound seller account `{}` pubkey `{account_pubkey}` cannot sign listing seller_pubkey `{}`",
+ canonical.seller_account_id, canonical.seller_pubkey
+ ),
+ json!({
+ "seller_actor_source": canonical.seller_actor_source,
+ "listing_seller_account_id": canonical.seller_account_id,
+ "listing_seller_pubkey": canonical.seller_pubkey,
+ "account_pubkey": account_pubkey,
+ "actions": [
+ "radroots account import <path>",
+ "radroots account attach-secret <account-id> <path>",
+ ],
+ }),
+ )
.into());
}
Ok(signing)
}
+fn listing_bound_signing_error(
+ error: RuntimeError,
+ canonical: &CanonicalListingDraft,
+) -> RuntimeError {
+ match error {
+ RuntimeError::Account(accounts::AccountRuntimeFailure::Unresolved(issue)) => {
+ accounts::AccountRuntimeFailure::unresolved_with_detail(
+ issue.message().to_owned(),
+ json!({
+ "seller_actor_source": canonical.seller_actor_source,
+ "listing_seller_account_id": canonical.seller_account_id,
+ "listing_seller_pubkey": canonical.seller_pubkey,
+ "actions": [
+ "radroots account import <path>",
+ format!("radroots listing rebind <file> {}", canonical.seller_account_id),
+ ],
+ }),
+ )
+ .into()
+ }
+ RuntimeError::Account(accounts::AccountRuntimeFailure::WatchOnly(issue)) => {
+ accounts::AccountRuntimeFailure::watch_only_with_detail(
+ &canonical.seller_account_id,
+ json!({
+ "seller_actor_source": canonical.seller_actor_source,
+ "listing_seller_account_id": canonical.seller_account_id,
+ "listing_seller_pubkey": canonical.seller_pubkey,
+ "reason": issue.message(),
+ "actions": [
+ format!("radroots account attach-secret {} <path>", canonical.seller_account_id),
+ ],
+ }),
+ )
+ .into()
+ }
+ other => other,
+ }
+}
+
fn binding_error_view(
config: &RuntimeConfig,
args: &ListingMutationArgs,
@@ -1984,7 +2401,9 @@ fn binding_error_view(
file: args.file.display().to_string(),
listing_id: canonical.listing_id.clone(),
listing_addr,
+ seller_account_id: canonical.seller_account_id.clone(),
seller_pubkey: canonical.seller_pubkey.clone(),
+ seller_actor_source: canonical.seller_actor_source.clone(),
event_kind: KIND_LISTING,
dry_run: false,
deduplicated: false,
@@ -2045,7 +2464,9 @@ fn published_mutation_view(
file: args.file.display().to_string(),
listing_id: canonical.listing_id.clone(),
listing_addr: listing_addr.clone(),
+ seller_account_id: canonical.seller_account_id.clone(),
seller_pubkey: canonical.seller_pubkey.clone(),
+ seller_actor_source: canonical.seller_actor_source.clone(),
event_kind: KIND_LISTING,
dry_run: false,
deduplicated: false,
@@ -2178,7 +2599,7 @@ fn issue_from_trade_validation(
match error {
RadrootsTradeListingValidationError::InvalidSeller => issue_for_field(
contents,
- "listing.seller_pubkey",
+ "seller_actor.pubkey",
"listing author does not match the farm pubkey",
),
RadrootsTradeListingValidationError::MissingTitle => {
@@ -2222,7 +2643,20 @@ fn issue_from_trade_validation(
}
fn authoring_defaults(config: &RuntimeConfig) -> Result<ListingAuthoringDefaults, RuntimeError> {
- let selected_account = accounts::resolve_account(config)?;
+ let account_resolution = accounts::resolve_account_resolution(config)?;
+ let Some(selected_account) = account_resolution.resolved_account.clone() else {
+ return Err(accounts::AccountRuntimeFailure::unresolved_with_detail(
+ "no resolved account is available for listing seller actor",
+ json!({
+ "seller_actor_source": LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT,
+ "actions": [
+ "radroots account create",
+ "radroots account import <path>",
+ ],
+ }),
+ )
+ .into());
+ };
let mut defaults = ListingAuthoringDefaults {
farm_config_present: false,
farm_defaults_ready: false,
@@ -2232,12 +2666,13 @@ fn authoring_defaults(config: &RuntimeConfig) -> Result<ListingAuthoringDefaults
.to_owned(),
),
farm_name: None,
- selected_account_id: selected_account
- .as_ref()
- .map(|account| account.record.account_id.to_string()),
- selected_account_pubkey: selected_account
- .as_ref()
- .map(|account| account.record.public_identity.public_key_hex.clone()),
+ seller_account_id: selected_account.record.account_id.to_string(),
+ seller_pubkey: selected_account
+ .record
+ .public_identity
+ .public_key_hex
+ .clone(),
+ seller_actor_source: LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT.to_owned(),
selected_farm_d_tag: None,
delivery_method: None,
location: None,
@@ -2273,8 +2708,9 @@ fn authoring_defaults(config: &RuntimeConfig) -> Result<ListingAuthoringDefaults
.and_then(non_empty)
.or_else(|| non_empty(resolved.document.profile.name.clone()))
.or_else(|| non_empty(resolved.document.farm.name.clone()));
- defaults.selected_account_id = Some(resolved.document.selection.account.clone());
- defaults.selected_account_pubkey = Some(account.record.public_identity.public_key_hex.clone());
+ defaults.seller_account_id = resolved.document.selection.account.clone();
+ defaults.seller_pubkey = account.record.public_identity.public_key_hex.clone();
+ defaults.seller_actor_source = LISTING_SELLER_ACTOR_SOURCE_FARM_CONFIG.to_owned();
defaults.selected_farm_d_tag = Some(resolved.document.selection.farm_d_tag.clone());
let draft_missing = farm_config::missing_fields(&resolved.document);
defaults.farm_defaults_ready = !draft_missing.iter().any(|field| {
@@ -2300,18 +2736,6 @@ fn authoring_defaults(config: &RuntimeConfig) -> Result<ListingAuthoringDefaults
Ok(defaults)
}
-fn resolve_selected_farm_d_tag(
- config: &RuntimeConfig,
- seller_pubkey: &str,
-) -> Result<Option<String>, RuntimeError> {
- if !config.local.replica_db_path.exists() {
- return Ok(None);
- }
- let db = ReplicaSql::new(SqliteExecutor::open(&config.local.replica_db_path)?);
- db.farm_unique_d_tag_by_pubkey(seller_pubkey)
- .map_err(RuntimeError::from)
-}
-
fn draft_location_from_model(location: &RadrootsListingLocation) -> ListingDraftLocation {
ListingDraftLocation {
primary: location.primary.clone(),
@@ -2416,7 +2840,9 @@ fn line_for_field(contents: &str, field: &str) -> Option<usize> {
"kind" => &["kind ="],
"listing.d_tag" => &["d_tag ="],
"listing.farm_d_tag" => &["farm_d_tag ="],
- "listing.seller_pubkey" => &["seller_pubkey ="],
+ "seller_actor.account_id" => &["[seller_actor]", "account_id ="],
+ "seller_actor.pubkey" => &["[seller_actor]", "pubkey ="],
+ "seller_actor.source" => &["[seller_actor]", "source ="],
"product.key" => &["key ="],
"product.title" => &["title ="],
"product.category" => &["category ="],
@@ -2669,7 +3095,11 @@ mod tests {
listing: super::ListingDraftMeta {
d_tag: "AAAAAAAAAAAAAAAAAAAAAg".to_owned(),
farm_d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_owned(),
- seller_pubkey: "a".repeat(64),
+ },
+ seller_actor: super::ListingDraftSellerActor {
+ account_id: "acct_seller".to_owned(),
+ pubkey: "a".repeat(64),
+ source: super::LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT.to_owned(),
},
product: super::ListingDraftProduct {
key: "sku".to_owned(),
@@ -2720,7 +3150,11 @@ mod tests {
listing: super::ListingDraftMeta {
d_tag: "AAAAAAAAAAAAAAAAAAAAAg".to_owned(),
farm_d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_owned(),
- seller_pubkey: seller_pubkey.clone(),
+ },
+ seller_actor: super::ListingDraftSellerActor {
+ account_id: "acct_seller".to_owned(),
+ pubkey: seller_pubkey.clone(),
+ source: super::LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT.to_owned(),
},
product: super::ListingDraftProduct {
key: "sku".to_owned(),
@@ -2769,9 +3203,6 @@ mod tests {
};
let contents = toml::to_string_pretty(&document).expect("render draft");
let context = super::ListingValidationContext {
- selected_account_id: Some("acct_seller".to_owned()),
- selected_account_pubkey: Some(seller_pubkey),
- selected_farm_d_tag: Some("AAAAAAAAAAAAAAAAAAAAAw".to_owned()),
farm_setup_action: "radroots farm create".to_owned(),
};
diff --git a/src/runtime_args.rs b/src/runtime_args.rs
@@ -168,6 +168,13 @@ pub struct ListingFileArgs {
}
#[derive(Debug, Clone)]
+pub struct ListingRebindArgs {
+ pub file: PathBuf,
+ pub selector: String,
+ pub farm_d_tag: Option<String>,
+}
+
+#[derive(Debug, Clone)]
pub struct ListingMutationArgs {
pub file: PathBuf,
pub idempotency_key: Option<String>,
diff --git a/src/target_cli.rs b/src/target_cli.rs
@@ -185,6 +185,7 @@ impl TargetCommand {
ListingCommand::List => "listing.list",
ListingCommand::Update(_) => "listing.update",
ListingCommand::Validate(_) => "listing.validate",
+ ListingCommand::Rebind(_) => "listing.rebind",
ListingCommand::Publish(_) => "listing.publish",
ListingCommand::Archive(_) => "listing.archive",
},
@@ -588,6 +589,7 @@ pub enum ListingCommand {
List,
Update(FileArgs),
Validate(FileArgs),
+ Rebind(ListingRebindArgs),
Publish(FileArgs),
Archive(FileArgs),
}
@@ -642,6 +644,14 @@ pub struct FileArgs {
}
#[derive(Debug, Clone, Args)]
+pub struct ListingRebindArgs {
+ pub file: Option<PathBuf>,
+ pub selector: Option<String>,
+ #[arg(long = "farm-d-tag")]
+ pub farm_d_tag: Option<String>,
+}
+
+#[derive(Debug, Clone, Args)]
pub struct LookupArgs {
pub key: Option<String>,
}
@@ -1047,7 +1057,7 @@ mod tests {
use clap::{CommandFactory, Parser};
use super::{
- AccountCommand, FarmCommand, OrderCommand, OrderFulfillmentCommand,
+ AccountCommand, FarmCommand, ListingCommand, OrderCommand, OrderFulfillmentCommand,
OrderFulfillmentStateArg, OrderPaymentCommand, OrderReceiptCommand, OrderRevisionCommand,
OrderSettlementCommand, TargetCliArgs, TargetOutputFormat,
};
@@ -1180,6 +1190,34 @@ mod tests {
}
#[test]
+ fn target_parser_accepts_listing_rebind_inputs() {
+ let parsed = TargetCliArgs::try_parse_from([
+ "radroots",
+ "listing",
+ "rebind",
+ "listing.toml",
+ "acct_test",
+ "--farm-d-tag",
+ "AAAAAAAAAAAAAAAAAAAAAw",
+ ])
+ .expect("target args parse");
+
+ assert_eq!(parsed.command.operation_id(), "listing.rebind");
+ let crate::target_cli::TargetCommand::Listing(listing) = parsed.command else {
+ panic!("expected listing command")
+ };
+ let ListingCommand::Rebind(args) = listing.command else {
+ panic!("expected listing rebind command")
+ };
+ assert_eq!(
+ args.file.as_ref().map(|path| path.as_os_str()),
+ Some(std::ffi::OsStr::new("listing.toml"))
+ );
+ assert_eq!(args.selector.as_deref(), Some("acct_test"));
+ assert_eq!(args.farm_d_tag.as_deref(), Some("AAAAAAAAAAAAAAAAAAAAAw"));
+ }
+
+ #[test]
fn target_parser_accepts_order_fulfillment_update_state() {
let parsed = TargetCliArgs::try_parse_from([
"radroots",
diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs
@@ -791,7 +791,21 @@ fn myc_mode_allows_read_inspection_commands() {
#[test]
fn local_listing_publish_fails_without_local_account_authority() {
let sandbox = RadrootsCliSandbox::new();
+ let account = sandbox.json_success(&["--format", "json", "account", "create"]);
+ let account_id = account["result"]["account"]["id"]
+ .as_str()
+ .expect("account id");
let listing_file = create_listing_draft(&sandbox, "local-no-account");
+ make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw");
+ sandbox.json_success(&[
+ "--format",
+ "json",
+ "--approval-token",
+ "approve",
+ "account",
+ "remove",
+ account_id,
+ ]);
let (output, value) = sandbox.json_output(&[
"--format",
@@ -813,14 +827,28 @@ fn local_listing_publish_fails_without_local_account_authority() {
assert_eq!(value["errors"][0]["detail"]["class"], "account");
assert_contains(
&value["errors"][0]["message"],
- "no resolved account pubkey is available",
+ "listing-bound seller account",
);
}
#[test]
fn local_listing_publish_dry_run_validates_local_account_authority() {
let sandbox = RadrootsCliSandbox::new();
+ let account = sandbox.json_success(&["--format", "json", "account", "create"]);
+ let account_id = account["result"]["account"]["id"]
+ .as_str()
+ .expect("account id");
let listing_file = create_listing_draft(&sandbox, "local-dry-run-no-account");
+ make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw");
+ sandbox.json_success(&[
+ "--format",
+ "json",
+ "--approval-token",
+ "approve",
+ "account",
+ "remove",
+ account_id,
+ ]);
let (output, value) = sandbox.json_output(&[
"--format",
@@ -842,7 +870,21 @@ fn local_listing_publish_dry_run_validates_local_account_authority() {
#[test]
fn local_listing_update_dry_run_validates_local_account_authority() {
let sandbox = RadrootsCliSandbox::new();
+ let account = sandbox.json_success(&["--format", "json", "account", "create"]);
+ let account_id = account["result"]["account"]["id"]
+ .as_str()
+ .expect("account id");
let listing_file = create_listing_draft(&sandbox, "local-update-dry-run-no-account");
+ make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw");
+ sandbox.json_success(&[
+ "--format",
+ "json",
+ "--approval-token",
+ "approve",
+ "account",
+ "remove",
+ account_id,
+ ]);
let (output, value) = sandbox.json_output(&[
"--format",
@@ -1013,7 +1055,7 @@ fn local_listing_publish_fails_when_selected_account_does_not_match_seller() {
assert_eq!(value["errors"][0]["detail"]["class"], "account");
assert_contains(
&value["errors"][0]["message"],
- "cannot sign listing seller_pubkey",
+ "listing draft is bound to seller account",
);
assert_no_removed_command_reference(&value, &["listing", "publish", "account mismatch"]);
}
diff --git a/tests/support/mod.rs b/tests/support/mod.rs
@@ -308,6 +308,10 @@ pub fn replace_latest_listing_event_id(
}
pub fn create_listing_draft(sandbox: &RadrootsCliSandbox, key: &str) -> PathBuf {
+ let accounts = sandbox.json_success(&["--format", "json", "account", "list"]);
+ if accounts["result"]["count"].as_u64().unwrap_or_default() == 0 {
+ sandbox.json_success(&["--format", "json", "account", "create"]);
+ }
let listing_file = sandbox.root().join(format!("{key}.toml"));
let listing_file_arg = listing_file.to_string_lossy();
let value = sandbox.json_success(&[
@@ -358,11 +362,15 @@ pub fn identity_secret(seed: u8) -> RadrootsIdentity {
pub fn make_listing_publishable(path: &Path, farm_d_tag: &str) {
let raw = fs::read_to_string(path).expect("listing draft");
let mut seller_pubkey_present = false;
+ let mut in_seller_actor = false;
let patched = raw
.lines()
.map(|line| {
let trimmed = line.trim_start();
- if trimmed.starts_with("seller_pubkey =") {
+ if trimmed.starts_with('[') {
+ in_seller_actor = trimmed == "[seller_actor]";
+ }
+ if in_seller_actor && trimmed.starts_with("pubkey =") {
seller_pubkey_present = !trimmed.ends_with("\"\"");
line.to_owned()
} else if trimmed.starts_with("farm_d_tag =") {
@@ -384,13 +392,17 @@ pub fn make_listing_publishable(path: &Path, farm_d_tag: &str) {
pub fn make_listing_publishable_with_seller(path: &Path, farm_d_tag: &str, seller_pubkey: &str) {
let raw = fs::read_to_string(path).expect("listing draft");
let mut seller_pubkey_field_present = false;
+ let mut in_seller_actor = false;
let patched = raw
.lines()
.map(|line| {
let trimmed = line.trim_start();
- if trimmed.starts_with("seller_pubkey =") {
+ if trimmed.starts_with('[') {
+ in_seller_actor = trimmed == "[seller_actor]";
+ }
+ if in_seller_actor && trimmed.starts_with("pubkey =") {
seller_pubkey_field_present = true;
- format!("{}seller_pubkey = \"{}\"", line_indent(line), seller_pubkey)
+ format!("{}pubkey = \"{}\"", line_indent(line), seller_pubkey)
} else if trimmed.starts_with("farm_d_tag =") {
format!("{}farm_d_tag = \"{}\"", line_indent(line), farm_d_tag)
} else if trimmed.starts_with("method =") {
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -59,7 +59,7 @@ impl OneShotJsonRpcServer {
"signer_session_id": "session_test",
"event_kind": 30402,
"event_id": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
- "event_addr": "30402:daemon_test:radrootsd-router",
+ "event_addr": null,
"relay_count": 2,
"acknowledged_relay_count": 1
}
@@ -989,14 +989,10 @@ signer_session_ref = "session_test"
assert_eq!(value["result"]["event_id"], "e".repeat(64));
assert_eq!(
value["result"]["event_addr"],
- "30402:daemon_test:radrootsd-router"
+ value["result"]["listing_addr"]
);
- assert_eq!(
- value["result"]["listing_addr"],
- "30402:daemon_test:radrootsd-router"
- );
- assert_eq!(value["result"]["listing_id"], "radrootsd-router");
- assert_eq!(value["result"]["seller_pubkey"], "daemon_test");
+ assert!(value["result"]["listing_id"].is_string());
+ assert!(value["result"]["seller_pubkey"].is_string());
assert_eq!(value["result"]["signer_mode"], "nip46");
assert_eq!(value["result"]["signer_session_id"], "session_test");
assert_eq!(
@@ -1205,7 +1201,7 @@ fn radrootsd_farm_publish_missing_signer_binding_points_to_capability_binding()
}
#[test]
-fn radrootsd_listing_writes_dry_run_use_draft_identity_without_local_account() {
+fn radrootsd_listing_writes_dry_run_reject_missing_invocation_account() {
for operation in ["publish", "update", "archive"] {
let sandbox = RadrootsCliSandbox::new();
let seller = identity_public(42);
@@ -1249,25 +1245,16 @@ signer_session_ref = "session_test"
.expect("run radrootsd dry-run listing write");
let value: Value = serde_json::from_slice(&output.stdout).expect("json output");
- assert!(output.status.success());
+ assert!(!output.status.success());
assert_eq!(value["operation_id"], format!("listing.{operation}"));
- assert_eq!(value["result"]["state"], "dry_run");
- assert_eq!(
- value["result"]["source"],
- "radrootsd publish transport · signer session"
- );
- assert_eq!(value["result"]["seller_pubkey"], seller.public_key_hex);
- assert_eq!(
- value["result"]["requested_signer_session_id"],
- "session_test"
- );
- assert_eq!(value["result"]["signer_mode"], "nip46");
- assert_eq!(value["errors"].as_array().expect("errors").len(), 0);
+ assert_eq!(value["result"], Value::Null);
+ assert_eq!(value["errors"][0]["code"], "account_unresolved");
+ assert_eq!(value["errors"][0]["detail"]["class"], "account");
}
}
#[test]
-fn radrootsd_listing_writes_use_draft_identity_without_local_account() {
+fn radrootsd_listing_writes_reject_missing_invocation_account() {
for operation in ["publish", "update", "archive"] {
let sandbox = RadrootsCliSandbox::new();
let seller = identity_public(43);
@@ -1292,11 +1279,8 @@ target = "http://myc.invalid"
signer_session_ref = "session_test"
"#,
);
- let server = OneShotJsonRpcServer::listing_publish();
-
let mut command = sandbox.command();
command
- .env("RADROOTS_RPC_URL", &server.endpoint)
.env("RADROOTS_RPC_BEARER_TOKEN", "bridge_test")
.args([
"--format",
@@ -1311,23 +1295,12 @@ signer_session_ref = "session_test"
]);
let output = command.output().expect("run radrootsd listing write");
let value: Value = serde_json::from_slice(&output.stdout).expect("json output");
- let request = server.take_request();
- assert!(output.status.success());
+ assert!(!output.status.success());
assert_eq!(value["operation_id"], format!("listing.{operation}"));
- assert_eq!(
- value["result"]["source"],
- "radrootsd publish transport · signer session"
- );
- assert_eq!(
- value["result"]["listing_addr"],
- "30402:daemon_test:radrootsd-router"
- );
- assert_eq!(value["result"]["listing_id"], "radrootsd-router");
- assert_eq!(value["result"]["seller_pubkey"], "daemon_test");
- assert_eq!(request.body["method"], "bridge.listing.publish");
- assert_eq!(request.body["params"]["signer_session_id"], "session_test");
- assert_eq!(value["errors"].as_array().expect("errors").len(), 0);
+ assert_eq!(value["result"], Value::Null);
+ assert_eq!(value["errors"][0]["code"], "account_unresolved");
+ assert_eq!(value["errors"][0]["detail"]["class"], "account");
}
}
@@ -1352,14 +1325,9 @@ fn radrootsd_listing_publish_bridge_errors_are_classified() {
),
] {
let sandbox = RadrootsCliSandbox::new();
- let seller = identity_public(44);
let listing_file =
create_listing_draft(&sandbox, format!("radrootsd-bridge-error-{class}").as_str());
- make_listing_publishable_with_seller(
- &listing_file,
- "AAAAAAAAAAAAAAAAAAAAAw",
- seller.public_key_hex.as_str(),
- );
+ make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw");
sandbox.write_app_config(
r#"[publish]
mode = "radrootsd"
@@ -2054,6 +2022,103 @@ fn listing_list_reports_default_local_drafts() {
}
#[test]
+fn listing_rebind_updates_seller_actor_with_approval() {
+ let sandbox = RadrootsCliSandbox::new();
+ let first = sandbox.json_success(&["--format", "json", "account", "create"]);
+ let first_account_id = first["result"]["account"]["id"]
+ .as_str()
+ .expect("first account id");
+ let listing_file = create_listing_draft(&sandbox, "rebind-listing");
+ make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw");
+ let initial_validation = sandbox.json_success(&[
+ "--format",
+ "json",
+ "listing",
+ "validate",
+ listing_file.to_string_lossy().as_ref(),
+ ]);
+ let first_pubkey = initial_validation["result"]["seller_pubkey"]
+ .as_str()
+ .expect("first pubkey");
+ let before = fs::read_to_string(&listing_file).expect("listing before");
+ let second = sandbox.json_success(&["--format", "json", "account", "create"]);
+ let second_account_id = second["result"]["account"]["id"]
+ .as_str()
+ .expect("second account id");
+
+ let dry_run = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "listing",
+ "rebind",
+ listing_file.to_string_lossy().as_ref(),
+ second_account_id,
+ "--farm-d-tag",
+ "AAAAAAAAAAAAAAAAAAAAAw",
+ ]);
+ assert_eq!(dry_run["operation_id"], "listing.rebind");
+ assert_eq!(dry_run["result"]["state"], "dry_run");
+ assert_eq!(
+ dry_run["result"]["from_seller_account_id"],
+ first_account_id
+ );
+ assert_eq!(dry_run["result"]["from_seller_pubkey"], first_pubkey);
+ assert_eq!(dry_run["result"]["to_seller_account_id"], second_account_id);
+ let second_pubkey = dry_run["result"]["to_seller_pubkey"]
+ .as_str()
+ .expect("second pubkey");
+ assert_eq!(dry_run["result"]["seller_pubkey_changed"], true);
+ assert_eq!(
+ fs::read_to_string(&listing_file).expect("listing after dry-run"),
+ before
+ );
+
+ let unapproved = sandbox.json_output(&[
+ "--format",
+ "json",
+ "listing",
+ "rebind",
+ listing_file.to_string_lossy().as_ref(),
+ second_account_id,
+ "--farm-d-tag",
+ "AAAAAAAAAAAAAAAAAAAAAw",
+ ]);
+ assert!(!unapproved.0.status.success());
+ assert_eq!(unapproved.1["errors"][0]["code"], "approval_required");
+
+ let rebound = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--approval-token",
+ "approve",
+ "listing",
+ "rebind",
+ listing_file.to_string_lossy().as_ref(),
+ second_account_id,
+ "--farm-d-tag",
+ "AAAAAAAAAAAAAAAAAAAAAw",
+ ]);
+ assert_eq!(rebound["operation_id"], "listing.rebind");
+ assert_eq!(rebound["result"]["state"], "rebound");
+ let after = fs::read_to_string(&listing_file).expect("listing after rebind");
+ assert!(after.contains("[seller_actor]"));
+ assert!(after.contains(second_account_id));
+ assert!(after.contains("source = \"listing_rebind\""));
+
+ let validation = sandbox.json_success(&[
+ "--format",
+ "json",
+ "listing",
+ "validate",
+ listing_file.to_string_lossy().as_ref(),
+ ]);
+ assert_eq!(validation["result"]["valid"], true);
+ assert_eq!(validation["result"]["seller_account_id"], second_account_id);
+ assert_eq!(validation["result"]["seller_pubkey"], second_pubkey);
+}
+
+#[test]
fn account_id_global_populates_envelope_actor() {
let output = radroots()
.args([