Rewardable Entities
Following the transition to Solana, all actors in the network that receive tokens are considered "Rewardable Entities."
While, as of writing this document, hotspots are the only rewardable entity, this will change with the addition of mappers and other rewardable categories.
A rewardable entity on the Helium Network is represented on Solana as an NFT (Non-Fungible Token).
NFTs are ideal for this job as the represent ownership in a way that is easily transferrable and recognized by all major wallets. Open your wallet, and see all of your hotspots inside.
Compression NFTs
A new developmenet on the Solana Blockchain has been Compression NFTs.
With normal NFTs on Solana, each token is stored on chain as a distinct spl-token mint, with decimals of 0 and supply of 1. This mint has some metadata attached to it, as well as a token account representing the ownership of the token. In total this leads to 4 accounts with a cost of 0.01197616 SOL in rent. Multiplying this by millions of hotspots leads to not only a high cost, but also a large usage of storage space on the blockchain.
Enter: Compression NFTs. Compression NFTs use a Concurrent Merkle Tree to compress millions of NFTs into one 32 byte hash. Clients executing transactions on compressed NFTs are able to send a cryptograpic proof to the blockchain that verifies the NFT and ownership. In other words, instead of sending a transaction with:
...
pub mint: Account<'info, Mint>,
pub metadata: Account<'info, Metadata>,
pub master_edition: Account<'info, MasterEdition>,
#[account(
constraint = token_account.amount >= 1,
token::owner = owner
)]
pub token_account: Account<'info, TokenAccount>,
pub owner: Signer<'info>
...
You can instead send:
pub struct ProofArgs {
pub hash: Vec<u8>, // The 32 byte hash of the NFT
pub root: [u8; 32], // The root of the merkle tree
pub index: u32, // The index of the item in the tree
pub proof: Vec<Vec<u8>> // An array of 32 byte proofs proving that the `hash` is part of the `root`
}
...
pub merkle_tree: UncheckedAccount<'info>,
...
While this can make the transaction size larger, the amount of data stored on chain is tiny. While
this may look difficult, this is all neatly packed in the Metaplex Digital Asset API. This API runs
on multiple RPC providers, and allows you to hit convenient functions like get_assets_by_owner
and
get_asset_proof
so that you never have to worry about the underlying concurrent merkle algorithm.
Fetching Rewardable Entities.
All rewardable entities are created by the helium-entity-manager
program. Further, they exist
under a collection that is managed by the Maker of that entity. The easiest way to fetch all helium
entities for a given wallet is to filter by the helium-entity-manager-creator
We can get this address by running
import { entityCreatorKey } from "@helium/helium-entity-manager-sdk";
const hntMint = new PublicKey('...hnt mint...')
const creator = entityCreatorKey(hntMint)[0]
This should output: 9XtEZRnuWizL8vGoNwRX636guuSXTjQvsf3wAXmH4C1b
Now, we can use the metaplex digital asset api to get rewardable entities my wallet
(BcJzP2hEYgzjUwpHEtS6RhuqGfEJVx8Rq3MejujAAWrR
) owns:
curl --request POST \
--url https://rpc-devnet.aws.metaplex.com/ \
--header 'Content-Type: application/json' \
--data '{
"jsonrpc": "2.0",
"method": "search_assets",
"id": "get-assets-op-1",
"params": {
"ownerAddress": "BcJzP2hEYgzjUwpHEtS6RhuqGfEJVx8Rq3MejujAAWrR",
"page": 0,
"creatorAddress": "9XtEZRnuWizL8vGoNwRX636guuSXTjQvsf3wAXmH4C1b",
"creatorVerified": true,
"sortBy": {"sortBy": "created", "sortDirection": "asc"}
}
}'
Hotspots and ECC Compact Keys
Hotspots on the Helium network currently are uniquely identified by their ECC Compact Public Keys
At onboarding time, hotspots are able to sign an onboard transaction proving physical ownership of the device.
As such, the Helium Solana implementation enforces that all NFTs are unique by ECC Compact keys.
If you have an asset id of an NFT on Solana, you can get the ECC Compact Key by fetching the digital asset and pulling out the last value from the content.json_uri.
For example:
const asset = await getAsset(rpcUrl, new PublicKey("...asset id..."));
const eccCompactKey = asset.content.json_uri.split("/").slice(-1)[0];
If you have only an ECC Compact key and would like to find the NFT, you can use the KeyToAssetV0
struct that is stored on chain:
import { daoKey } from "@helium/helium-sub-daos-sdk"
import { keyToAssetKey, init } from "@helium/helium-entity-manager-sdk"
// Declare constants
const hntMint = new PublicKey("...hnt mint...")
const dao = daoKey(hntMint)[0]
const eccKey = "...ecc compact key..."
// Init program
const program = await init(provider);
// Get the asset id from the KeyToAssetV0 account on chain
const keyToAssetPkey = keyToAssetKey(dao, eccKey)[0]
const keyToAssetAcc = await program.acoount.KeyToAssetV0.fetch(keyToAssetPkey);
const assetId = keyToAssetAcc.asset;
// Now get the asset from the metaplex asset api
const asset = await getAsset(rpcUrl, assetId)
Transferring Rewardable Entities
Onboarding Rewardable Entities
The onboarding flow for an entity is general that a customer purchases the hardware from a maker, and then self-onboards to the network. Because all hardware are created by approved makers, the onboard transaction must be approved by the maker. This uses a similar flow to the legacy onboarding-server of the Helium blockchain.
The main difference between the Solana version of Helium, and the legacy version on the helium chain, is that onboarding is on a per-network basis. A Hotspot can be an IOT hotspot, a MOBILE hotspot, or both. If the Hotspot wants to earn both MOBILE and IOT rewards, it must onboard to the IOT network AND the MOBILE network. It must also assert location in both.
Similarly, makers are approved for specific subnetworks. A maker that creates IOT hotspots can not necessarily onboard MOBILE hotspots. Each network subdao must approve makers to issue rewardable entities.
The following test shows the full flow of onboarding a Hotspot using the onboarding server.
The React Native Helium SDK should greatly simplify this process.
import Address from "@helium/address";
import { Keypair } from "@helium/crypto";
import { sendAndConfirmWithRetry } from "@helium/spl-utils";
import { AddGatewayV1 } from "@helium/transactions";
import { accountProviders } from "@metaplex-foundation/mpl-bubblegum";
import {
Connection,
PublicKey,
Transaction,
Keypair as SolanaKeypair,
} from "@solana/web3.js";
import axios from "axios";
import { BN } from "bn.js";
function random(len: number): string {
return new Array(len).join().replace(/(.|$)/g, function () {
return ((Math.random() * 36) | 0).toString(36);
});
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe("onboarding server", () => {
let me: Keypair;
let gateway: Keypair;
const maker = Address.fromB58(
"ENTER YOUR MAKER ADDRESS"
);
let onboardingKey: string;
let base = "https://onboarding.oracle.test-helium.com";
before(async () => {
me = await Keypair.makeRandom();
gateway = await Keypair.makeRandom();
onboardingKey = gateway.address.b58;
const result = await axios.post(
`${base}/api/v2/hotspots`,
{
onboardingKey,
macWlan0: random(10),
macEth0: random(10),
rpiSerial: random(10),
heliumSerial: random(10),
batch: "example-batch",
},
{
headers: {
authorization: "ENTER YOUR MAKER AUTH KEYS"
},
}
);
await sleep(2000);
});
it("should run issue txs", async () => {
const hotspot = (
await axios.get(`${base}/api/v2/hotspots/${onboardingKey}`)
).data.data;
console.log(hotspot);
console.log(me.privateKey);
const solanaKeypair = SolanaKeypair.fromSecretKey(me.privateKey);
console.log("Solana pubkey: ", solanaKeypair.publicKey.toBase58())
await sleep(2000);
const result = await axios.post(
`${base}/api/v3/transactions/create-hotspot`,
{
location: new BN(1).toString(),
transaction: (
await new AddGatewayV1({
owner: me.address,
gateway: gateway.address,
payer: maker,
}).sign({
gateway,
})
).toString(),
}
);
const connection = new Connection("https://api.devnet.solana.com");
const { solanaTransactions } = result.data.data;
for (const solanaTransaction of solanaTransactions) {
const txid = await sendAndConfirmWithRetry(
connection,
Buffer.from(solanaTransaction),
{ skipPreflight: true },
"confirmed"
);
console.log(txid.txid);
}
let tries = 0;
let onboardResult: any;
while (tries < 10 && !onboardResult) {
try {
onboardResult = await axios.post(
`${base}/api/v3/transactions/mobile/onboard`,
{
entityKey: onboardingKey,
}
);
} catch {
console.log(`Hotspot may not exist yet ${tries}`);
tries++;
await sleep(2000); // Wait for hotspot to be indexed into asset api
}
}
for (const solanaTransaction of onboardResult!.data.data
.solanaTransactions) {
const tx = Transaction.from(Buffer.from(solanaTransaction));
tx.partialSign(solanaKeypair)
const txid = await sendAndConfirmWithRetry(
connection,
tx.serialize(),
{ skipPreflight: true },
"confirmed"
);
console.log(txid.txid);
}
const updateResult = await axios.post(
`${base}/api/v3/transactions/mobile/update-metadata`,
{
entityKey: onboardingKey,
location: new BN(1).toString(),
elevation: 2,
gain: 11,
wallet: solanaKeypair.publicKey.toBase58()
}
);
for (const solanaTransaction of updateResult.data.data.solanaTransactions) {
const tx = Transaction.from(Buffer.from(solanaTransaction));
tx.partialSign(solanaKeypair)
const txid = await sendAndConfirmWithRetry(
connection,
tx.serialize(),
{ skipPreflight: true },
"confirmed"
);
console.log(txid.txid);
}
});
});
Claiming Rewards on Rewardable Entities
A system of oracles keep track of the rewards owed to each Rewardable Entity. For example, in the IOT network there are oracles that keep track of the IOT tokens earned by each Hotspot for PoC.
These oracles are responsible ONLY for tracking the total lifetime rewards associated with a hotspot. This frees the oracles from any kind of dependence on the blockchain. They don't care about the issuance or movement of tokens.
The lazy-distributor
program keeps track of, for each hotspot, how much of the total lifetime
rewards have been claimed. Claiming rewards, then, consists of two steps:
- Ask oracles to update the on-chain state to reflect the latest lifetime rewards for this hotspot
- Issue the difference between the total lifetime rewards and total claimed rewards to the hotspot owner.
In practice, we have made it so that this can all happen in a singular transaction.
Finding current rewards
Oracles work off of a restful API that involves
- Grabbing lifetime rewards for all oracles
- Constructing a transaction to set the rewards, and passing the transaction to each oracle to partially sign.
- Submitting the fully signed transaction to Solana.
The SDK simplifies this process into two functions, getCurrentRewards
and formTransaction
.
If you have interst in using the REST api, you can see the relatively simple client code here
To fetch rewards across all oracles for a given asset id:
import { init, lazyDistributorKey } from "@helium/lazy-distributor-sdk";
import { getCurrentRewards } from "@helium/distributor-oracle";
import * as anchor from "@coral-xyz/anchor";
anchor.setProvider(anchor.AnchorProvider.local("...rpc url..."));
const provider = anchor.getProvider() as anchor.AnchorProvider;
const program = await init(provider)
const iotMint = new PublicKey("...iot mint...")
const assetId = new PublicKey("...asset id...")
const lazyDistributorPkey = lazyDistributorKey(iotMint)[0];
const rewards = await getCurrentRewards(program, lazyDistributorPkey, assetId);
rewards.map(reward => {
console.log(`Oracle ${reward.oracleKey} stating lifetime rewards of ${reward.currentRewards}`);
})
Claiming rewards
You can then get a transaction to submit to the solana blockchain using those rewards:
import { formTransaction } from "@helium/distributor-oracle";
const tx = await formTransaction({
program,
provider,
rewards, // from above
hotspot: assetId,
lazyDistributor: lazyDistributorPkey,
})
await provider.send(tx)