From 50471c33d06ba7f55e605ed7dbba02119af5e3cd Mon Sep 17 00:00:00 2001 From: Noah Prince <83885631+ChewingGlass@users.noreply.github.com> Date: Tue, 19 Dec 2023 14:31:51 -0600 Subject: [PATCH] feat(#494): Implement HIP-96 wifi onboarding fees (#506) --- Anchor.toml | 3 ++ Cargo.lock | 1 + Cargo.toml | 1 + .../helium-admin-cli/src/create-subdao.ts | 13 +++-- .../src/update-rewardable-entity-config.ts | 20 +++++--- .../src/resolvers.ts | 13 ++++- programs/helium-entity-manager/Cargo.toml | 3 +- programs/helium-entity-manager/src/error.rs | 2 + .../instructions/onboard_mobile_hotspot_v0.rs | 37 +++++++++++++- programs/helium-entity-manager/src/state.rs | 50 ++++++++++++++++--- tests/helium-entity-manager.ts | 49 +++++++++++------- 11 files changed, 153 insertions(+), 39 deletions(-) diff --git a/Anchor.toml b/Anchor.toml index 0ce5b22a8..ca4e4faf6 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -74,6 +74,9 @@ address = "propFYxqmVcufMhk5esNMrexq2ogHbbC2kP9PU1qxKs" # Proposal [[test.validator.clone]] address = "66t3XARU6Ja3zj91gDZ2KoNLJHEMTYPSKqJWYb6PJJBA" # Proposal IDL +[[test.validator.clone]] +address = "moraMdsjyPFz8Lp1RJGoW4bQriSF5mHE7Evxt7hytSF" # Mobile price oracle + # Pyth price oracle [[test.validator.clone]] address = "7moA1i5vQUpfDwSpK6Pw9s56ahB7WFGidtbL2ujWrVvm" diff --git a/Cargo.lock b/Cargo.lock index 04ad7b2e7..a8ab57656 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1951,6 +1951,7 @@ dependencies = [ "helium-sub-daos", "mpl-token-metadata", "no-emit", + "price-oracle", "shared-utils", "solana-security-txt", ] diff --git a/Cargo.toml b/Cargo.toml index a60e0dd80..20de67a75 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,4 +28,5 @@ shared-utils = { path = "./utils/shared-utils" } circuit-breaker = { path = "./programs/circuit-breaker", features = ["cpi"] } helium-sub-daos = { path = "./programs/helium-sub-daos", features = ["cpi"] } helium-entity-manager = { path = "./programs/helium-entity-manager", features = ["cpi"] } +price-oracle = { path = "./programs/price-oracle", features = ["cpi"] } no-emit = { path = "./programs/no-emit", features = ["cpi"] } diff --git a/packages/helium-admin-cli/src/create-subdao.ts b/packages/helium-admin-cli/src/create-subdao.ts index 69012927c..72edca17f 100644 --- a/packages/helium-admin-cli/src/create-subdao.ts +++ b/packages/helium-admin-cli/src/create-subdao.ts @@ -51,6 +51,7 @@ import { parseEmissionsSchedule, sendInstructionsOrSquads, } from './utils'; +import { BN } from 'bn.js'; const SECS_PER_DAY = 86400; const SECS_PER_YEAR = 365 * SECS_PER_DAY; @@ -539,22 +540,28 @@ export async function run(args: any = process.argv) { }; } else { settings = { - mobileConfigV1: { + mobileConfigV2: { feesByDevice: [ { deviceType: { cbrs: {} }, dcOnboardingFee: toBN(40, 5), locationStakingFee: toBN(10, 5), + mobileOnboardingFeeUsd: toBN(0, 6), + reserved: new Array(8).fill(new BN(0)), }, { deviceType: { wifiIndoor: {} }, - dcOnboardingFee: toBN(0, 5), + dcOnboardingFee: toBN(10, 5), locationStakingFee: toBN(0, 5), + mobileOnboardingFeeUsd: toBN(10, 6), + reserved: new Array(8).fill(new BN(0)), }, { deviceType: { wifiOutdoor: {} }, - dcOnboardingFee: toBN(0, 5), + dcOnboardingFee: toBN(10, 5), locationStakingFee: toBN(0, 5), + mobileOnboardingFeeUsd: toBN(20, 6), + reserved: new Array(8).fill(new BN(0)), }, ], }, diff --git a/packages/helium-admin-cli/src/update-rewardable-entity-config.ts b/packages/helium-admin-cli/src/update-rewardable-entity-config.ts index 8326f7669..ee7cbfa24 100644 --- a/packages/helium-admin-cli/src/update-rewardable-entity-config.ts +++ b/packages/helium-admin-cli/src/update-rewardable-entity-config.ts @@ -116,22 +116,28 @@ export async function run(args: any = process.argv) { }; } else { settings = { - mobileConfigV1: { + mobileConfigV2: { feesByDevice: [ { deviceType: { cbrs: {} }, - dcOnboardingFee: new BN(argv.cbrsDcOnboardingFee!), - locationStakingFee: new BN(argv.cbrsDcLocationStakingFee!), + dcOnboardingFee: toBN(40, 5), + locationStakingFee: toBN(10, 5), + mobileOnboardingFeeUsd: toBN(0, 6), + reserved: new Array(8).fill(new BN(0)), }, { deviceType: { wifiIndoor: {} }, - dcOnboardingFee: new BN(argv.wifiDcOnboardingFee!), - locationStakingFee: new BN(argv.wifiDcLocationStakingFee!), + dcOnboardingFee: toBN(10, 5), + locationStakingFee: toBN(0, 5), + mobileOnboardingFeeUsd: toBN(10, 6), + reserved: new Array(8).fill(new BN(0)), }, { deviceType: { wifiOutdoor: {} }, - dcOnboardingFee: new BN(argv.wifiDcOnboardingFee!), - locationStakingFee: new BN(argv.wifiDcLocationStakingFee!), + dcOnboardingFee: toBN(10, 5), + locationStakingFee: toBN(0, 5), + mobileOnboardingFeeUsd: toBN(20, 6), + reserved: new Array(8).fill(new BN(0)), }, ], }, diff --git a/packages/helium-entity-manager-sdk/src/resolvers.ts b/packages/helium-entity-manager-sdk/src/resolvers.ts index ca542f1c6..196273b35 100644 --- a/packages/helium-entity-manager-sdk/src/resolvers.ts +++ b/packages/helium-entity-manager-sdk/src/resolvers.ts @@ -33,6 +33,11 @@ export const heliumEntityManagerResolvers = combineResolvers( mint: "dntMint", owner: "maker", }), + resolveIndividual(async ({ path }) => { + if (path[path.length - 1] == "dntPrice") { + return new PublicKey("moraMdsjyPFz8Lp1RJGoW4bQriSF5mHE7Evxt7hytSF"); + } + }), resolveIndividual(async ({ path, args, accounts, provider }) => { if (path[path.length - 1] == "programApproval" && accounts.dao) { let programId = args[args.length - 1] && args[args.length - 1].programId; @@ -167,7 +172,13 @@ export const heliumEntityManagerResolvers = combineResolvers( instruction: "issueNotEmittedEntityV0", mint: "mint", account: "recipientAccount", - owner: "recipient" + owner: "recipient", + }), + ataResolver({ + instruction: "onboardMobileHotspotV0", + mint: "dntMint", + account: "dntBurner", + owner: "payer", }), subDaoEpochInfoResolver ); diff --git a/programs/helium-entity-manager/Cargo.toml b/programs/helium-entity-manager/Cargo.toml index 758a4b138..365732f2d 100644 --- a/programs/helium-entity-manager/Cargo.toml +++ b/programs/helium-entity-manager/Cargo.toml @@ -34,4 +34,5 @@ data-credits = { path = "../data-credits", features = ["cpi"] } helium-sub-daos = { workspace = true } solana-security-txt = { workspace = true } default-env = { workspace = true } -no-emit = { workspace = true } \ No newline at end of file +price-oracle = { workspace = true } +no-emit = { workspace = true } diff --git a/programs/helium-entity-manager/src/error.rs b/programs/helium-entity-manager/src/error.rs index 048deed2a..9c57723f4 100644 --- a/programs/helium-entity-manager/src/error.rs +++ b/programs/helium-entity-manager/src/error.rs @@ -45,4 +45,6 @@ pub enum ErrorCode { InvalidSymbol, #[msg("Mobile device type not found")] InvalidDeviceType, + #[msg("No mobile oracle price")] + NoOraclePrice, } diff --git a/programs/helium-entity-manager/src/instructions/onboard_mobile_hotspot_v0.rs b/programs/helium-entity-manager/src/instructions/onboard_mobile_hotspot_v0.rs index 6693a5eac..e54057be3 100644 --- a/programs/helium-entity-manager/src/instructions/onboard_mobile_hotspot_v0.rs +++ b/programs/helium-entity-manager/src/instructions/onboard_mobile_hotspot_v0.rs @@ -1,10 +1,11 @@ use crate::error::ErrorCode; use crate::state::*; use anchor_lang::{prelude::*, solana_program::hash::hash}; +use std::str::FromStr; use anchor_spl::{ associated_token::AssociatedToken, - token::{Mint, Token}, + token::{burn, Burn, Mint, Token, TokenAccount}, }; use data_credits::{ cpi::{ @@ -22,6 +23,7 @@ use helium_sub_daos::{ use account_compression_cpi::program::SplAccountCompression; use bubblegum_cpi::get_asset_id; +use price_oracle::{calculate_current_price, PriceOracleV0}; use shared_utils::*; #[derive(AnchorSerialize, AnchorDeserialize, Clone)] @@ -61,6 +63,12 @@ pub struct OnboardMobileHotspotV0<'info> { /// CHECK: Only loaded if location is being asserted #[account(mut)] pub dc_burner: UncheckedAccount<'info>, + #[account( + mut, + associated_token::authority = payer, + associated_token::mint = dnt_mint + )] + pub dnt_burner: Account<'info, TokenAccount>, #[account( has_one = sub_dao, @@ -92,10 +100,17 @@ pub struct OnboardMobileHotspotV0<'info> { #[account( mut, has_one = dao, + has_one = dnt_mint, )] pub sub_dao: Box>, #[account(mut)] pub dc_mint: Box>, + #[account(mut)] + pub dnt_mint: Box>, + #[account( + address = Pubkey::from_str("moraMdsjyPFz8Lp1RJGoW4bQriSF5mHE7Evxt7hytSF").unwrap() + )] + pub dnt_price: Box>, #[account( seeds=[ @@ -132,6 +147,16 @@ impl<'info> OnboardMobileHotspotV0<'info> { CpiContext::new(self.data_credits_program.to_account_info(), cpi_accounts) } + + pub fn mobile_burn_ctx(&self) -> CpiContext<'_, '_, '_, 'info, Burn<'info>> { + let cpi_accounts = Burn { + mint: self.dnt_mint.to_account_info(), + from: self.dnt_burner.to_account_info(), + authority: self.payer.to_account_info(), + }; + + CpiContext::new(self.token_program.to_account_info(), cpi_accounts) + } } pub fn handler<'info>( @@ -214,5 +239,15 @@ pub fn handler<'info>( BurnWithoutTrackingArgsV0 { amount: dc_fee }, )?; + // Burn the mobile tokens + let dnt_fee = fees.mobile_onboarding_fee_usd; + let mobile_price = calculate_current_price( + &ctx.accounts.dnt_price.oracles, + Clock::get()?.unix_timestamp, + ) + .ok_or_else(|| error!(ErrorCode::NoOraclePrice))?; + let mobile_fee = dnt_fee.checked_div(mobile_price).unwrap(); + burn(ctx.accounts.mobile_burn_ctx(), mobile_fee)?; + Ok(()) } diff --git a/programs/helium-entity-manager/src/state.rs b/programs/helium-entity-manager/src/state.rs index 6eb5194a5..25b337130 100644 --- a/programs/helium-entity-manager/src/state.rs +++ b/programs/helium-entity-manager/src/state.rs @@ -27,6 +27,28 @@ pub struct DeviceFeesV0 { pub location_staking_fee: u64, } +impl From for DeviceFeesV1 { + fn from(value: DeviceFeesV0) -> Self { + DeviceFeesV1 { + device_type: value.device_type, + dc_onboarding_fee: value.dc_onboarding_fee, + location_staking_fee: value.location_staking_fee, + mobile_onboarding_fee_usd: 0, + reserved: [0_u64; 8], + } + } +} + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy)] +pub struct DeviceFeesV1 { + pub device_type: MobileDeviceTypeV0, + pub dc_onboarding_fee: u64, + pub location_staking_fee: u64, + // mobile onboarding fee in usd with 6 decimals of precision + pub mobile_onboarding_fee_usd: u64, + pub reserved: [u64; 8], +} + #[derive(AnchorSerialize, AnchorDeserialize, Clone)] #[allow(deprecated)] pub enum ConfigSettingsV0 { @@ -36,29 +58,40 @@ pub enum ConfigSettingsV0 { full_location_staking_fee: u64, dataonly_location_staking_fee: u64, }, - // Deprecated, use MobileConfigV1 + // Deprecated, use MobileConfigV2 MobileConfig { full_location_staking_fee: u64, dataonly_location_staking_fee: u64, }, + // Deprecated, use MobileConfigV2 MobileConfigV1 { fees_by_device: Vec, }, + MobileConfigV2 { + fees_by_device: Vec, + }, } impl ConfigSettingsV0 { #[allow(deprecated)] - pub fn mobile_device_fees(&self, device: MobileDeviceTypeV0) -> Option { + pub fn mobile_device_fees(&self, device: MobileDeviceTypeV0) -> Option { match self { ConfigSettingsV0::MobileConfig { full_location_staking_fee, .. - } => Some(DeviceFeesV0 { - device_type: MobileDeviceTypeV0::Cbrs, - dc_onboarding_fee: 4000000_u64, - location_staking_fee: *full_location_staking_fee, - }), + } => Some( + DeviceFeesV0 { + device_type: MobileDeviceTypeV0::Cbrs, + dc_onboarding_fee: 4000000_u64, + location_staking_fee: *full_location_staking_fee, + } + .into(), + ), ConfigSettingsV0::MobileConfigV1 { fees_by_device, .. } => fees_by_device + .iter() + .find(|d| d.device_type == device) + .map(|i| (*i).into()), + ConfigSettingsV0::MobileConfigV2 { fees_by_device, .. } => fees_by_device .iter() .find(|d| d.device_type == device) .copied(), @@ -79,7 +112,8 @@ impl ConfigSettingsV0 { } } pub fn is_mobile(&self) -> bool { - matches!(self, ConfigSettingsV0::MobileConfigV1 { .. }) + matches!(self, ConfigSettingsV0::MobileConfigV2 { .. }) + || matches!(self, ConfigSettingsV0::MobileConfigV1 { .. }) || matches!(self, ConfigSettingsV0::MobileConfig { .. }) } diff --git a/tests/helium-entity-manager.ts b/tests/helium-entity-manager.ts index a398e3229..1f4c9f96e 100644 --- a/tests/helium-entity-manager.ts +++ b/tests/helium-entity-manager.ts @@ -131,7 +131,8 @@ describe("helium-entity-manager", () => { authority: me, dao, activeDeviceAuthority: activeDeviceAuthority.publicKey, - numTokens: MAKER_STAKING_FEE.mul(new BN(2)), + // Add some padding for onboards + numTokens: MAKER_STAKING_FEE.mul(new BN(2)).add(new BN(10000000000)), })); }); @@ -160,7 +161,7 @@ describe("helium-entity-manager", () => { .preInstructions(await createMintInstructions(provider, 0, me, me, mint)) .accounts({ dao, - mint: mint.publicKey + mint: mint.publicKey, }) .signers([mint]) .rpc({ skipPreflight: true }); @@ -177,8 +178,8 @@ describe("helium-entity-manager", () => { }) .rpc({ skipPreflight: true }) - const postBalance = (await getAccount(provider.connection, burnAta)).amount - expect(postBalance).to.eq(BigInt(0)) + const postBalance = (await getAccount(provider.connection, burnAta)).amount; + expect(postBalance).to.eq(BigInt(0)); }); it("initializes a rewardable entity config", async () => { @@ -548,22 +549,28 @@ describe("helium-entity-manager", () => { hemProgram, subDao, { - mobileConfigV1: { + mobileConfigV2: { feesByDevice: [ { deviceType: { cbrs: {} }, dcOnboardingFee: toBN(0, 5), locationStakingFee: toBN(10, 5), + mobileOnboardingFeeUsd: toBN(0, 6), + reserved: new Array(8).fill(new BN(0)), }, { deviceType: { wifiIndoor: {} }, - dcOnboardingFee: toBN(0, 5), + dcOnboardingFee: toBN(10, 5), locationStakingFee: toBN(0, 5), + mobileOnboardingFeeUsd: toBN(10, 6), + reserved: new Array(8).fill(new BN(0)), }, { deviceType: { wifiOutdoor: {} }, - dcOnboardingFee: toBN(0, 5), + dcOnboardingFee: toBN(10, 5), locationStakingFee: toBN(0, 5), + mobileOnboardingFeeUsd: toBN(20, 6), + reserved: new Array(8).fill(new BN(0)), }, ], }, @@ -680,6 +687,7 @@ describe("helium-entity-manager", () => { rewardableEntityConfig, getAssetFn, getAssetProofFn, + deviceType: "wifiIndoor", }) ).signers([makerKeypair, hotspotOwner]); @@ -691,9 +699,7 @@ describe("helium-entity-manager", () => { ); expect(Boolean(mobileInfoAcc)).to.be.true; const subDaoAcc = await hsdProgram.account.subDaoV0.fetch(subDao); - expect(subDaoAcc.dcOnboardingFeesPaid.toNumber()).to.be.eq( - 0 - ); + expect(subDaoAcc.dcOnboardingFeesPaid.toNumber()).to.be.eq(1000000); }); describe("with hotspot", () => { @@ -783,44 +789,51 @@ describe("helium-entity-manager", () => { await hemProgram.methods .updateRewardableEntityConfigV0({ settings: { - mobileConfigV1: { + mobileConfigV2: { feesByDevice: [ { deviceType: { cbrs: {} }, dcOnboardingFee: toBN(40, 5), locationStakingFee: toBN(10, 5), + mobileOnboardingFeeUsd: toBN(0, 6), + reserved: new Array(8).fill(new BN(0)), }, { deviceType: { wifiIndoor: {} }, - dcOnboardingFee: toBN(0, 5), + dcOnboardingFee: toBN(10, 5), locationStakingFee: toBN(0, 5), + mobileOnboardingFeeUsd: toBN(10, 6), + reserved: new Array(8).fill(new BN(0)), }, { deviceType: { wifiOutdoor: {} }, - dcOnboardingFee: toBN(0, 5), + dcOnboardingFee: toBN(10, 5), locationStakingFee: toBN(0, 5), + mobileOnboardingFeeUsd: toBN(20, 6), + reserved: new Array(8).fill(new BN(0)), }, ], }, }, newAuthority: null, - stakingRequirement: MAKER_STAKING_FEE + stakingRequirement: MAKER_STAKING_FEE, }) .accounts({ rewardableEntityConfig }) .rpc({ skipPreflight: true }); - const subDaoAcc = await hsdProgram.account.subDaoV0.fetch(subDao); + await method.rpc({ skipPreflight: true }); + const postBalance = await provider.connection.getTokenAccountBalance( ata ); expect(postBalance.value.uiAmount).to.be.eq( - preBalance.value.uiAmount! - subDaoAcc.onboardingDcFee.toNumber() + preBalance.value.uiAmount! - toBN(40, 5).toNumber() ); const infoAcc = await hemProgram.account.mobileHotspotInfoV0.fetch( infoKey! ); expect(infoAcc.dcOnboardingFeePaid.toNumber()).to.eq( - subDaoAcc.onboardingDcFee.toNumber() + toBN(40, 5).toNumber() ); }); @@ -983,7 +996,7 @@ describe("helium-entity-manager", () => { .updateRewardableEntityConfigV0({ newAuthority: PublicKey.default, settings: null, - stakingRequirement: null + stakingRequirement: null, }) .accounts({ rewardableEntityConfig,