Migrate an app to FTSO
FTSO Adapters, provided by the @flarenetwork/ftso-adapters
library, allow decentralized applications (dApps) built for other popular oracle interfaces to integrate with Flare's FTSO with minimal code changes.
The library provides adapters for Pyth, Chainlink, API3, Band Protocol, and Chronicle.
These adapters act as a compatibility layer, translating the FTSO's data structure into the format expected by each respective oracle's interface.
This enables a seamless migration path for projects looking to leverage the speed, decentralization, and cost-effectiveness of Flare's native oracle. This guide focuses on the specific code modifications required to migrate your existing dApp.
All code examples can be found in our hardhat-starter-kit.
Key code changes
Migrating to a Flare FTSO adapter requires a different approach to handling oracle data. Instead of your contract calling an external oracle, it will now manage price data internally by using an adapter library. This process involves two main changes: modifying your smart contract and setting up a new offchain keeper process.
1. Onchain: Use the adapter library
The main changes happen within your smart contract. You will modify it to store, update, and serve the FTSO price data itself.
-
State Variables: Instead of storing an address to an external oracle, you add state variables to your contract to manage the FTSO feed and cache the price data.
- Before:
AggregatorV3Interface internal dataFeed;
- After:
bytes21 public immutable ftsoFeedId; FtsoChainlinkAdapterLibrary.Round private _latestPriceData;
- Before:
-
Constructor: Your constructor no longer needs an oracle's address. Instead, it takes FTSO-specific information, such as the
ftsoFeedId
and any other parameters the adapter needs (likechainlinkDecimals
).- Before:
constructor(address _dataFeedAddress) { dataFeed = AggregatorV3Interface(_dataFeedAddress); }
- After:
constructor(bytes21 _ftsoFeedId, uint8 _chainlinkDecimals) { ftsoFeedId = _ftsoFeedId; chainlinkDecimals = _chainlinkDecimals; }
- Before:
-
Implement
refresh()
: You must add a publicrefresh()
function. This function's only job is to call the adapter library'srefresh
logic, which updates your contract's state variables with the latest FTSO price. -
Implement the Oracle Interface: You then add the standard
view
function for the oracle you are migrating from (e.g.,latestRoundData()
for Chainlink). This function calls the corresponding logic from the adapter library, reading directly from your contract's cached state. -
No Change to Core Logic: Your dApp's internal logic that uses the price data remains unchanged. It continues to call the same standard oracle functions as before (e.g.,
latestRoundData()
), but now it's calling a function implemented directly within your own contract.
2. Offchain: Set up a keeper bot
Since your contract now manages its own price updates, you need an external process to trigger them.
- Create a Keeper Script: This is a simple script that connects to the network and periodically calls the public
refresh()
function on your deployed contract. - Run the Keeper: This script ensures the price cached in your contract stays fresh. It replaces the need to rely on the oracle provider's keepers, giving you direct control over how often your prices are updated and how much you spend on gas.
FtsoChainlinkAdapter
The FtsoChainlinkAdapter
implements Chainlink's AggregatorV3Interface
. The example is an AssetVault
contract that uses the FTSO price to value collateral for borrowing and lending.
ChainlinkExample.sol
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;
import {FtsoChainlinkAdapterLibrary} from "@flarenetwork/ftso-adapters/contracts/coston2/ChainlinkAdapter.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract AssetVault is ERC20 {
// --- Adapter State ---
bytes21 public immutable ftsoFeedId;
uint8 public immutable chainlinkDecimals;
string public descriptionText;
uint256 public immutable maxAgeSeconds;
// The AssetVault is now responsible for storing the cached price data.
FtsoChainlinkAdapterLibrary.Round private _latestPriceData;
// Mapping of user addresses to their deposited collateral amount in wei.
mapping(address => uint256) public collateral;
// The Loan-to-Value (LTV) ratio, as a percentage.
// e.g., 50 means a user can borrow up to 50% of their collateral's value.
uint256 public constant LOAN_TO_VALUE_RATIO = 50;
// --- Errors ---
error InsufficientCollateral();
error NothingToWithdraw();
error LoanNotRepaid();
error AmountIsZero();
// --- Events ---
event CollateralDeposited(address indexed user, uint256 amount);
event CollateralWithdrawn(address indexed user, uint256 amount);
event LoanBorrowed(address indexed user, uint256 amount);
event LoanRepaid(address indexed user, uint256 amount);
constructor(
bytes21 _ftsoFeedId,
uint8 _chainlinkDecimals,
string memory _description,
uint256 _maxAgeSeconds
) ERC20("Mock USD", "MUSD") {
// Initialize the adapter configuration state.
ftsoFeedId = _ftsoFeedId;
chainlinkDecimals = _chainlinkDecimals;
descriptionText = _description;
maxAgeSeconds = _maxAgeSeconds;
}
// --- Public Refresh Function ---
// --- Public Adapter Functions ---
function refresh() external {
// Call the library's logic, passing this contract's state to be updated.
FtsoChainlinkAdapterLibrary.refresh(
_latestPriceData,
ftsoFeedId,
chainlinkDecimals
);
}
function latestRoundData()
public
view
returns (uint80, int256, uint256, uint256, uint80)
{
// Call the library's logic, passing this contract's state to be read.
return
FtsoChainlinkAdapterLibrary.latestRoundData(
_latestPriceData,
maxAgeSeconds
);
}
function decimals() public view virtual override(ERC20) returns (uint8) {
return ERC20.decimals();
}
// --- Core Functions ---
/**
* @notice Deposits the sent native tokens as collateral for the sender.
*/
function deposit() external payable {
if (msg.value == 0) revert AmountIsZero();
collateral[msg.sender] += msg.value;
emit CollateralDeposited(msg.sender, msg.value);
}
/**
* @notice Allows a user to borrow MUSD against their deposited collateral.
* @param _amount The amount of MUSD to borrow (with 18 decimals).
*/
function borrow(uint256 _amount) external {
if (_amount == 0) revert AmountIsZero();
uint256 userCollateralValue = getCollateralValueInUsd(msg.sender);
// The total debt will be their current MUSD balance plus the new amount.
uint256 totalDebt = balanceOf(msg.sender) + _amount;
// Calculate the maximum borrowable amount based on LTV.
uint256 maxBorrowableUsd = (userCollateralValue * LOAN_TO_VALUE_RATIO) /
100;
if (totalDebt > maxBorrowableUsd) revert InsufficientCollateral();
_mint(msg.sender, _amount);
emit LoanBorrowed(msg.sender, _amount);
}
/**
* @notice Repays a portion of the user's MUSD loan. The user must first approve
* this contract to spend their MUSD.
* @param _amount The amount of MUSD to repay.
*/
function repay(uint256 _amount) external {
if (_amount == 0) revert AmountIsZero();
// The user must have enough MUSD to repay.
// This also implicitly checks that they have a loan.
require(balanceOf(msg.sender) >= _amount, "Insufficient MUSD balance");
_burn(msg.sender, _amount);
emit LoanRepaid(msg.sender, _amount);
}
/**
* @notice Withdraws a specified amount of the user's collateral.
* @dev The user must have repaid their entire MUSD loan before withdrawing.
* @param _amount The amount of collateral to withdraw in wei.
*/
function withdraw(uint256 _amount) external {
if (_amount == 0) revert AmountIsZero();
if (balanceOf(msg.sender) > 0) revert LoanNotRepaid();
if (collateral[msg.sender] < _amount) revert NothingToWithdraw();
collateral[msg.sender] -= _amount;
payable(msg.sender).transfer(_amount);
emit CollateralWithdrawn(msg.sender, _amount);
}
// --- View Functions ---
/**
* @notice Calculates the total USD value of a user's collateral.
* @param _user The address of the user.
* @return The total value in USD, scaled by 18 decimals.
*/
function getCollateralValueInUsd(
address _user
) public view returns (uint256) {
uint256 userCollateral = collateral[_user];
if (userCollateral == 0) return 0;
// Call this contract's own public latestRoundData function.
(, int256 price, , , ) = latestRoundData();
return (userCollateral * uint256(price)) / (10 ** chainlinkDecimals);
}
}
chainlinkExample.ts
import { artifacts, run, web3 } from "hardhat";
import { AssetVaultInstance } from "../../typechain-types";
// --- Configuration ---
const AssetVault: AssetVaultInstance = artifacts.require("AssetVault");
// FTSO Feed ID for FLR / USD (bytes21) on the Coston2 network.
const FTSO_FEED_ID = "0x01464c522f55534400000000000000000000000000";
// The number of decimals the adapted Chainlink feed will expose (8 is common for crypto pairs).
const CHAINLINK_DECIMALS = 8;
// A human-readable description for the price feed adapter.
const DESCRIPTION = "FTSOv2 FLR/USD adapted for Chainlink";
// A staleness check; the adapter will revert if the price hasn't been refreshed in this many seconds.
const MAX_AGE_SECONDS = 3600; // 1 hour
/**
* Deploys and verifies the AssetVault contract.
*/
async function deployContracts(): Promise<{ vault: AssetVaultInstance }> {
// 1. Deploy the AssetVault, linking it to the adapter
const vaultArgs: (string | number)[] = [
FTSO_FEED_ID,
CHAINLINK_DECIMALS,
DESCRIPTION,
MAX_AGE_SECONDS,
];
console.log("\nDeploying AssetVault with arguments:");
console.log(` - Price Feed Address: ${vaultArgs[0]}`);
console.log(` - Chainlink Decimals: ${vaultArgs[1]}`);
console.log(` - Description: ${vaultArgs[2]}`);
console.log(` - Max Age (seconds): ${vaultArgs[3]}`);
const vault = await AssetVault.new(
...(vaultArgs as [string, number, string, number]),
);
console.log("\n✅ AssetVault deployed to:", vault.address);
// 2. Verify contracts on a live network
try {
console.log("\nVerifying AssetVault on block explorer...");
await run("verify:verify", {
address: vault.address,
constructorArguments: vaultArgs,
});
console.log("Vault verification successful.");
} catch (e: unknown) {
if (e instanceof Error) {
console.error("Vault verification failed:", e.message);
} else {
console.error("An unknown error occurred during verification:", e);
}
}
return { vault };
}
/**
* Simulates a user's full lifecycle with the AssetVault.
* @param vault The deployed AssetVault instance.
*/
async function interactWithVault(vault: AssetVaultInstance) {
const [user] = await web3.eth.getAccounts();
const depositAmount = 100n * 10n ** 18n; // 100 native tokens (e.g., CFLR)
console.log(`\n--- Simulating user flow with account: ${user} ---`);
console.log(
`Initial collateral in vault: ${(await vault.collateral(user)).toString()}`,
);
// Step 1: User deposits collateral
console.log(
`\nStep 1: Depositing ${web3.utils.fromWei(depositAmount.toString())} native tokens as collateral...`,
);
await vault.deposit({ from: user, value: depositAmount.toString() });
console.log("✅ Deposit successful.");
// Step 2: Refresh the price feed on the adapter
console.log("\nStep 2: Refreshing the FTSO price on the adapter...");
await vault.refresh({ from: user });
console.log("✅ Price feed refreshed.");
// Step 3: Check the value of the deposited collateral
const collateralValueUSD = await vault.getCollateralValueInUsd(user);
const collateralValueFormatted =
Number(BigInt(collateralValueUSD.toString()) / 10n ** 16n) / 100;
console.log(`\nStep 3: Checking collateral value...`);
console.log(
`✅ User's 100 native tokens are worth ${collateralValueFormatted.toFixed(2)} USD.`,
);
// Step 4: Borrow MUSD against collateral (40% of LTV)
const borrowAmount = (BigInt(collateralValueUSD.toString()) * 40n) / 100n; // Borrow 40% of value
console.log(
`\nStep 4: Borrowing ${web3.utils.fromWei(borrowAmount.toString())} MUSD...`,
);
await vault.borrow(borrowAmount.toString(), { from: user });
const musdBalance = await vault.balanceOf(user);
console.log(
`✅ Borrow successful. User now has ${web3.utils.fromWei(musdBalance.toString())} MUSD.`,
);
// Step 5: Repay the MUSD loan
console.log(
`\nStep 5: Repaying the ${web3.utils.fromWei(borrowAmount.toString())} MUSD loan...`,
);
// First, user must approve the vault to spend their MUSD
await vault.approve(vault.address, borrowAmount.toString(), { from: user });
console.log(" - ERC20 approval successful.");
await vault.repay(borrowAmount.toString(), { from: user });
console.log(
"✅ Repayment successful. User MUSD balance is now:",
(await vault.balanceOf(user)).toString(),
);
// Step 6: Withdraw the original collateral
console.log(
`\nStep 6: Withdrawing the initial ${web3.utils.fromWei(depositAmount.toString())} native tokens...`,
);
await vault.withdraw(depositAmount.toString(), { from: user });
const finalCollateral = await vault.collateral(user);
console.log(
`✅ Withdrawal successful. User's final collateral in vault: ${finalCollateral.toString()}`,
);
}
async function main() {
console.log("🚀 Starting Asset Vault Management Script 🚀");
const { vault } = await deployContracts();
await interactWithVault(vault);
console.log("\n🎉 Script finished successfully! 🎉");
}
void main()
.then(() => {
process.exit(0);
})
.catch((error) => {
console.error(error);
process.exit(1);
});
FtsoPythAdapter
The FtsoPythAdapter
implements Pyth's IPyth
interface. The example is a PythNftMinter
contract that dynamically calculates a $1 minting fee based on the live FTSO price.
PythExample.sol
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;
import {FtsoPythAdapterLibrary} from "@flarenetwork/ftso-adapters/contracts/coston2/PythAdapter.sol";
import {IPyth, PythStructs} from "@pythnetwork/pyth-sdk-solidity/IPyth.sol";
/**
* @title PythNftMinter
* @notice Mints an NFT for $1 worth of an asset, using FTSO data via a Pyth-compatible interface.
* @dev This contract implements the IPyth interface and uses the FtsoPythAdapterLibrary for its logic.
*/
contract PythNftMinter is IPyth {
// --- Adapter State ---
bytes21 public immutable ftsoFeedId;
bytes32 public immutable pythPriceId;
string public descriptionText;
// The PythNftMinter is now responsible for storing the cached price data.
PythStructs.Price private _latestPrice;
// --- Minter-Specific State ---
uint256 private _nextTokenId;
error InsufficientFee();
constructor(
bytes21 _ftsoFeedId,
bytes32 _pythPriceId,
string memory _description
) {
// Initialize the adapter configuration state.
ftsoFeedId = _ftsoFeedId;
pythPriceId = _pythPriceId;
descriptionText = _description;
}
// --- Public Adapter Functions ---
function refresh() external {
// Call the library's logic, passing this contract's state to be updated.
FtsoPythAdapterLibrary.refresh(_latestPrice, ftsoFeedId, pythPriceId);
}
function getPriceNoOlderThan(
bytes32 _id,
uint _age
) public view override returns (PythStructs.Price memory) {
return
FtsoPythAdapterLibrary.getPriceNoOlderThan(
_latestPrice,
pythPriceId,
_id,
_age
);
}
function getPriceUnsafe(
bytes32 _id
) public view override returns (PythStructs.Price memory) {
return
FtsoPythAdapterLibrary.getPriceUnsafe(
_latestPrice,
pythPriceId,
_id
);
}
// --- Core Minter Functions ---
function mint() public payable {
// Call this contract's own public getPriceNoOlderThan function.
PythStructs.Price memory price = getPriceNoOlderThan(pythPriceId, 60);
uint assetPrice18Decimals = (uint(uint64(price.price)) * (10 ** 18)) /
(10 ** uint(uint32(-1 * price.expo)));
uint oneDollarInWei = ((10 ** 18) * (10 ** 18)) / assetPrice18Decimals;
if (msg.value < oneDollarInWei) revert InsufficientFee();
_mint(msg.sender);
}
function _mint(address to) private {
_nextTokenId++; // Mocking minting logic
}
function getTokenCounter() public view returns (uint256) {
return _nextTokenId;
}
// --- Unsupported IPyth Functions (Required for interface compliance) ---
function getEmaPriceUnsafe(
bytes32
) external view override returns (PythStructs.Price memory) {
revert("UNSUPPORTED");
}
function getEmaPriceNoOlderThan(
bytes32,
uint
) external view override returns (PythStructs.Price memory) {
revert("UNSUPPORTED");
}
function updatePriceFeeds(bytes[] calldata) external payable override {
revert("UNSUPPORTED");
}
function updatePriceFeedsIfNecessary(
bytes[] calldata,
bytes32[] calldata,
uint64[] calldata
) external payable override {
revert("UNSUPPORTED");
}
function getUpdateFee(
bytes[] calldata
) external view override returns (uint) {
revert("UNSUPPORTED");
}
function getTwapUpdateFee(
bytes[] calldata
) external view override returns (uint) {
revert("UNSUPPORTED");
}
function parsePriceFeedUpdates(
bytes[] calldata,
bytes32[] calldata,
uint64,
uint64
) external payable override returns (PythStructs.PriceFeed[] memory) {
revert("UNSUPPORTED");
}
function parsePriceFeedUpdatesWithConfig(
bytes[] calldata,
bytes32[] calldata,
uint64,
uint64,
bool,
bool,
bool
)
external
payable
override
returns (PythStructs.PriceFeed[] memory, uint64[] memory)
{
revert("UNSUPPORTED");
}
function parseTwapPriceFeedUpdates(
bytes[] calldata,
bytes32[] calldata
) external payable override returns (PythStructs.TwapPriceFeed[] memory) {
revert("UNSUPPORTED");
}
function parsePriceFeedUpdatesUnique(
bytes[] calldata,
bytes32[] calldata,
uint64,
uint64
) external payable override returns (PythStructs.PriceFeed[] memory) {
revert("UNSUPPORTED");
}
}
pythExample.ts
import { artifacts, run } from "hardhat";
import { PythNftMinterInstance } from "../../typechain-types";
// --- Configuration ---
const PythNftMinter: PythNftMinterInstance = artifacts.require("PythNftMinter");
// FTSO Feed ID for BTC / USD (bytes21)
const FTSO_FEED_ID = "0x014254432f55534400000000000000000000000000";
// Pyth Price ID for the same feed (bytes32)
const PYTH_PRICE_ID =
"0x4254432f55534400000000000000000000000000000000000000000000000001";
// Description for the adapter functionality
const DESCRIPTION = "FTSOv2 BTC/USD adapted for Pyth";
// --- Types ---
interface PriceData {
price: { toString(): string };
expo: { toString(): string };
publishTime: { toString(): string };
}
/**
* Deploys and verifies the integrated PythNftMinter contract.
*/
async function deployMinter(): Promise<{ minter: PythNftMinterInstance }> {
// The constructor arguments are now for the PythNftMinter itself.
const minterArgs: string[] = [FTSO_FEED_ID, PYTH_PRICE_ID, DESCRIPTION];
console.log("Deploying integrated PythNftMinter with arguments:");
console.log(` - FTSO Feed ID: ${minterArgs[0]}`);
console.log(` - Pyth Price ID: ${minterArgs[1]}`);
console.log(` - Description: ${minterArgs[2]}`);
const minter = await PythNftMinter.new(...minterArgs);
console.log("\n✅ PythNftMinter deployed to:", minter.address);
// Verify the single contract on a live network.
try {
console.log("\nVerifying PythNftMinter on block explorer...");
await run("verify:verify", {
address: minter.address,
constructorArguments: minterArgs,
});
console.log("Minter verification successful.");
} catch (e: unknown) {
if (e instanceof Error) {
console.error("Minter verification failed:", e.message);
} else {
console.error("An unknown error occurred during verification:", e);
}
}
return { minter };
}
/**
* Interacts with the deployed minter contract to refresh the price and mint an NFT.
* @param minter The deployed PythNftMinter instance.
*/
async function interactWithMinter(minter: PythNftMinterInstance) {
console.log(`\n--- Interacting with PythNftMinter at ${minter.address} ---`);
// 1. Refresh the price from the FTSO on the minter contract itself.
console.log("\nCalling refresh() to update the price from the FTSO...");
const refreshResult = await minter.refresh();
console.log(`Refresh transaction successful! Hash: ${refreshResult.tx}`);
// 2. Fetch and log the latest price data from the minter contract.
const latestPriceData = await minter.getPriceUnsafe(PYTH_PRICE_ID);
logFtsoPriceData("Fetched FTSO price data from minter", latestPriceData);
// 3. Calculate the required fee and mint the NFT.
console.log(
"\nCalculating $1 worth of native token using the refreshed price...",
);
const price = BigInt(latestPriceData.price.toString());
const expo = BigInt(latestPriceData.expo.toString());
const ether = 10n ** 18n;
const ten = 10n;
const absoluteExpo = expo < 0n ? -expo : expo;
const assetPrice18Decimals = (price * ether) / ten ** absoluteExpo;
const oneDollarInWei = (ether * ether) / assetPrice18Decimals;
console.log(`Required payment in wei: ${oneDollarInWei.toString()}`);
console.log("\nSubmitting mint() transaction...");
const mintResult = await minter.mint({ value: oneDollarInWei.toString() });
console.log(`NFT minted successfully! Hash: ${mintResult.tx}`);
const tokenCounter = await minter.getTokenCounter();
console.log(`Total NFTs minted: ${tokenCounter.toString()}`);
}
/**
* Logs the FTSO price data in a human-readable format.
*/
function logFtsoPriceData(label: string, data: PriceData) {
const { price, expo, publishTime } = data;
const priceStr = price.toString();
const expoStr = expo.toString();
const timestamp = Number(publishTime.toString()) * 1000;
const decimalPrice = Number(priceStr) * 10 ** Number(expoStr);
console.log(`\n${label}:`);
console.log(` - Raw Price: ${priceStr}`);
console.log(` - Exponent: ${expoStr}`);
console.log(` - Adjusted Price: ${decimalPrice.toFixed(4)}`);
console.log(` - Publish Time: ${new Date(timestamp).toISOString()}`);
}
async function main() {
console.log("🚀 Starting NFT Minter Management Script 🚀");
const { minter } = await deployMinter();
await interactWithMinter(minter);
console.log("\n🎉 Script finished successfully! 🎉");
}
void main()
.then(() => {
process.exit(0);
})
.catch((error) => {
console.error(error);
process.exit(1);
});
FtsoApi3Adapter
The FtsoApi3Adapter
implements the IApi3ReaderProxy
interface. The example is a PriceGuesser
prediction market that uses the FTSO price to settle bets.
Api3Example.sol
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;
import {FtsoApi3AdapterLibrary} from "@flarenetwork/ftso-adapters/contracts/coston2/Api3Adapter.sol";
/**
* @title PriceGuesser
* @notice A simple prediction market where users bet on a future asset price.
* @dev This contract uses an API3-compatible price feed to settle the market.
* All bets are made in the native network token (e.g., C2FLR).
*/
contract PriceGuesser {
// --- Adapter State ---
bytes21 public immutable ftsoFeedId;
string public descriptionText;
uint256 public immutable maxAgeSeconds;
// The PriceGuesser is now responsible for storing the cached price data.
FtsoApi3AdapterLibrary.DataPoint private _latestDataPoint;
// --- Prediction Market State ---
uint256 public immutable strikePrice;
uint256 public immutable expiryTimestamp;
uint256 public totalBetsAbove;
uint256 public totalBetsBelow;
mapping(address => uint256) public betsAbove;
mapping(address => uint256) public betsBelow;
enum Outcome {
Unsettled,
Above,
Below
}
Outcome public outcome;
mapping(address => bool) public hasClaimed;
// --- Errors ---
error BettingIsClosed();
error RoundNotSettledYet();
error RoundAlreadySettled();
error NothingToClaim();
error AmountIsZero();
// --- Events ---
event BetPlaced(address indexed user, bool isBetAbove, uint256 amount);
event MarketSettled(Outcome outcome, int256 finalPrice);
event WinningsClaimed(address indexed user, uint256 amount);
constructor(
bytes21 _ftsoFeedId,
string memory _description,
uint256 _maxAgeSeconds,
uint256 _strikePrice,
uint256 _durationSeconds
) {
// Initialize the adapter configuration state.
ftsoFeedId = _ftsoFeedId;
descriptionText = _description;
maxAgeSeconds = _maxAgeSeconds;
// Initialize the prediction market state.
strikePrice = _strikePrice;
expiryTimestamp = block.timestamp + _durationSeconds;
}
// --- Public Refresh Function ---
function refresh() external {
// Call the library's logic, passing this contract's state to be updated.
FtsoApi3AdapterLibrary.refresh(_latestDataPoint, ftsoFeedId);
}
function read() public view returns (int224, uint32) {
// Call the library's logic, passing this contract's state to be read.
return FtsoApi3AdapterLibrary.read(_latestDataPoint, maxAgeSeconds);
}
// --- User Functions ---
/**
* @notice Places a bet that the asset price will be >= the strike price at expiry.
*/
function betAbove() external payable {
if (block.timestamp >= expiryTimestamp) revert BettingIsClosed();
if (msg.value == 0) revert AmountIsZero();
betsAbove[msg.sender] += msg.value;
totalBetsAbove += msg.value;
emit BetPlaced(msg.sender, true, msg.value);
}
/**
* @notice Places a bet that the asset price will be < the strike price at expiry.
*/
function betBelow() external payable {
if (block.timestamp >= expiryTimestamp) revert BettingIsClosed();
if (msg.value == 0) revert AmountIsZero();
betsBelow[msg.sender] += msg.value;
totalBetsBelow += msg.value;
emit BetPlaced(msg.sender, false, msg.value);
}
/**
* @notice Settles the market by reading the final price from the oracle.
* @dev Anyone can call this after the expiry timestamp has passed.
*/
function settle() external {
if (block.timestamp < expiryTimestamp) revert RoundNotSettledYet();
if (outcome != Outcome.Unsettled) revert RoundAlreadySettled();
// Call this contract's own public read function.
(int224 finalPrice, ) = read();
if (int256(finalPrice) >= int256(strikePrice)) {
outcome = Outcome.Above;
} else {
outcome = Outcome.Below;
}
emit MarketSettled(outcome, finalPrice);
}
/**
* @notice Allows a winning user to claim their share of the prize pool.
*/
function claimWinnings() external {
if (outcome == Outcome.Unsettled) revert RoundNotSettledYet();
if (hasClaimed[msg.sender]) revert NothingToClaim();
uint256 winnings = 0;
if (outcome == Outcome.Above) {
// User wins if they bet in the "Above" pool.
uint256 userBet = betsAbove[msg.sender];
if (userBet > 0 && totalBetsAbove > 0) {
// Winnings = (userBet / totalWinningPool) * totalLosingPool
winnings = (userBet * totalBetsBelow) / totalBetsAbove;
// Add their original bet back.
winnings += userBet;
}
} else {
// outcome == Outcome.Below
// User wins if they bet in the "Below" pool.
uint256 userBet = betsBelow[msg.sender];
if (userBet > 0 && totalBetsBelow > 0) {
winnings = (userBet * totalBetsAbove) / totalBetsBelow;
winnings += userBet;
}
}
if (winnings == 0) revert NothingToClaim();
hasClaimed[msg.sender] = true;
payable(msg.sender).transfer(winnings);
emit WinningsClaimed(msg.sender, winnings);
}
}
api3Example.ts
import { artifacts, run, web3 } from "hardhat";
import { PriceGuesserInstance } from "../../typechain-types";
// --- Configuration ---
const PriceGuesser: PriceGuesserInstance = artifacts.require("PriceGuesser");
const FTSO_FEED_ID = "0x01464c522f55534400000000000000000000000000";
const DESCRIPTION = "FTSOv2 FLR/USD adapted for API3";
const MAX_AGE_SECONDS = 3600;
const STRIKE_PRICE_USD = 0.025;
const ROUND_DURATION_SECONDS = 300;
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function deployContracts(): Promise<{ guesser: PriceGuesserInstance }> {
const strikePriceWei = BigInt(STRIKE_PRICE_USD * 1e18);
const guesserArgs: (string | number)[] = [
FTSO_FEED_ID,
DESCRIPTION,
MAX_AGE_SECONDS,
strikePriceWei.toString(),
ROUND_DURATION_SECONDS,
];
console.log("\nDeploying integrated PriceGuesser contract with arguments:");
console.log(` - FTSO Feed ID: ${guesserArgs[0]}`);
console.log(` - Description: ${guesserArgs[1]}`);
console.log(` - Max Age (seconds): ${guesserArgs[2]}`);
console.log(` - Strike Price: ${STRIKE_PRICE_USD} (${guesserArgs[3]} wei)`);
console.log(` - Round Duration: ${guesserArgs[4]} seconds`);
const guesser = await PriceGuesser.new(
...(guesserArgs as [string, string, number, string, number]),
);
console.log("\n✅ PriceGuesser deployed to:", guesser.address);
try {
console.log("\nVerifying PriceGuesser on block explorer...");
await run("verify:verify", {
address: guesser.address,
constructorArguments: guesserArgs,
});
console.log("PriceGuesser verification successful.");
} catch (e: unknown) {
if (e instanceof Error) {
console.error("PriceGuesser verification failed:", e.message);
} else {
console.error("An unknown error occurred during verification:", e);
}
}
return { guesser };
}
async function interactWithMarket(guesser: PriceGuesserInstance) {
const accounts = await web3.eth.getAccounts();
const deployer = accounts[0];
const bettorAbove = accounts.length > 1 ? accounts[1] : deployer;
const bettorBelow = accounts.length > 2 ? accounts[2] : deployer;
const betAmountAbove = 10n * 10n ** 18n;
const betAmountBelow = 20n * 10n ** 18n;
console.log(`\n--- Simulating Prediction Market ---`);
console.log(` - Deployer/Settler: ${deployer}`);
console.log(` - Bettor "Above": ${bettorAbove}`);
console.log(` - Bettor "Below": ${bettorBelow}`);
console.log("\nStep 1: Bettors are placing their bets...");
await guesser.betAbove({
from: bettorAbove,
value: betAmountAbove.toString(),
});
console.log(
` - Bettor "Above" placed ${web3.utils.fromWei(betAmountAbove.toString())} tokens.`,
);
await guesser.betBelow({
from: bettorBelow,
value: betAmountBelow.toString(),
});
console.log(
` - Bettor "Below" placed ${web3.utils.fromWei(betAmountBelow.toString())} tokens.`,
);
console.log(
`\nStep 2: Betting round is live. Waiting ${ROUND_DURATION_SECONDS} seconds for it to expire...`,
);
await wait(ROUND_DURATION_SECONDS * 1000);
console.log(" - The betting round has now expired.");
console.log(
"\nStep 3: Refreshing the FTSO price on the contract post-expiry...",
);
await guesser.refresh({ from: deployer });
console.log(" - Price has been updated on the PriceGuesser contract.");
console.log("\nStep 4: Settling the prediction market...");
const settleTx = await guesser.settle({ from: deployer });
const settledEvent = settleTx.logs.find((e) => e.event === "MarketSettled");
const finalPrice = BigInt(settledEvent.args.finalPrice.toString());
const outcome = Number(settledEvent.args.outcome);
const finalPriceFormatted = Number(finalPrice / 10n ** 14n) / 10000;
const outcomeString = outcome === 1 ? "ABOVE" : "BELOW";
console.log(
`✅ Market settled! Final Price: ${finalPriceFormatted.toFixed(4)}`,
);
console.log(`✅ Outcome: The price was ${outcomeString} the strike price.`);
console.log("\nStep 5: Distributing winnings...");
const [winner, loser] =
outcome === 1 ? [bettorAbove, bettorBelow] : [bettorBelow, bettorAbove];
const prizePool = outcome === 1 ? betAmountBelow : betAmountAbove;
const winnerBet = outcome === 1 ? betAmountAbove : betAmountBelow;
if (prizePool > 0n || winnerBet > 0n) {
console.log(` - Attempting to claim for WINNER ("${outcomeString}")`);
await guesser.claimWinnings({ from: winner });
const totalWinnings = winnerBet + prizePool;
console.log(
` - WINNER claimed their prize of ${web3.utils.fromWei(totalWinnings.toString())} tokens.`,
);
} else {
console.log(" - WINNER's pool won, but no bets were placed to claim.");
}
if (winner !== loser) {
try {
await guesser.claimWinnings({ from: loser });
} catch (error: unknown) {
if (error instanceof Error && error.message.includes("NothingToClaim")) {
console.log(
" - LOSER correctly failed to claim winnings as expected.",
);
} else if (error instanceof Error) {
console.error(
" - An unexpected error occurred for the loser:",
error.message,
);
} else {
console.error(" - An unknown error occurred for the loser:", error);
}
}
} else {
console.log(
" - Skipping loser claim attempt as winner and loser are the same account.",
);
}
}
async function main() {
console.log("🚀 Starting Prediction Market Management Script 🚀");
const { guesser } = await deployContracts();
await interactWithMarket(guesser);
console.log("\n🎉 Script finished successfully! 🎉");
}
void main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
FtsoBandAdapter
The FtsoBandAdapter
implements Band Protocol's IStdReference
interface. The example is a PriceTriggeredSafe
that locks withdrawals during high market volatility, detected by checking a basket of FTSO prices.
BandExample.sol
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;
import {IStdReference, FtsoBandAdapterLibrary} from "@flarenetwork/ftso-adapters/contracts/coston2/BandAdapter.sol";
/**
* @title PriceTriggeredSafe
* @notice A vault that automatically pauses withdrawals during high market volatility.
* @dev Uses the FtsoBandAdapterLibrary to check a basket of asset prices.
*/
contract PriceTriggeredSafe {
// --- State Variables ---
address public owner;
bool public isLocked;
mapping(address => uint256) public balances;
// Stores the last checked price for each asset (base symbol => rate).
mapping(string => uint256) public lastCheckedPrices;
// The volatility threshold in Basis Points (BIPS). 1000 BIPS = 10%.
uint256 public constant VOLATILITY_THRESHOLD_BIPS = 1000;
// --- Events ---
event Deposited(address indexed user, uint256 amount);
event Withdrawn(address indexed user, uint256 amount);
event MarketLocked(
string indexed volatileAsset,
uint256 oldPrice,
uint256 newPrice
);
event MarketUnlocked();
modifier onlyOwner() {
require(msg.sender == owner, "Only owner");
_;
}
modifier whenNotLocked() {
require(!isLocked, "Safe is currently locked due to volatility");
_;
}
constructor() {
owner = msg.sender;
}
// --- User Functions ---
function deposit() external payable {
balances[msg.sender] += msg.value;
emit Deposited(msg.sender, msg.value);
}
function withdraw(uint256 _amount) external whenNotLocked {
require(balances[msg.sender] >= _amount, "Insufficient balance");
balances[msg.sender] -= _amount;
payable(msg.sender).transfer(_amount);
emit Withdrawn(msg.sender, _amount);
}
// --- Safety Mechanism ---
/**
* @notice Checks a basket of assets for volatility. If any price moves more
* than the threshold, it locks the contract.
* @dev Can be called by anyone, intended for a keeper bot.
*/
function checkMarketVolatility() external {
string[] memory bases = new string[](3);
bases[0] = "FLR";
bases[1] = "BTC";
bases[2] = "ETH";
string[] memory quotes = new string[](3);
quotes[0] = "USD";
quotes[1] = "USD";
quotes[2] = "USD";
// Use the library to get all prices in a single call.
IStdReference.ReferenceData[] memory prices = FtsoBandAdapterLibrary
.getReferenceDataBulk(bases, quotes);
for (uint i = 0; i < prices.length; i++) {
string memory base = bases[i];
uint256 lastPrice = lastCheckedPrices[base];
uint256 currentPrice = prices[i].rate;
// If this is the first time we're checking, just record the price.
if (lastPrice == 0) {
lastCheckedPrices[base] = currentPrice;
continue;
}
// Check for significant price movement.
uint256 priceDiff = (lastPrice > currentPrice)
? lastPrice - currentPrice
: currentPrice - lastPrice;
uint256 changeBIPS = (priceDiff * 10000) / lastPrice;
if (changeBIPS > VOLATILITY_THRESHOLD_BIPS) {
isLocked = true;
emit MarketLocked(base, lastPrice, currentPrice);
// Stop checking on the first sign of high volatility.
return;
}
// If the market is stable, update the last checked price.
lastCheckedPrices[base] = currentPrice;
}
}
/**
* @notice Manually unlocks the safe after a volatility event.
*/
function unlockSafe() external onlyOwner {
isLocked = false;
emit MarketUnlocked();
}
}
bandExample.ts
import { artifacts, network, run, web3 } from "hardhat";
import { PriceTriggeredSafeInstance } from "../../typechain-types";
// --- Configuration ---
const PriceTriggeredSafe: PriceTriggeredSafeInstance =
artifacts.require("PriceTriggeredSafe");
// --- Helper Functions ---
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Fetches the prices stored in the contract and logs them to the console.
* @param safe The deployed PriceTriggeredSafe instance.
* @param label A descriptive label for the log output.
*/
async function logFTSOPrices(safe: PriceTriggeredSafeInstance, label: string) {
console.log(`\n--- ${label} ---`);
const assets = ["FLR", "BTC", "ETH"];
for (const asset of assets) {
const priceWei = await safe.lastCheckedPrices(asset);
if (priceWei.toString() === "0") {
console.log(` - ${asset}/USD: (not yet recorded)`);
} else {
const priceFormatted = web3.utils.fromWei(priceWei.toString(), "ether");
console.log(` - ${asset}/USD: ${Number(priceFormatted).toFixed(4)}`);
}
}
}
/**
* Deploys the PriceTriggeredSafe contract.
*/
async function deployContracts(): Promise<{
safe: PriceTriggeredSafeInstance;
}> {
console.log("\nDeploying PriceTriggeredSafe contract...");
const safe = await PriceTriggeredSafe.new();
console.log("\n✅ PriceTriggeredSafe deployed to:", safe.address);
if (network.name !== "hardhat" && network.name !== "localhost") {
try {
console.log("\nVerifying PriceTriggeredSafe on block explorer...");
await run("verify:verify", {
address: safe.address,
constructorArguments: [],
});
console.log("Safe verification successful.");
} catch (e: unknown) {
if (e instanceof Error) {
console.error("Safe verification failed:", e.message);
} else {
console.error("An unknown error occurred during verification:", e);
}
}
}
return { safe };
}
/**
* Simulates a user and keeper interacting with the PriceTriggeredSafe.
* @param safe The deployed PriceTriggeredSafe instance.
*/
async function interactWithSafe(safe: PriceTriggeredSafeInstance) {
const [user] = await web3.eth.getAccounts();
const depositAmount = 2n * 10n ** 18n;
const withdrawalAmount = 1n * 10n ** 18n;
console.log(`\n--- Simulating Price-Triggered Safe flow ---`);
console.log(` - User/Owner/Keeper Account: ${user}`);
// Step 1: User deposits funds.
console.log(
`\nStep 1: User deposits ${web3.utils.fromWei(depositAmount.toString())} native tokens...`,
);
await safe.deposit({ from: user, value: depositAmount.toString() });
console.log("✅ Deposit successful.");
// Step 2: Set the baseline prices.
console.log("\nStep 2: Performing initial price check to set baseline...");
await safe.checkMarketVolatility({ from: user });
console.log("✅ Baseline prices recorded on-chain.");
await logFTSOPrices(safe, "Baseline Prices Recorded");
// Step 3: User withdraws while unlocked.
console.log(
`\nStep 3: User withdraws ${web3.utils.fromWei(withdrawalAmount.toString())} tokens...`,
);
await safe.withdraw(withdrawalAmount.toString(), { from: user });
console.log("✅ Initial withdrawal successful.");
// Step 4: Wait for market prices to change.
const waitTimeSeconds = 180; // 3 minutes
console.log(
`\nStep 4: Waiting ${waitTimeSeconds} seconds for market prices to update on the FTSO...`,
);
await wait(waitTimeSeconds * 1000);
// Step 5: Perform the second volatility check.
console.log("\nStep 5: Performing second volatility check...");
await safe.checkMarketVolatility({ from: user });
await logFTSOPrices(safe, "Updated FTSO Prices After Waiting");
// Step 6: Check the contract's state and react.
const isLocked = await safe.isLocked();
if (isLocked) {
console.log("\n🚨 VOLATILITY DETECTED! The safe is now LOCKED.");
console.log(
"\nStep 6a: User attempts to withdraw while locked (should fail)...",
);
try {
await safe.withdraw(withdrawalAmount.toString(), { from: user });
} catch (error: unknown) {
if (
error instanceof Error &&
error.message.includes("Safe is currently locked due to volatility")
) {
console.log("✅ Transaction correctly reverted as expected.");
} else {
throw error;
}
}
console.log("\nStep 6b: Owner unlocks the safe...");
await safe.unlockSafe({ from: user });
console.log("✅ Safe has been manually unlocked.");
console.log(
"\nStep 6c: User attempts to withdraw again (should succeed)...",
);
await safe.withdraw(withdrawalAmount.toString(), { from: user });
console.log("✅ Withdrawal successful after unlocking.");
} else {
console.log("\n✅ MARKET STABLE. The safe remains unlocked.");
console.log("\nStep 6: User confirms they can still withdraw...");
await safe.withdraw(withdrawalAmount.toString(), { from: user });
console.log("✅ Subsequent withdrawal successful.");
}
const finalBalance = await safe.balances(user);
console.log(
`\nFinal user balance in safe: ${web3.utils.fromWei(finalBalance.toString())} CFLR`,
);
}
async function main() {
console.log("🚀 Starting Price-Triggered Safe Management Script 🚀");
const { safe } = await deployContracts();
await interactWithSafe(safe);
console.log("\n🎉 Script finished successfully! 🎉");
}
void main()
.then(() => {
process.exit(0);
})
.catch((error) => {
console.error(error);
process.exit(1);
});
FtsoChronicleAdapter
The FtsoChronicleAdapter
implements the IChronicle
interface. The example is a DynamicNftMinter
that mints NFTs of different tiers based on the live FTSO asset price.
ChronicleExample.sol
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;
import {FtsoChronicleAdapterLibrary} from "@flarenetwork/ftso-adapters/contracts/coston2/ChronicleAdapter.sol";
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
interface IChronicle {
/// @notice Returns the oracle's identifier.
/// @return wat The oracle's identifier.
function wat() external view returns (bytes32 wat);
/// @notice Returns the oracle's current value.
/// @dev Reverts if no value set.
/// @return value The oracle's current value.
function read() external view returns (uint value);
/// @notice Returns the oracle's current value and its age.
/// @dev Reverts if no value set.
/// @return value The oracle's current value.
/// @return age The value's age.
function readWithAge() external view returns (uint value, uint age);
/// @notice Returns the oracle's current value.
/// @return isValid True if value exists, false otherwise.
/// @return value The oracle's current value if it exists, zero otherwise.
function tryRead() external view returns (bool isValid, uint value);
/// @notice Returns the oracle's current value and its age.
/// @return isValid True if value exists, false otherwise.
/// @return value The oracle's current value if it exists, zero otherwise.
/// @return age The value's age if value exists, zero otherwise.
function tryReadWithAge()
external
view
returns (bool isValid, uint value, uint age);
}
/**
* @title DynamicNftMinter
* @notice Mints an NFT with a tier based on a live asset price from the FTSO.
* @dev This contract implements the IChronicle interface and uses the FtsoChronicleAdapterLibrary.
*/
contract DynamicNftMinter is IChronicle, ERC721 {
// --- Adapter State ---
bytes21 public immutable ftsoFeedId;
bytes32 public immutable wat; // Chronicle's name for the feed identifier.
// The contract is now responsible for storing the cached price data.
FtsoChronicleAdapterLibrary.DataPoint private _latestDataPoint;
// --- Minter-Specific State ---
enum Tier {
None,
Bronze,
Silver,
Gold
}
uint256 private _nextTokenId;
uint256 public constant MINT_FEE = 0.1 ether;
mapping(uint256 => Tier) public tokenTiers; // Stores the tier for each minted NFT.
// Price boundaries for tiers (with 18 decimals)
uint256 public constant SILVER_TIER_PRICE = 0.02 ether; // $0.02
uint256 public constant GOLD_TIER_PRICE = 0.03 ether; // $0.03
event NftMinted(address indexed owner, uint256 indexed tokenId, Tier tier);
constructor(
bytes21 _ftsoFeedId,
string memory _description // e.g., "FTSO FLR/USD"
) ERC721("Dynamic Tier NFT", "DTN") {
ftsoFeedId = _ftsoFeedId;
wat = keccak256(abi.encodePacked(_description));
}
// --- Public Adapter Functions ---
function refresh() external {
FtsoChronicleAdapterLibrary.refresh(_latestDataPoint, ftsoFeedId);
}
// --- IChronicle Interface Implementation ---
function read() public view override returns (uint256) {
return FtsoChronicleAdapterLibrary.read(_latestDataPoint);
}
function readWithAge() public view override returns (uint256, uint256) {
return FtsoChronicleAdapterLibrary.readWithAge(_latestDataPoint);
}
function tryRead() public view override returns (bool, uint256) {
return FtsoChronicleAdapterLibrary.tryRead(_latestDataPoint);
}
function tryReadWithAge()
public
view
override
returns (bool, uint256, uint256)
{
return FtsoChronicleAdapterLibrary.tryReadWithAge(_latestDataPoint);
}
// --- Core Minter Functions ---
function mint() external payable {
require(msg.value >= MINT_FEE, "Insufficient mint fee");
// Use the safe tryRead() to get the price.
(bool isValid, uint256 currentPrice) = tryRead();
require(isValid, "Price feed is not available, please refresh");
Tier mintedTier;
if (currentPrice >= GOLD_TIER_PRICE) {
mintedTier = Tier.Gold;
} else if (currentPrice >= SILVER_TIER_PRICE) {
mintedTier = Tier.Silver;
} else {
mintedTier = Tier.Bronze;
}
uint256 newTokenId = _nextTokenId++;
_safeMint(msg.sender, newTokenId);
tokenTiers[newTokenId] = mintedTier;
emit NftMinted(msg.sender, newTokenId, mintedTier);
}
}
chronicleExample.ts
import { artifacts, run, web3 } from "hardhat";
import { DynamicNftMinterInstance } from "../../typechain-types";
// --- Configuration ---
const DynamicNftMinter: DynamicNftMinterInstance =
artifacts.require("DynamicNftMinter");
// FTSO Feed ID for FLR / USD (bytes21)
const FTSO_FEED_ID = "0x01464c522f55534400000000000000000000000000";
// A human-readable description for the Chronicle 'wat' identifier.
const DESCRIPTION = "FTSO FLR/USD";
/**
* Deploys the integrated DynamicNftMinter contract.
*/
async function deployContracts(): Promise<{
minter: DynamicNftMinterInstance;
}> {
const minterArgs: string[] = [FTSO_FEED_ID, DESCRIPTION];
console.log(
"\nDeploying integrated DynamicNftMinter contract with arguments:",
);
console.log(` - FTSO Feed ID: ${minterArgs[0]}`);
console.log(` - Description: ${minterArgs[1]}`);
const minter = await DynamicNftMinter.new(...minterArgs);
console.log("\n✅ DynamicNftMinter deployed to:", minter.address);
try {
console.log("\nVerifying DynamicNftMinter on block explorer...");
await run("verify:verify", {
address: minter.address,
constructorArguments: minterArgs,
});
console.log("Minter verification successful.");
} catch (e: unknown) {
if (e instanceof Error) {
console.error("Minter verification failed:", e.message);
} else {
console.error("An unknown error occurred during verification:", e);
}
}
return { minter };
}
/**
* Simulates a user minting a dynamic NFT.
* @param minter The deployed DynamicNftMinter instance.
*/
async function interactWithMinter(minter: DynamicNftMinterInstance) {
const accounts = await web3.eth.getAccounts();
const user = accounts[0]; // The account that will mint the NFT.
const mintFee = BigInt(await minter.MINT_FEE());
console.log(`\n--- Simulating Dynamic NFT Mint ---`);
console.log(` - Minter Contract: ${minter.address}`);
console.log(` - User Account: ${user}`);
console.log(` - Mint Fee: ${web3.utils.fromWei(mintFee.toString())} CFLR`);
// Step 1: Refresh the FTSO price on the contract.
console.log("\nStep 1: Refreshing the FTSO price on the contract...");
await minter.refresh({ from: user });
console.log("✅ Price has been updated on the minter contract.");
// Step 2: Read the price to see what tier the NFT will be.
console.log("\nStep 2: Reading the current price from the contract...");
const tryReadResult = await minter.tryRead();
const isValid = tryReadResult[0];
const currentPrice = tryReadResult[1];
if (!isValid) {
throw new Error("Price feed is not valid after refresh. Exiting.");
}
const priceFormatted =
Number(BigInt(currentPrice.toString()) / 10n ** 16n) / 100;
console.log(`✅ Current asset price is ${priceFormatted.toFixed(4)} USD.`);
// Step 3: User mints the NFT.
console.log("\nStep 3: Submitting mint transaction...");
const mintTx = await minter.mint({ from: user, value: mintFee.toString() });
// Step 4: Decode the event to find out which tier was minted.
const mintedEvent = mintTx.logs.find((e) => e.event === "NftMinted");
if (!mintedEvent) {
throw new Error("NftMinted event not found in transaction logs.");
}
const tokenId = mintedEvent.args.tokenId.toString();
const tierEnum = Number(mintedEvent.args.tier);
const tierToString = (tierNum: number): string => {
if (tierNum === 1) return "Bronze";
if (tierNum === 2) return "Silver";
if (tierNum === 3) return "Gold";
return "None";
};
console.log(`✅ Mint successful! Transaction Hash: ${mintTx.tx}`);
console.log(` - Token ID: ${tokenId}`);
console.log(` - Minted Tier: ${tierToString(tierEnum)}`);
}
async function main() {
console.log("🚀 Starting Dynamic NFT Minter Management Script 🚀");
const { minter } = await deployContracts();
await interactWithMinter(minter);
console.log("\n🎉 Script finished successfully! 🎉");
}
void main()
.then(() => {
process.exit(0);
})
.catch((error) => {
console.error(error);
process.exit(1);
});