The Black Hole Prototype – Technical Walkthrough
In this post, we will learn about the creation of The Black Hole, a prototype for retiring digital carbon using smart contracts on the Hedera network. The article explores unique challenges and solutions associated with token allowance, association, and transfer. It further highlights the use of HashConnect と HIP-584 to create a user-friendly web app for interacting with The Black Hole. The post also briefly delves into future improvements and ambitions, such as reducing gas costs, solving the rent payment dilemma, and the potential for carbon retirement in the crypto-space.
This technical breakdown complements the DOVU announcement of The Black Hole protocol. Please read that first for context then head back here to get a deep dive into the technical details.
If you’d like to jump straight to the demo then please head over to The Black Hole Prototype Demo which is a NextJS site demoing the interaction with The Black Hole smart contract.
Why?
Because who wouldn’t want to put “Built a Black Hole” on their résumé!?
…also, The Black Hole is a prototype for the DOVU retirement protocol. In building this prototype, we have been able to explore and improve our tools for the creation and deployment of smart contracts on Hedera. It is also very important to us that everyone is able to easily interact with it, so we built a frontend app using NextJS to demo The Black Hole prototype. We make use of the new mirror node endpoint POST /api/v1/contracts/call
that HIP-584 enables, allowing us to read our contract state for free. The demo also makes use of HashConnect for signing transactions.
Securing a One Way Black Hole
The Black Hole smart contract is designed to consume NFTs. It enables the transfer of NFTs to itself but purposely provides no ability to transfer them out other than from network side effects such as not paying rent which we discuss later. In addition to this, The Black Hole also keeps track of a couple of basic stats:
- The total number of NFTs it has consumed
- A list of accounts and the NFTs each account has sent to be consumed
Steps Into the Abyss with Consent
There are three steps to transferring an NFT into The Black Hole:
- The Allowance Approval (Required before calling the smart contract)
- The Token Association (Smart contract function)
- The Token Transfer (Smart contract function)
The Allowance Approval
The recent security updates to Hedera requires an additional layer of permission granting from the owner before a smart contract can interact with their tokens. If you want a smart contract to perform any transaction on your tokens, a user must first sign an AccountAllowanceApproveTransaction transaction. This essentially says “Hey smart contract, I allow you to perform actions on this token as if you were the owner”. Once this step has been completed, the smart contract then has the permissions to act on that token when run.
So, to begin, the user signs an approveTokenNftAllowance transaction which grants the smart contract access to the specific NFT they want to send. The NFT is not transferred at this point, it is still held by the owner and not the smart contract.
Here is a simplified example from our live demo which uses the signer from HashConnect to perform this approval:
import {
AccountAllowanceApproveTransaction,
AccountId,
NftId,
Status,
TokenId,
} from "@hashgraph/sdk";
// The token ID
const tokenId = TokenId.fromString("0.0.123456")
// The serial we want to transfer
const serialNumber = 42
const nftId = new NftId(allowanceTokenId, serialNumber)
// This is the user's account
const ownerAccountId = AccountId.fromString("0.0.234567")
// This is The Black Hole smart contract ID
const spenderAccountId = AccountId.fromString("0.0.345678")
let transaction = await new AccountAllowanceApproveTransaction()
.approveTokenNftAllowance(nftId, ownerAccountId, spenderAccountId)
.freezeWithSigner(signer)
const response = await transaction.executeWithSigner(signer)
const receipt = await response.getReceiptWithSigner(signer);
if (receipt?.status !== Status.Success) {
throw new Error(
`Failed to approve allowance for ${tokenId}#${serialNumber}.`
);
}
It is important to remember that the operator ID of the signer
needs to be the same as the ownerAccountId
otherwise the transaction will fail.
Once the allowance transaction has been signed by the user, The Black Hole smart contract function castIntoBlackHole
can be called which carries out the final two steps of token association and transfer.
The full smart contract can be found as an example in our Hedera Hardhat Tooling repo.
function castIntoBlackHole(address _tokenAddress, int64 _serialNumber) external {
_tokenAssociate(_tokenAddress);
_tokenTransfer(_tokenAddress, _serialNumber);
}
The Token Association
In order to receive a token, the smart contract must first associate the token ID with its account. We use the HederaTokenService.associateToken(…) precompiled contract for this.
function _tokenAssociate(address _tokenAddress) private {
int256 response = HederaTokenService.associateToken(address(this), _tokenAddress);
if (
response != HederaResponseCodes.SUCCESS &&
response != HederaResponseCodes.TOKEN_ALREADY_ASSOCIATED_TO_ACCOUNT
) {
revert("Associate Failed");
}
}
The Token Transfer
Once the token is associated, a call is then made to the pre-compile contract for transferring the NFT from the user’s account to The Black Hole smart contract account. We take the opportunity to update the analytics properties here too to keep track of the total NFTs sent and updating the NFTs sent by sender address.
function _tokenTransfer(address _tokenAddress, int64 _serialNumber) private {
sentNFTs[msg.sender].push(NFT(_tokenAddress, _serialNumber));
totalNFTs++;
int256 response = HederaTokenService.transferNFT(_tokenAddress, msg.sender, address(this), _serialNumber);
if (response != HederaResponseCodes.SUCCESS) {
revert("Transfer Failed");
}
}
This completes the transfer and the NFT is now locked away within The Black Hole.
The Black Hole State
Before HIP-584 the only way you could read data from a contract was by directly calling the contract on the mainnet. This costs gas each time you call because you were interacting with the Hashgraph. HIP-584 gave us a really handy mirror node endpoint for performing reads for free! Let’s take a look at how this is done…
The endpoint is a POST request to api/v1/contracts/call
. It allows us to call functions on a smart contract. As long as these are view functions that don’t mutate the contract, then you will get a response as if you were calling it on the mainnet.
The POST takes a payload body similar to this
{
"data": "0xcdea2d12",
"from": "0x00000000000000000000000000000000003636b3",
"to": "0x000000000000000000000000000000000041797b"
}
data
– This is a hex encoded representation of the function name to call on the contract. We use Web3 in our example to do this encoding. The example above encodes the ABI function of getMassOfBlackHole
from
– This is the senders address as an EVM HEX string
to
– This is the address of the contract as an EVM HEX string
We use the Hedera JS SDK to create the EVM solidity addresses for us.
The only other thing we’ll require is the ABI of the smart contract. This is the JSON file that is generated when you build and deploy your smart contract which describes how to interact with your contract. If you used our Hedera Hardhat Tooling then you can find this after you build and deploy your contract in the following folder: /artificats/contracts/<YOUR_CONTRACT>.sol/<YOUR_CONTRACT>.json. The ABI in the following example is simply the array from that json file’s “abi” key.
Here is a simplified JavaScript example showing how to construct the payload and make a call to the mirror node on testnet.
import { AccountId } from "@hashgraph/sdk";
import Web3 from "web3";
const web3 = new Web3();
const abi = [
{
inputs: [],
name: "getMassOfBlackHole",
outputs: [
{
internalType: "int64",
name: "",
type: "int64",
},
],
stateMutability: "view",
type: "function",
},
{...},
]
// The function name in the contract we want to call
const functionName = "getMassOfBlackHole";
// The Hedera account ID of the user calling the contract
const fromAddress = "0.0.3552947"
// The Hedera contract ID of the contract
const contractAddress = "0.0.4290939"
// Fetch the function entity from the ABI
const matchedAbiFunction = abi.find(
(f) => f.name === functionName
);
// Encode the function call
const encodedFunctionCall = web3.eth.abi.encodeFunctionCall(matchedAbiFunction,[]);
// Convert the Hedera account ID and contract ID to EVM addresses
const contractEvmAddress = AccountId.fromString(contractAddress).toSolidityAddress();
const fromEvmAddress = AccountId.fromString(fromAddress).toSolidityAddress();
// Add the 0x prefix to the EVM addresses to make them valid HEX strings
const contractEvmAddressHex = "0x".concat(contractEvmAddress);
const fromEvmAddressHex = "0x".concat(fromEvmAddress);
// The body of the request to the API
const payload = {
data: encodedFunctionCall, // "0xcdea2d12"
from: fromEvmAddressHex, // "0x00000000000000000000000000000000003636b3"
to: contractEvmAddressHex, // "0x000000000000000000000000000000000041797b"
};
// Make a POST request to the mirror node with the payload
const response = await fetch('https://testnet.mirrornode.hedera.com/api/v1/contracts/call', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const data = await response.json();
/* Example data response
{
"result": "0x0000000000000000000000000000000000000000000000000000000000000019"
}
*/
const hex = data.result;
Once we have the hex response data back, we can use Web3 to decode it for us so it’s easier to use:
import Web3 from "web3";
const web3 = new Web3();
const abi = [
{
inputs: [],
name: "getMassOfBlackHole",
outputs: [
{
internalType: "int64",
name: "",
type: "int64",
},
],
stateMutability: "view",
type: "function",
}
]
/**
* Decodes the result of a contract's function execution
* @param functionName the name of the function within the ABI
* @param hex a hex string containing the execution result
*/
export function decodeFunctionResult ({functionName, hex}) {
const functionAbi = abi.find((func) => func.name === functionName);
if (!functionAbi) {
throw new Error(`Function "${functionName}" not found in ABI`);
}
const functionParameters = functionAbi.outputs;
if (!functionParameters) {
throw new Error(`Function "${functionName}" has no output parameters`);
}
const result = web3.eth.abi.decodeParameters(functionParameters, hex);
/*
`result` will look like this:
{ '0': '25', __length__: 1 }
In this situation we only have a single output but if you have more
outputs from your contract function, then you'll need to handle
them here.
*/
return result[0];
}
You can now call the decoder with the hex value you retrieved from the mirror node
const result = decodeFunctionResult({functionName, hex})
// result: 25
We now have the decoded value from the contract and this was free to call!
From Black Holes to Carbon Retirement
With The Black Hole built, we’re now able to reflect and look at the next steps to a carbon retirement protocol.
Saving Fuel
We can reduce gas costs by first checking if The Black Hole has the token ID associated and skip the associate token call.
Fallback Fees
When transferring an NFT that has fallback fees, the receiver of the NFT would normally have to pay for this. Our demo doesn’t currently support these kind of NFTs but could be adjusted to do so. We don’t expect carbon credit NFTs to have fallback fees set but we can cater for this if need be.
DOV to Solve The Black Hole Rent Dilemma
Every smart contract on Hedera has to pay rent to the network to avoid being deleted. Although this fee is minimal it still needs to be considered carefully when dealing with a protocol which is meant to guarantee it’ll be around forever keeping those NFTs locked away. In our retirement announcement blog post, we hinted at the concept of a smart contract that would use DOV as a form of gas for the execution of the contract. The DOV would be swapped for HBAR via a DEX contract, such as Saucerswap, ensuring that the contract was able to exist without having to rely on the manual injection of HBAR for rent payments.
A Cluster of Auto Spawning Black Holes
Scaling the smart contract to support storing millions of NFTs may require a cluster of black holes, as Hedera accounts have historically been limited to holding a finite amount of value. As a contract reaches a certain threshold there may be a need to spawn a new version of itself. Using DOV as gas will become critical in this respect to maintain the existence, auto creation and maintenance of these contracts on the network, in permanence.
These challenges will turn into interesting opportunities to gamify the protocol for the DOVU community.
Analytics
Providing a simple and transparent view into the state of The Black Hole is crucial for those that need to report on it. There is scope for adding events and tracking other key indicators to make reporting and reacting to the state of the contract easy. We aim to make carbon retirement as simple and transparent as possible.
…and breathe
If you’re still with us here then thank you! It’s been a deep dive and we hope you’ve gleaned some knowledge from it. We’re sure you’ll have questions and we’d love to get the community’s feedback on this too as together we can create amazing things!
This is just the beginning – from the depths of The Black Hole, we are ready to take on the greater challenge of carbon retirement. We’re excited about the potential impact and the role we can play in driving forward sustainable practices in the crypto-space. Stay tuned for future developments as we continue to enhance our carbon retirement protocol.