Skip to content

Commit f95f302

Browse files
smartcontract: add Index account for multicast group code uniqueness
Introduce an Index account (PDA derived from entity type + lowercased code) that enforces unique multicast group codes onchain and enables O(1) code-to-pubkey lookup. Integrate Index lifecycle into multicast group create/update/delete/close instructions. Add standalone CreateIndex/DeleteIndex instructions (variants 104/105) for migration backfill. Update Go, Python, and TypeScript SDKs with Index account type.
1 parent e149ac0 commit f95f302

31 files changed

Lines changed: 1304 additions & 81 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ All notable changes to this project will be documented in this file.
1111
- CLI
1212
- Allow incremental multicast group addition without disconnecting
1313
- Reset SIGPIPE to SIG_DFL at the start of main() in all 3 CLI binaries (doublezero, doublezero-geolocation, doublezero-admin) so the process exits silently like standard CLI tools
14+
- Smartcontract
15+
- Add Index account for multicast group code uniqueness — PDA derived from entity type + lowercased code enforces unique codes onchain and enables O(1) code-to-pubkey lookup
1416
- SDK
1517
- Add Go SDK for shred subscription program with read-only account deserialization (epoch state, seat assignments, pricing, settlement, validator client rewards), PDA derivation helpers, RPC fetchers, compatibility tests, and a fetch example CLI
1618
- Tools

