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

- RFCs
- RFC-27: IP Ownership Verification Service for user connection
- Serviceability
- `GlobalState` carries `ip_verifier_authority_pk`, the RFC-27 trust root for IP ownership proof validation, which `SetAuthority` and `doublezero global-config authority set --ip-verifier-authority <pubkey|me>` rotate without a program upgrade. (#4196)
- Utility crates
- New `doublezero-ip-proof` crate defines the RFC-27 `IpOwnershipProof` and the exact bytes the verifier signs, in one place the serviceability program, the CLI, and the verification service all share. Nothing consumes it yet. (#4195, #4206)

Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ fn generate_global_state(dir: &Path) {
let health_oracle_pk = pubkey_from_byte(0x04);
let qa_pk = pubkey_from_byte(0x05);
let feed_authority_pk = pubkey_from_byte(0x06);
let ip_verifier_authority_pk = pubkey_from_byte(0x07);

let val = GlobalState {
account_type: AccountType::GlobalState,
Expand All @@ -395,6 +396,7 @@ fn generate_global_state(dir: &Path) {
qa_allowlist: vec![qa_pk],
feature_flags: 1,
feed_authority_pk: feed_authority_pk,
ip_verifier_authority_pk,
};

let data = borsh::to_vec(&val).unwrap();
Expand All @@ -419,6 +421,7 @@ fn generate_global_state(dir: &Path) {
FieldValue { name: "QaAllowlist0".into(), value: pubkey_bs58(&qa_pk), typ: "pubkey".into() },
FieldValue { name: "FeatureFlags".into(), value: "1".into(), typ: "u128".into() },
FieldValue { name: "FeedAuthorityPk".into(), value: pubkey_bs58(&feed_authority_pk), typ: "pubkey".into() },
FieldValue { name: "IpVerifierAuthorityPk".into(), value: pubkey_bs58(&ip_verifier_authority_pk), typ: "pubkey".into() },
],
};

Expand Down
Binary file modified sdk/serviceability/testdata/fixtures/global_state.bin
Binary file not shown.
5 changes: 5 additions & 0 deletions sdk/serviceability/testdata/fixtures/global_state.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@
"name": "FeedAuthorityPk",
"value": "QRSsyMWN1yHT9ir42bgNZUNZ4PdEhcSWCrL2AryKpy5",
"typ": "pubkey"
},
{
"name": "IpVerifierAuthorityPk",

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.

This golden now advertises IpVerifierAuthorityPk, but nothing validates it:

  • Go's GlobalState struct (smartcontract/sdk/go/serviceability/state.go:74-88) stops at FeedAuthorityPK, and fixture_test.go has no GlobalState case at all.
  • The TS harness skips unknown names (fixtures.test.ts: if (!(f.name in got)) continue;) and Python does the same (test_fixtures.py: if name not in got: continue).

So these bytes can drift or regress undetected. I know the SDK deserializer work is tracked separately — the point here is just that the golden currently has zero coverage for the field it advertises, which is a slightly worse state than not adding it yet. Adding IpVerifierAuthorityPK to the Go struct plus a GlobalState fixture case would close it.

"value": "UKrXU5bFrTzrqqpZXs8GVDbp4xPweiM65ADXNAy3ddR",
"typ": "pubkey"
}
]
}
1 change: 1 addition & 0 deletions smartcontract/cli/src/globalconfig/airdrop/get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ mod tests {
qa_allowlist: vec![],
feature_flags: 0,
feed_authority_pk: Pubkey::default(),
ip_verifier_authority_pk: Pubkey::default(),
};

client
Expand Down
13 changes: 13 additions & 0 deletions smartcontract/cli/src/globalconfig/authority/get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ pub struct AuthorityDisplay {
pub feed_authority: Pubkey,
#[serde(serialize_with = "serializer::serialize_pubkey_as_string")]
pub health_oracle: Pubkey,
#[serde(serialize_with = "serializer::serialize_pubkey_as_string")]
pub ip_verifier_authority: Pubkey,
}

