Skip to content
Draft
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,11 +8,14 @@ All notable changes to this project will be documented in this file.

- SDK
- revdist Python SDK migrated to the async solana-py RPC API (solana-py 0.40.0 removed the sync `Client`). The `Client` read methods (`fetch_config`, `fetch_distribution`, etc.) are now coroutines and must be awaited; `new_rpc_client` returns an `AsyncClient`. (#3945)
- Serviceability
- The `AccessPass` `EdgeSeat` variant now carries a `Vec<FeedSeat>` payload (`feed_key` + per-feed cap) instead of being a bare marker. This changes the `AccessPass` borsh layout for EdgeSeat passes. (#1700)

### Added

- Serviceability
- `Feed` account: a catalog mapping `metro(exchange) → group-set`, managed by a catalog admin (`FEED_AUTHORITY` Permission or `FOUNDATION`) via `CreateFeed`/`UpdateFeed`/`DeleteFeed`. A feed with no metros imposes no restriction. (#1700)
- `SetAccessPassFeeds` provisions feed_keys (SKU seats) onto an EdgeSeat pass; the oracle calls it via its `ACCESS_PASS_ADMIN` Permission. (#1700)

### Changes

Expand Down
8 changes: 5 additions & 3 deletions smartcontract/cli/src/accesspass/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,9 @@ fn accesspass_type_short(t: &AccessPassType) -> String {
crate::util::abbreviate_prefix(key)
)
}
AccessPassType::Prepaid | AccessPassType::EdgeSeat => t.to_string(),
// Compact form: the discriminant only (feed details are shown in the get/JSON views).
AccessPassType::EdgeSeat(_) => t.to_discriminant_string(),
AccessPassType::Prepaid => t.to_string(),
}
}

Expand Down Expand Up @@ -212,7 +214,7 @@ impl ListAccessPassCliCommand {
// Filter access passes by EdgeSeat type
if self.edge_seat {
access_passes.retain(|(_, access_pass)| {
matches!(access_pass.accesspass_type, AccessPassType::EdgeSeat)
matches!(access_pass.accesspass_type, AccessPassType::EdgeSeat(_))
});
}
// Filter access passes by client IP
Expand Down Expand Up @@ -640,7 +642,7 @@ mod tests {
"prepaid"
);
assert_eq!(
super::accesspass_type_short(&AccessPassType::EdgeSeat),
super::accesspass_type_short(&AccessPassType::EdgeSeat(vec![])),
"edge_seat"
);
// Others: type_name kept, embedded key truncated to a copyable prefix.
Expand Down
4 changes: 2 additions & 2 deletions smartcontract/cli/src/accesspass/set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ impl SetAccessPassCliCommand {
"Others access pass type requires --others-name <STRING> and --others-key <STRING>"
),
},
CliAccessPassType::EdgeSeat => AccessPassType::EdgeSeat,
CliAccessPassType::EdgeSeat => AccessPassType::EdgeSeat(vec![]),
};