activator/src/process/multicastgroup.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,13 +398,22 @@ mod tests {
398398
// Insert it first so it can be removed
399399
multicastgroups.insert(pubkey, multicastgroup.clone());
400400

401-
// Stateless mode: use_onchain_deallocation=true
401+
// Mock get() for DeactivateMulticastGroupCommand which fetches the
402+
// multicast group to derive the Index PDA
403+
let mgroup_for_get = multicastgroup.clone();
404+
client
405+
.expect_get()
406+
.with(predicate::eq(pubkey))
407+
.returning(move |_| Ok(AccountData::MulticastGroup(mgroup_for_get.clone())));
408+
409+
// Stateless mode: use_onchain_deallocation=true, close_index=true
402410
client
403411
.expect_execute_transaction()
404412
.with(
405413
predicate::eq(DoubleZeroInstruction::DeactivateMulticastGroup(
406414
MulticastGroupDeactivateArgs {
407415
use_onchain_deallocation: true,
416+
close_index: true,
408417
},
409418
)),
410419
predicate::always(),

e2e/internal/qa/provisioning.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -600,4 +600,3 @@ func formatBandwidth(bps uint64) string {
600600
}
601601
return fmt.Sprintf("%d bps", bps)
602602
}
603-

sdk/serviceability/go/state.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const (
2525
ResourceExtensionType AccountType = 12
2626
TenantType AccountType = 13
2727
PermissionType AccountType = 15
28+
IndexType AccountType = 16
2829
)
2930

3031
type LocationStatus uint8

sdk/serviceability/python/serviceability/state.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ class AccountTypeEnum(IntEnum):
4242
ACCESS_PASS = 11
4343
TENANT = 13
4444
PERMISSION = 15
45+
INDEX = 16
4546

4647

4748
# ---------------------------------------------------------------------------

sdk/serviceability/typescript/serviceability/state.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export const ACCOUNT_TYPE_CONTRIBUTOR = 10;
3232
export const ACCOUNT_TYPE_ACCESS_PASS = 11;
3333
export const ACCOUNT_TYPE_TENANT = 13;
3434
export const ACCOUNT_TYPE_PERMISSION = 15;
35+
export const ACCOUNT_TYPE_INDEX = 16;
3536

3637
// ---------------------------------------------------------------------------
3738
// Enum string mappings

smartcontract/programs/doublezero-serviceability/src/entrypoint.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ use crate::{
4747
setauthority::process_set_authority, setfeatureflags::process_set_feature_flags,
4848
setversion::process_set_version,
4949
},
50+
index::{create::process_create_index, delete::process_delete_index},
5051
link::{
5152
accept::process_accept_link, activate::process_activate_link,
5253
closeaccount::process_closeaccount_link, create::process_create_link,
@@ -421,6 +422,12 @@ pub fn process_instruction(
421422
DoubleZeroInstruction::DeletePermission(value) => {
422423
process_delete_permission(program_id, accounts, &value)?
423424
}
425+
DoubleZeroInstruction::CreateIndex(value) => {
426+
process_create_index(program_id, accounts, &value)?
427+
}
428+
DoubleZeroInstruction::DeleteIndex(value) => {
429+
process_delete_index(program_id, accounts, &value)?
430+
}
424431
};
425432
Ok(())
426433
}

smartcontract/programs/doublezero-serviceability/src/instructions.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ use crate::processors::{
3535
setairdrop::SetAirdropArgs, setauthority::SetAuthorityArgs,
3636
setfeatureflags::SetFeatureFlagsArgs, setversion::SetVersionArgs,
3737
},
38+
index::{create::IndexCreateArgs, delete::IndexDeleteArgs},
3839
link::{
3940
accept::LinkAcceptArgs, activate::LinkActivateArgs, closeaccount::LinkCloseAccountArgs,
4041
create::LinkCreateArgs, delete::LinkDeleteArgs, reject::LinkRejectArgs,
@@ -218,6 +219,9 @@ pub enum DoubleZeroInstruction {
218219

219220
Deprecated102(), // variant 102 (was CreateReservedSubscribeUser)
220221
Deprecated103(), // variant 103 (was DeleteReservedSubscribeUser)
222+
223+
CreateIndex(IndexCreateArgs), // variant 104
224+
DeleteIndex(IndexDeleteArgs), // variant 105
221225
}
222226

223227
impl DoubleZeroInstruction {
@@ -350,6 +354,9 @@ impl DoubleZeroInstruction {
350354
101 => Ok(Self::DeletePermission(PermissionDeleteArgs::try_from(rest).unwrap())),
351355

352356

357+
104 => Ok(Self::CreateIndex(IndexCreateArgs::try_from(rest).unwrap())),
358+
105 => Ok(Self::DeleteIndex(IndexDeleteArgs::try_from(rest).unwrap())),
359+
353360
_ => Err(ProgramError::InvalidInstructionData),
354361
}
355362
}
@@ -483,6 +490,9 @@ impl DoubleZeroInstruction {
483490

484491
Self::Deprecated102() => "Deprecated102".to_string(),
485492
Self::Deprecated103() => "Deprecated103".to_string(),
493+
494+
Self::CreateIndex(_) => "CreateIndex".to_string(), // variant 104
495+
Self::DeleteIndex(_) => "DeleteIndex".to_string(), // variant 105
486496
}
487497
}
488498

@@ -609,6 +619,9 @@ impl DoubleZeroInstruction {
609619

610620
Self::Deprecated102() => String::new(),
611621
Self::Deprecated103() => String::new(),
622+
623+
Self::CreateIndex(args) => format!("{args:?}"), // variant 104
624+
Self::DeleteIndex(args) => format!("{args:?}"), // variant 105
612625
}
613626
}
614627
}
@@ -975,6 +988,7 @@ mod tests {
975988
publisher_count: None,
976989
subscriber_count: None,
977990
use_onchain_allocation: false,
991+
rename_index: false,
978992
}),
979993
"UpdateMulticastGroup",
980994
);
@@ -992,13 +1006,15 @@ mod tests {
9921006
test_instruction(
9931007
DoubleZeroInstruction::DeleteMulticastGroup(MulticastGroupDeleteArgs {
9941008
use_onchain_deallocation: false,
1009+
close_index: false,
9951010
}),
9961011
"DeleteMulticastGroup",
9971012
);
9981013

9991014
test_instruction(
10001015
DoubleZeroInstruction::DeactivateMulticastGroup(MulticastGroupDeactivateArgs {
10011016
use_onchain_deallocation: false,
1017+
close_index: false,
10021018
}),
10031019
"DeactivateMulticastGroup",
10041020
);

smartcontract/programs/doublezero-serviceability/src/pda.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ use solana_program::pubkey::Pubkey;
55
use crate::{
66
seeds::{
77
SEED_ACCESS_PASS, SEED_CONFIG, SEED_CONTRIBUTOR, SEED_DEVICE, SEED_DEVICE_TUNNEL_BLOCK,
8-
SEED_DZ_PREFIX_BLOCK, SEED_EXCHANGE, SEED_GLOBALSTATE, SEED_LINK, SEED_LINK_IDS,
9-
SEED_LOCATION, SEED_MULTICASTGROUP_BLOCK, SEED_MULTICAST_GROUP,
8+
SEED_DZ_PREFIX_BLOCK, SEED_EXCHANGE, SEED_GLOBALSTATE, SEED_INDEX, SEED_LINK,
9+
SEED_LINK_IDS, SEED_LOCATION, SEED_MULTICASTGROUP_BLOCK, SEED_MULTICAST_GROUP,
1010
SEED_MULTICAST_PUBLISHER_BLOCK, SEED_PERMISSION, SEED_PREFIX, SEED_PROGRAM_CONFIG,
1111
SEED_SEGMENT_ROUTING_IDS, SEED_TENANT, SEED_TUNNEL_IDS, SEED_USER, SEED_USER_TUNNEL_BLOCK,
1212
SEED_VRF_IDS,
@@ -103,6 +103,19 @@ pub fn get_accesspass_pda(
103103
)
104104
}
105105

106+
pub fn get_index_pda(program_id: &Pubkey, entity_seed: &[u8], code: &str) -> (Pubkey, u8) {
107+
let lowercase_code = code.to_ascii_lowercase();
108+
Pubkey::find_program_address(
109+
&[
110+
SEED_PREFIX,
111+
SEED_INDEX,
112+
entity_seed,
113+
lowercase_code.as_bytes(),
114+
],
115+
program_id,
116+
)
117+
}
118+
106119
pub fn get_resource_extension_pda(
107120
program_id: &Pubkey,
108121
resource_type: crate::resource::ResourceType,
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
use crate::{
2+
error::DoubleZeroError,
3+
pda::get_index_pda,
4+
seeds::{SEED_INDEX, SEED_PREFIX},
5+
serializer::try_acc_create,
6+
state::{accounttype::AccountType, globalstate::GlobalState, index::Index},
7+
};
8+
use borsh::BorshSerialize;
9+
use borsh_incremental::BorshDeserializeIncremental;
10+
use doublezero_program_common::validate_account_code;
11+
use solana_program::{
12+
account_info::{next_account_info, AccountInfo},
13+
entrypoint::ProgramResult,
14+
program_error::ProgramError,
15+
pubkey::Pubkey,
16+
};
17+
use std::fmt;
18+
19+
#[cfg(test)]
20+
use solana_program::msg;
21+
22+
#[derive(BorshSerialize, BorshDeserializeIncremental, PartialEq, Clone, Default)]
23+
pub struct IndexCreateArgs {
24+
pub entity_seed: String,
25+
pub code: String,
26+
}
27+
28+
impl fmt::Debug for IndexCreateArgs {
29+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30+
write!(f, "entity_seed: {}, code: {}", self.entity_seed, self.code)
31+
}
32+
}
33+
34+
pub fn process_create_index(
35+
program_id: &Pubkey,
36+
accounts: &[AccountInfo],
37+
value: &IndexCreateArgs,
38+
) -> ProgramResult {
39+
let accounts_iter = &mut accounts.iter();
40+
41+
let index_account = next_account_info(accounts_iter)?;
42+
let entity_account = next_account_info(accounts_iter)?;
43+
let globalstate_account = next_account_info(accounts_iter)?;
44+
let payer_account = next_account_info(accounts_iter)?;
45+
let system_program = next_account_info(accounts_iter)?;
46+
47+
#[cfg(test)]
48+
msg!("process_create_index({:?})", value);
49+
50+
assert!(payer_account.is_signer, "Payer must be a signer");
51+
52+
// Validate accounts
53+
assert_eq!(
54+
globalstate_account.owner, program_id,
55+
"Invalid GlobalState Account Owner"
56+
);
57+
assert_eq!(
58+
entity_account.owner, program_id,
59+
"Invalid Entity Account Owner"
60+
);
61+
assert_eq!(
62+
*system_program.unsigned_key(),
63+
solana_system_interface::program::ID,
64+
"Invalid System Program Account Owner"
65+
);
66+
assert!(index_account.is_writable, "Index Account is not writable");
67+
68+
// Check foundation allowlist
69+
let globalstate = GlobalState::try_from(globalstate_account)?;
70+
if !globalstate.foundation_allowlist.contains(payer_account.key) {
71+
return Err(DoubleZeroError::NotAllowed.into());
72+
}
73+
74+
// Validate and normalize code
75+
let code =
76+
validate_account_code(&value.code).map_err(|_| DoubleZeroError::InvalidAccountCode)?;
77+
let lowercase_code = code.to_ascii_lowercase();
78+
79+
// Derive and verify the Index PDA
80+
let (expected_pda, bump_seed) = get_index_pda(program_id, value.entity_seed.as_bytes(), &code);
81+
assert_eq!(index_account.key, &expected_pda, "Invalid Index Pubkey");
82+
83+
// Uniqueness: account must not already exist
84+
if !index_account.data_is_empty() {
85+
return Err(ProgramError::AccountAlreadyInitialized);
86+
}
87+
88+
// Verify the entity account is a valid, non-Index program account
89+
assert!(!entity_account.data_is_empty(), "Entity Account is empty");
90+
let entity_type = AccountType::from(entity_account.try_borrow_data()?[0]);
91+
assert!(
92+
entity_type != AccountType::None && entity_type != AccountType::Index,
93+
"Entity Account has invalid type for indexing: {entity_type}"
94+
);
95+
96+
let index = Index {
97+
account_type: AccountType::Index,
98+
pk: *entity_account.key,
99+
bump_seed,
100+
};
101+
102+
try_acc_create(
103+
&index,
104+
index_account,
105+
payer_account,
106+
system_program,
107+
program_id,
108+
&[
109+
SEED_PREFIX,
110+
SEED_INDEX,
111+
value.entity_seed.as_bytes(),
112+
lowercase_code.as_bytes(),
113+
&[bump_seed],
114+
],
115+
)?;
116+
117+
Ok(())
118+
}

0 commit comments

Comments
 (0)