impl GetAuthorityCliCommand {
Expand All @@ -41,6 +43,7 @@ impl GetAuthorityCliCommand {
access_authority: gstate.sentinel_authority_pk,
feed_authority: gstate.feed_authority_pk,
health_oracle: gstate.health_oracle_pk,
ip_verifier_authority: gstate.ip_verifier_authority_pk,

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.

An unset verifier renders as the literal 11111111111111111111111111111111, which is indistinguishable from a deliberately configured key.

Concrete case: right after the program upgrade but before the first rotation, an operator runs doublezero global-config authority get to confirm "is the verifier set?", sees a valid-looking base58 pubkey, and concludes yes.

Rendering Pubkey::default() as not set (and null under --json) would match the sentinel the field documents. Only this field needs it — the other four have no documented sentinel.

};

if self.json {
Expand Down Expand Up @@ -79,6 +82,7 @@ mod tests {
let sentinel_authority = Pubkey::new_unique();
let feed_authority = Pubkey::new_unique();
let health_oracle = Pubkey::new_unique();
let ip_verifier_authority = Pubkey::new_unique();
let globalstate = GlobalState {
account_type: AccountType::GlobalState,
bump_seed: 0,
Expand All @@ -94,6 +98,7 @@ mod tests {
qa_allowlist: vec![],
feature_flags: 0,
feed_authority_pk: feed_authority,
ip_verifier_authority_pk: ip_verifier_authority,
};

client
Expand Down Expand Up @@ -129,6 +134,10 @@ mod tests {
has_row("health_oracle", &health_oracle.to_string()),
"health_oracle row should contain value"
);
assert!(
has_row("ip_verifier_authority", &ip_verifier_authority.to_string()),
"ip_verifier_authority row should contain value"
);

// JSON output
let mut output = Vec::new();
Expand All @@ -153,5 +162,9 @@ mod tests {
json["health_oracle"].as_str().unwrap(),
health_oracle.to_string()
);
assert_eq!(
json["ip_verifier_authority"].as_str().unwrap(),
ip_verifier_authority.to_string()
);
}
}
20 changes: 20 additions & 0 deletions smartcontract/cli/src/globalconfig/authority/set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ pub struct SetAuthorityCliCommand {
/// New feed authority public key
#[arg(long)]
pub feed_authority: Option<String>,

/// New IP ownership verifier authority public key (RFC-27)
#[arg(long)]
pub ip_verifier_authority: Option<String>,
}

impl SetAuthorityCliCommand {
Expand Down Expand Up @@ -82,11 +86,24 @@ impl SetAuthorityCliCommand {
}
};

let ip_verifier_authority_pk = {
if let Some(ip_verifier_authority) = &self.ip_verifier_authority {
if ip_verifier_authority.eq_ignore_ascii_case("me") {
Some(client.get_payer())
} else {
Some(Pubkey::from_str(ip_verifier_authority)?)
}
} else {
None
}
};

let signature = client.set_authority(SetAuthorityCommand {
activator_authority_pk,
sentinel_authority_pk,
health_oracle_pk,
feed_authority_pk,
ip_verifier_authority_pk,
})?;
writeln!(out, "Signature: {signature}",)?;

Expand Down Expand Up @@ -122,6 +139,7 @@ mod tests {
let sentinel_authority_pk = Pubkey::new_unique();
let health_oracle_pk = Pubkey::new_unique();
let feed_authority_pk = Pubkey::new_unique();
let ip_verifier_authority_pk = Pubkey::new_unique();

client
.expect_check_requirements()
Expand All @@ -134,6 +152,7 @@ mod tests {
sentinel_authority_pk: Some(sentinel_authority_pk),
health_oracle_pk: Some(health_oracle_pk),
feed_authority_pk: Some(feed_authority_pk),
ip_verifier_authority_pk: Some(ip_verifier_authority_pk),
}))
.returning(move |_| Ok(signature));

Expand All @@ -147,6 +166,7 @@ mod tests {
sentinel_authority: Some(sentinel_authority_pk.to_string()),
health_oracle: Some(health_oracle_pk.to_string()),
feed_authority: Some(feed_authority_pk.to_string()),
ip_verifier_authority: Some(ip_verifier_authority_pk.to_string()),
}
.execute(&ctx, &client, &mut output1),
);
Expand Down
3 changes: 3 additions & 0 deletions smartcontract/cli/src/globalconfig/featureflags/get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ mod tests {
qa_allowlist: vec![],
feature_flags: 1,
feed_authority_pk: Pubkey::default(),
ip_verifier_authority_pk: Pubkey::default(),
};

client
Expand Down Expand Up @@ -114,6 +115,7 @@ mod tests {
qa_allowlist: vec![],
feature_flags: 0,
feed_authority_pk: Pubkey::default(),
ip_verifier_authority_pk: Pubkey::default(),
};

client
Expand Down Expand Up @@ -151,6 +153,7 @@ mod tests {
qa_allowlist: vec![],
feature_flags: 1,
feed_authority_pk: Pubkey::default(),
ip_verifier_authority_pk: Pubkey::default(),
};

