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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ All notable changes to this project will be documented in this file.

### Changes

- Client / SDK
- `connect` and `GetAccessPassCommand`'s callers (`CreateUserCommand`, `CreateSubscribeUserCommand`) now prefer whichever of the dynamic (`0.0.0.0`) and exact-IP access passes actually clears the epoch check for the user type being created, instead of always taking the dynamic pass when one exists. A dynamic pass left over from a never-epoch-gated EdgeSeat multicast subscription could go stale while a valid exact-IP prepaid pass sat unused at the same address, so `doublezero connect ibrl` picked the stale pass and failed with "Unable to find a valid AccessPass" even though a usable one existed. New `GetAccessPassCommand::execute_usable` (only reads the epoch when both candidates exist); `execute` is unchanged for every other caller. (#4244)

## [v0.40.0](https://github.com/malbeclabs/doublezero/compare/client/v0.39.0...client/v0.40.0) - 2026-09-11

### Breaking
Expand Down
4 changes: 3 additions & 1 deletion client/doublezero/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,16 @@ impl<C: CliCommand + Sync> doublezero_daemon_cli::LedgerClient for LedgerAdapter
&self,
client_ip: std::net::Ipv4Addr,
user_payer: solana_sdk::pubkey::Pubkey,
user_type: doublezero_sdk::UserType,
) -> eyre::Result<Option<doublezero_serviceability::state::accesspass::AccessPass>> {
Ok(self
.client
.get_accesspass(
.get_accesspass_usable(
doublezero_sdk::commands::accesspass::get::GetAccessPassCommand {
client_ip,
user_payer,
},
user_type,
)?
.map(|(_, accesspass)| accesspass))
}
Expand Down
43 changes: 22 additions & 21 deletions crates/doublezero-daemon-cli/src/connect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,9 +183,10 @@ enum FeedJoinUser {
fn check_accesspass<L: LedgerClient>(
ledger: &L,
client_ip: Ipv4Addr,
user_type: UserType,
enforce_epoch: bool,
) -> eyre::Result<bool> {
let Some(accesspass) = ledger.get_accesspass(client_ip, ledger.get_payer())? else {
let Some(accesspass) = ledger.get_accesspass(client_ip, ledger.get_payer(), user_type)? else {
return Ok(false);
};

Expand All @@ -203,9 +204,10 @@ fn check_accesspass<L: LedgerClient>(
fn require_accesspass<L: LedgerClient, W: Write>(
ledger: &L,
client_ip: Ipv4Addr,
user_type: UserType,
out: &mut W,
) -> eyre::Result<AccessPass> {
match ledger.get_accesspass(client_ip, ledger.get_payer())? {
match ledger.get_accesspass(client_ip, ledger.get_payer(), user_type)? {
Some(accesspass) => Ok(accesspass),
None => {
writeln!(
Expand Down Expand Up @@ -522,13 +524,14 @@ impl Connect {
}

let parsed_mode = self.parse_dz_mode()?;
let user_type = proof_user_type(&parsed_mode);
// Multicast users are not subject to epoch expiry — only verify the AccessPass exists.
let enforce_epoch = !matches!(
parsed_mode,
ParsedDzMode::Multicast { .. } | ParsedDzMode::MulticastFeeds { .. }
);

if !check_accesspass(ledger, client_ip, enforce_epoch)? {
if !check_accesspass(ledger, client_ip, user_type, enforce_epoch)? {
writeln!(
out,
"❌ Unable to find a valid AccessPass for the IP: {client_ip_str} UserPayer: {}",
Expand All @@ -547,12 +550,7 @@ impl Connect {
// bare form runs two legs and builds one per leg; it returned above.) Nothing is
// requested here — see [`IpProofFetcher`] for why the request waits until a creation
// path asks for it.
let ip_proof = IpProofFetcher::new(
proof_client,
ledger.get_payer(),
proof_user_type(&parsed_mode),
client_ip,
);
let ip_proof = IpProofFetcher::new(proof_client, ledger.get_payer(), user_type, client_ip);
let ip_proof = &ip_proof;

let provisioned = match parsed_mode {
Expand Down Expand Up @@ -625,7 +623,14 @@ impl Connect {
spinner: &ProgressBar,
out: &mut W,
) -> eyre::Result<()> {
let accesspass = require_accesspass(ledger, client_ip, out)?;
// Only the IBRL leg is epoch-gated (Multicast is exempt), so the preflight pass must be
// the one that leg would actually use.
let ibrl_user_type = if self.allocate_addr {
UserType::IBRLWithAllocatedIP
} else {
UserType::IBRL
};
let accesspass = require_accesspass(ledger, client_ip, ibrl_user_type, out)?;

spinner.inc(1);
writeln!(out, " DoubleZero ID: {}", ledger.get_payer())?;
Expand Down Expand Up @@ -675,21 +680,16 @@ impl Connect {
accesspass.unicast_user_count, accesspass.max_unicast_users
))
} else {
let user_type = if self.allocate_addr {
UserType::IBRLWithAllocatedIP
} else {
UserType::IBRL
};
// A proof per leg, not per invocation: the proof binds `user_type`, and this form
// creates a unicast user and a multicast one. A single proof would be refused
// onchain by whichever leg it did not name.
let ip_proof =
IpProofFetcher::new(proof_client, ledger.get_payer(), user_type, client_ip);
IpProofFetcher::new(proof_client, ledger.get_payer(), ibrl_user_type, client_ip);
match self
.execute_ibrl(
ledger,
daemon,
user_type,
ibrl_user_type,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bare connect gates multicast on IBRL pass

Medium Severity

Bare doublezero connect resolves the preflight pass with the IBRL user_type, then uses that same account for the multicast EdgeSeat seat-cap skip. The multicast leg independently resolves with UserType::Multicast, which still treats a stale dynamic pass as usable. When the two PDAs differ, multicast can be skipped even though a usable pass exists, or attempted against a pass whose seats were never checked.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 40e905e. Configure here.

client_ip,
self.tenant.clone(),
&ip_proof,
Expand Down Expand Up @@ -783,7 +783,7 @@ impl Connect {
// subscriber allowlist. The pass is guaranteed to exist (validated by
// check_accesspass before dispatch); the ok_or_else is defensive.
let accesspass = ledger
.get_accesspass(client_ip, ledger.get_payer())?
.get_accesspass(client_ip, ledger.get_payer(), UserType::Multicast)?
.ok_or_else(|| {
eyre::eyre!(
"No valid AccessPass found for IP: {} user_payer: {}",
Expand Down Expand Up @@ -1511,7 +1511,7 @@ impl Connect {
// Refuse here what the program would refuse after the create: by then the bare user
// would already exist, holding a multicast slot and a device seat for nothing.
let accesspass = ledger
.get_accesspass(client_ip, ledger.get_payer())?
.get_accesspass(client_ip, ledger.get_payer(), UserType::Multicast)?
.ok_or_else(|| {
eyre::eyre!(
"No valid AccessPass found for IP: {client_ip} user_payer: {}",
Expand Down Expand Up @@ -1837,7 +1837,7 @@ impl Connect {
}

let accesspass = ledger
.get_accesspass(*client_ip, ledger.get_payer())?
.get_accesspass(*client_ip, ledger.get_payer(), user_type)?
.ok_or_else(|| {
eyre::eyre!(
"No valid AccessPass found for IP: {} user_payer: {}",
Expand Down Expand Up @@ -3024,8 +3024,9 @@ mod tests {
.with(
predicate::eq(Ipv4Addr::new(1, 2, 3, 4)),
predicate::eq(payer),
predicate::always(),
)
.returning_st(move |_, _| Ok(Some(accesspass.lock().unwrap().clone())));
.returning_st(move |_, _, _| Ok(Some(accesspass.lock().unwrap().clone())));

let users = fixture.users.clone();
fixture
Expand Down
9 changes: 6 additions & 3 deletions crates/doublezero-daemon-cli/src/ledger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use doublezero_sdk::{
},
user::{create::CreateUserCommand, create_subscribe::CreateSubscribeUserCommand},
},
Device, Exchange, Feed, GlobalState, MulticastGroup, Tenant, User,
Device, Exchange, Feed, GlobalState, MulticastGroup, Tenant, User, UserType,
};
use doublezero_serviceability::state::accesspass::AccessPass;
use mockall::automock;
Expand Down Expand Up @@ -64,12 +64,15 @@ pub trait LedgerClient: Send + Sync {
/// The current DZ ledger epoch (used for AccessPass expiry enforcement).
fn get_epoch(&self) -> eyre::Result<u64>;

/// Fetch the AccessPass for `(client_ip, user_payer)`, or `None` if no
/// such pass exists.
/// Fetch the AccessPass for `(client_ip, user_payer)` that is actually usable for
/// `user_type`, or `None` if no such pass exists. Preferring the pass a subsequent
/// `create_user`/`create_subscribe_user` call for the same `user_type` could use keeps
/// this preflight from disagreeing with the transaction it gates (#4244).
fn get_accesspass(
&self,
client_ip: Ipv4Addr,
user_payer: Pubkey,
user_type: UserType,
) -> eyre::Result<Option<AccessPass>>;

/// Fetch a device by pubkey or code.
Expand Down
17 changes: 16 additions & 1 deletion smartcontract/cli/src/doublezerocommand.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ use doublezero_sdk::{
telemetry::LinkLatencyStats,
DZClient, DZTransaction, Device, DoubleZeroClient, Exchange, Feed, GetGlobalConfigCommand,
GetGlobalStateCommand, GlobalConfig, GlobalState, Link, Location, MulticastGroup,
ResourceExtensionOwned, TopologyInfo, User,
ResourceExtensionOwned, TopologyInfo, User, UserType,
};
use doublezero_serviceability::state::{
accesspass::AccessPass, accountdata::AccountData, contributor::Contributor,
Expand Down Expand Up @@ -328,6 +328,14 @@ pub trait CliCommand {
&self,
cmd: GetAccessPassCommand,
) -> eyre::Result<Option<(Pubkey, AccessPass)>>;
/// Like `get_accesspass`, but for a caller about to create a `user_type` user: prefers
/// whichever candidate pass actually clears the access-pass epoch check for that user type
/// (see `GetAccessPassCommand::execute_usable`).
fn get_accesspass_usable(
&self,
cmd: GetAccessPassCommand,
user_type: UserType,
) -> eyre::Result<Option<(Pubkey, AccessPass)>>;
fn list_accesspass(
&self,
cmd: ListAccessPassCommand,
Expand Down Expand Up @@ -795,6 +803,13 @@ impl CliCommand for CliCommandImpl<'_> {
) -> eyre::Result<Option<(Pubkey, AccessPass)>> {
cmd.execute(self.client)
}
fn get_accesspass_usable(
&self,
cmd: GetAccessPassCommand,
user_type: UserType,
) -> eyre::Result<Option<(Pubkey, AccessPass)>> {
cmd.execute_usable(self.client, user_type)
}
fn list_accesspass(
&self,
cmd: ListAccessPassCommand,
Expand Down
Loading