// Convert tenant code to PDA if provided
Expand Down Expand Up @@ -941,7 +941,7 @@ mod tests {
client
.expect_set_accesspass()
.with(predicate::eq(SetAccessPassCommand {
accesspass_type: AccessPassType::EdgeSeat,
accesspass_type: AccessPassType::EdgeSeat(vec![]),
client_ip,
user_payer: payer,
last_access_epoch: 11,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::{
processors::{
accesspass::{
check_status::process_check_status_access_pass, close::process_close_access_pass,
set::process_set_access_pass,
set::process_set_access_pass, set_feeds::process_set_access_pass_feeds,
},
allowlist::{
foundation::{
Expand Down Expand Up @@ -420,6 +420,9 @@ pub fn process_instruction(
DoubleZeroInstruction::DeleteFeed(value) => {
process_delete_feed(program_id, accounts, &value)?
}
DoubleZeroInstruction::SetAccessPassFeeds(value) => {
process_set_access_pass_feeds(program_id, accounts, &value)?
}
};
Ok(())
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::processors::{
accesspass::{
check_status::CheckStatusAccessPassArgs, close::CloseAccessPassArgs, set::SetAccessPassArgs,
check_status::CheckStatusAccessPassArgs, close::CloseAccessPassArgs,
set::SetAccessPassArgs, set_feeds::SetAccessPassFeedsArgs,
},
allowlist::{
foundation::{add::AddFoundationAllowlistArgs, remove::RemoveFoundationAllowlistArgs},
Expand Down Expand Up @@ -246,9 +247,10 @@ pub enum DoubleZeroInstruction {

Deprecated111(), // variant 111, (was MigrateDeviceInterfaces)

CreateFeed(FeedCreateArgs), // variant 112
UpdateFeed(FeedUpdateArgs), // variant 113
DeleteFeed(FeedDeleteArgs), // variant 114
CreateFeed(FeedCreateArgs), // variant 112
UpdateFeed(FeedUpdateArgs), // variant 113
DeleteFeed(FeedDeleteArgs), // variant 114
SetAccessPassFeeds(SetAccessPassFeedsArgs), // variant 115
}

impl DoubleZeroInstruction {
Expand Down Expand Up @@ -394,6 +396,7 @@ impl DoubleZeroInstruction {
112 => Ok(Self::CreateFeed(FeedCreateArgs::try_from(rest).unwrap())),
113 => Ok(Self::UpdateFeed(FeedUpdateArgs::try_from(rest).unwrap())),
114 => Ok(Self::DeleteFeed(FeedDeleteArgs::try_from(rest).unwrap())),
115 => Ok(Self::SetAccessPassFeeds(SetAccessPassFeedsArgs::try_from(rest).unwrap())),

_ => Err(ProgramError::InvalidInstructionData),
}
Expand Down Expand Up @@ -542,6 +545,7 @@ impl DoubleZeroInstruction {
Self::CreateFeed(_) => "CreateFeed".to_string(), // variant 112
Self::UpdateFeed(_) => "UpdateFeed".to_string(), // variant 113
Self::DeleteFeed(_) => "DeleteFeed".to_string(), // variant 114
Self::SetAccessPassFeeds(_) => "SetAccessPassFeeds".to_string(), // variant 115
}
}

Expand Down Expand Up @@ -682,6 +686,7 @@ impl DoubleZeroInstruction {
Self::CreateFeed(args) => format!("{args:?}"), // variant 112
Self::UpdateFeed(args) => format!("{args:?}"), // variant 113
Self::DeleteFeed(args) => format!("{args:?}"), // variant 114
Self::SetAccessPassFeeds(args) => format!("{args:?}"), // variant 115
}
}
}
Expand Down Expand Up @@ -1377,5 +1382,21 @@ mod tests {
DoubleZeroInstruction::DeleteFeed(FeedDeleteArgs {}),
"DeleteFeed",
);
test_instruction(
DoubleZeroInstruction::SetAccessPassFeeds(SetAccessPassFeedsArgs {
client_ip: Ipv4Addr::UNSPECIFIED,
user_payer: Pubkey::new_unique(),
feeds: vec![doublezero_serviceability_feed_seat()],
}),
"SetAccessPassFeeds",
);
}

fn doublezero_serviceability_feed_seat() -> crate::state::accesspass::FeedSeat {
crate::state::accesspass::FeedSeat {
feed_key: Pubkey::new_unique(),
max_users: 4,
current_users: 0,
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
pub mod check_status;
pub mod close;
pub mod set;
pub mod set_feeds;

use solana_program::{
account_info::AccountInfo, entrypoint::ProgramResult, msg, program::invoke_signed_unchecked,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
use crate::{
authorize::authorize,
error::DoubleZeroError,
pda::get_accesspass_pda,
serializer::try_acc_write,
state::{
accesspass::{AccessPass, AccessPassType, FeedSeat},
feed::Feed,
globalstate::GlobalState,
permission::permission_flags,
},
};
use borsh::BorshSerialize;
use borsh_incremental::BorshDeserializeIncremental;
use core::fmt;
use solana_program::{
account_info::{next_account_info, AccountInfo},
entrypoint::ProgramResult,
msg,
pubkey::Pubkey,
};
use std::net::Ipv4Addr;

/// Provision the feed_keys (SKU seats) onto an EdgeSeat access pass. The provisioning actor is the
/// oracle, authorized via its `ACCESS_PASS_ADMIN` Permission — not the deprecated `feed_authority`
/// slot. `current_users` is preserved for feeds already present on the pass; caps come from the
/// caller (seeded from the coupon).
#[derive(BorshSerialize, BorshDeserializeIncremental, PartialEq, Clone)]
pub struct SetAccessPassFeedsArgs {
#[incremental(default = Ipv4Addr::UNSPECIFIED)]
pub client_ip: Ipv4Addr,
pub user_payer: Pubkey,
pub feeds: Vec<FeedSeat>,
}

impl fmt::Debug for SetAccessPassFeedsArgs {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"client_ip: {}, user_payer: {}, feeds: {}",
self.client_ip,
self.user_payer,
self.feeds.len()
)
}
}

pub fn process_set_access_pass_feeds(
program_id: &Pubkey,
accounts: &[AccountInfo],
value: &SetAccessPassFeedsArgs,
) -> ProgramResult {
let accounts_iter = &mut accounts.iter();

let accesspass_account = next_account_info(accounts_iter)?;
let globalstate_account = next_account_info(accounts_iter)?;
let payer_account = next_account_info(accounts_iter)?;
let _system_program = next_account_info(accounts_iter)?;

// One Feed account per requested seat, in the same order as `value.feeds`.
let mut feed_accounts = Vec::with_capacity(value.feeds.len());
for _ in 0..value.feeds.len() {
feed_accounts.push(next_account_info(accounts_iter)?);
}

assert!(payer_account.is_signer, "Payer must be a signer");
assert_eq!(
accesspass_account.owner, program_id,
"Invalid AccessPass Account Owner"
);
assert_eq!(
globalstate_account.owner, program_id,
"Invalid GlobalState Account Owner"
);
assert!(
accesspass_account.is_writable,
"AccessPass Account is not writable"
);

let (expected_pda, _) = get_accesspass_pda(program_id, &value.client_ip, &value.user_payer);
assert_eq!(
accesspass_account.key, &expected_pda,
"Invalid AccessPass PubKey"
);

// Provisioning actor authorized via ACCESS_PASS_ADMIN (Permission PDA or legacy fallback).
let globalstate = GlobalState::try_from(globalstate_account)?;
authorize(
program_id,
accounts_iter,
payer_account.key,
&globalstate,
permission_flags::ACCESS_PASS_ADMIN,
)?;

let mut accesspass = AccessPass::try_from(accesspass_account)?;

// Only EdgeSeat passes carry feed seats. Require the pass to already be EdgeSeat (the oracle
// sets the type via SetAccessPass first) so this instruction can't silently convert a pass of
// another type (e.g. SolanaValidator) into an EdgeSeat.
if !matches!(accesspass.accesspass_type, AccessPassType::EdgeSeat(_)) {
msg!("SetAccessPassFeeds requires an EdgeSeat access pass");
return Err(DoubleZeroError::InvalidArgument.into());
}
let prior_seats = accesspass.feed_seats().to_vec();

// Validate each referenced Feed, preserve live counts, and bump reference_count for
// newly-referenced feeds. NOTE: feeds dropped from the pass are intentionally NOT decremented
// here — their accounts are not passed, and an over-count only makes a feed harder to delete
// (never unsafe), since reference_count solely guards DeleteFeed.
let mut new_seats = Vec::with_capacity(value.feeds.len());
for (seat, feed_account) in value.feeds.iter().zip(feed_accounts.iter()) {
assert_eq!(
*feed_account.key, seat.feed_key,
"Feed account does not match seat feed_key"
);
assert_eq!(feed_account.owner, program_id, "Invalid Feed Account Owner");
let mut feed = Feed::try_from(*feed_account)?;

let current_users = prior_seats
.iter()
.find(|s| s.feed_key == seat.feed_key)
.map(|s| s.current_users)
.unwrap_or(0);

if !prior_seats.iter().any(|s| s.feed_key == seat.feed_key) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Duplicate feed_key double-counts. prior_seats is snapshotted once before the loop (line 105) and never updated, so if value.feeds lists the same feed_key twice (and it wasn't already on the pass) this guard passes on both iterations: reference_count is bumped twice — the second Feed::try_from re-reads the just-written +1 — and new_seats ends up with two identical FeedSeat entries. Nothing dedups value.feeds. Since reference_count is never decremented, the over-count isn't reclaimable either. Recommend rejecting or de-duplicating repeated feed_keys (track seen keys in-loop, or revert on a dup) so a feed is bumped at most once and seats aren't duplicated.

assert!(feed_account.is_writable, "Feed Account is not writable");
feed.reference_count = feed
.reference_count
.checked_add(1)
.ok_or(DoubleZeroError::InvalidIndex)?;
try_acc_write(&feed, feed_account, payer_account, accounts)?;
}

new_seats.push(FeedSeat {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

max_users can be set below the preserved current_users. current_users is preserved from the prior seat while max_users comes from the caller, with no max_users >= current_users check — so a re-provision can leave a seat over its cap. Latent today (nothing ticks current_users until Part 3), but Part 3's connect-time enforcement will inherit this unchecked invariant. Worth a clamp/guard when that lands — flagging here so it isn't lost.

feed_key: seat.feed_key,
max_users: seat.max_users,
current_users,
});
}

accesspass.accesspass_type = AccessPassType::EdgeSeat(new_seats);
try_acc_write(&accesspass, accesspass_account, payer_account, accounts)?;

msg!("Set {} feed(s) on access pass", value.feeds.len());

Ok(())
}
Loading
Loading