client
Expand Down
1 change: 1 addition & 0 deletions smartcontract/cli/src/globalconfig/featureflags/set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ mod tests {
qa_allowlist: vec![],
feature_flags,
feed_authority_pk: Pubkey::default(),
ip_verifier_authority_pk: Pubkey::default(),
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ pub fn create_mock_globalstate_account_shared(
qa_allowlist: vec![],
feature_flags: 0,
feed_authority_pk: Pubkey::new_unique(),
ip_verifier_authority_pk: Pubkey::new_unique(),
};

let data = borsh::to_vec(&globalstate).unwrap();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,7 @@ mod tests {
fn gs_with_feed(authority: &Pubkey) -> GlobalState {
GlobalState {
feed_authority_pk: *authority,
ip_verifier_authority_pk: Pubkey::default(),

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.

Nit: redundant next to ..GlobalState::default() on the following line, which already produces Pubkey::default() for this field. Same at line 527.

..GlobalState::default()
}
}
Expand All @@ -523,6 +524,7 @@ mod tests {
sentinel_authority_pk: sentinel,
health_oracle_pk: health_oracle,
feed_authority_pk: feed,
ip_verifier_authority_pk: Pubkey::default(),
..GlobalState::default()
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ pub fn initialize_global_state(program_id: &Pubkey, accounts: &[AccountInfo]) ->
qa_allowlist: vec![*payer_account.key],
feature_flags: 0,
feed_authority_pk: Pubkey::default(),
ip_verifier_authority_pk: Pubkey::default(),
};

try_acc_create(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,19 @@ pub struct SetAuthorityArgs {
pub sentinel_authority_pk: Option<Pubkey>,
pub health_oracle_pk: Option<Pubkey>,
pub feed_authority_pk: Option<Pubkey>,
pub ip_verifier_authority_pk: Option<Pubkey>,
}

impl fmt::Debug for SetAuthorityArgs {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"activator_authority_pk: {:?}, sentinel_authority_pk: {:?}, health_oracle_pk: {:?}, feed_authority_pk: {:?}",
self.activator_authority_pk, self.sentinel_authority_pk, self.health_oracle_pk, self.feed_authority_pk
"activator_authority_pk: {:?}, sentinel_authority_pk: {:?}, health_oracle_pk: {:?}, feed_authority_pk: {:?}, ip_verifier_authority_pk: {:?}",
self.activator_authority_pk,
self.sentinel_authority_pk,
self.health_oracle_pk,
self.feed_authority_pk,
self.ip_verifier_authority_pk
)
}
}
Expand Down Expand Up @@ -95,6 +100,9 @@ pub fn process_set_authority(
if let Some(feed_authority_pk) = value.feed_authority_pk {
globalstate.feed_authority_pk = feed_authority_pk;
}
if let Some(ip_verifier_authority_pk) = value.ip_verifier_authority_pk {
globalstate.ip_verifier_authority_pk = ip_verifier_authority_pk;
}
Comment on lines +103 to +105

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.

Some(Pubkey::default()) is accepted here and written straight through, silently clearing the trust root.

The field's own doc comment defines Pubkey::default() as the "no verifier configured, treat as hard reject" sentinel — so an operator who templates an empty or zero pubkey into --ip-verifier-authority disables every IP-verified user connection with no error and no distinguishable onchain signal. Pubkey::from_str("11111111111111111111111111111111") parses cleanly, so the CLI won't catch it either.

Non-blocking, but the guard is cheap and matches the semantics the field already documents:

if let Some(pk) = value.ip_verifier_authority_pk {
    if pk == Pubkey::default() {
        return Err(DoubleZeroError::InvalidAccountData.into()); // or the closest existing variant
    }
    globalstate.ip_verifier_authority_pk = pk;
}

I'd only guard the new field — retrofitting the other four is a behavior change that doesn't belong in this PR.


try_acc_write(&globalstate, globalstate_account, payer_account, accounts)?;

Expand All @@ -103,3 +111,37 @@ pub fn process_set_authority(

Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

/// Transactions built before `ip_verifier_authority_pk` existed encode only the
/// first four options. `BorshDeserializeIncremental` must still decode them,
/// leaving the new field as `None` (i.e. "leave it alone").
#[test]
fn test_setauthority_args_decodes_pre_ip_verifier_encoding() {
let feed = Pubkey::new_unique();

let mut data = Vec::new();
None::<Pubkey>.serialize(&mut data).unwrap();
None::<Pubkey>.serialize(&mut data).unwrap();
None::<Pubkey>.serialize(&mut data).unwrap();
Some(feed).serialize(&mut data).unwrap();

let args = SetAuthorityArgs::try_from(&data[..]).unwrap();
assert_eq!(args.feed_authority_pk, Some(feed));
assert_eq!(args.ip_verifier_authority_pk, None);
}

#[test]
fn test_setauthority_args_roundtrips_ip_verifier() {
let args = SetAuthorityArgs {
ip_verifier_authority_pk: Some(Pubkey::new_unique()),
..Default::default()
};

let data = borsh::to_vec(&args).unwrap();
assert_eq!(SetAuthorityArgs::try_from(&data[..]).unwrap(), args);
}
}
Loading
Loading