An on-chain fixed-income bond protocol built with Solidity and Foundry. Issuers create bond products; investors purchase notes that earn a simple-interest coupon and are redeemable at maturity.
| Contract | Address |
|---|---|
FixedIncomeFactory |
0xA7c59f010700930003b33aB25a7a0679C860f29c |
Explorer: explorer.hoodi.ethpandaops.io
| Contract | Address |
|---|---|
FixedIncomeFactory |
0xfd3FFdf299b8E73e702A2e592Cb9B4D4ff9E44A1 |
Explorer: hashscan.io/testnet
The factory automatically deploys
HoldingNFTandFixedIncomeEventEmitteron construction.
FixedIncomeFactory
|-- owns --> HoldingNFT
|-- owns --> FixedIncomeEventEmitter
|-- deploys --> FixedIncomeProduct (one per bond issuance)
The single entry point for the protocol. Deploying the factory also deploys the shared HoldingNFT registry and FixedIncomeEventEmitter. Any address can call createProduct to become the issuer of a new bond.
Key responsibilities:
- Validate product parameters (
faceValue > 0,maxSupply > 0,maturity > now) - Deploy a
FixedIncomeProductand set the caller as its owner - Authorize the new product on
HoldingNFTandFixedIncomeEventEmitter - Maintain a paginated registry of all deployed products (
allProducts,getProducts)
One contract per bond issuance. Holds the payment token reserve and enforces all bond mechanics.
Immutable parameters (set at construction, never change):
| Parameter | Description |
|---|---|
paymentToken |
ERC-20 token used for purchase and redemption (e.g. USDC) |
faceValue |
Price per note in paymentToken units |
couponBps |
Annual coupon rate in basis points (500 = 5%) |
maturity |
Unix timestamp after which notes can be redeemed |
maxSupply |
Maximum notes that can ever be sold |
issuedAt |
Block timestamp at deployment |
Mutable state:
totalNotesSold/totalNotesRedeemed-- cumulative countersinvestorNoteCount-- per-address note balancenetAssetValue-- issuer-reported NAV (informational only)
Coupon math is delegated to the CouponMath library. All interest is simple (linear), not compound, on a 365-day year basis:
coupon = faceValue x couponBps x elapsedSeconds
-----------------------------------------
(365 days in seconds) x 10_000
A shared ERC-721 registry (name: "FixedIncomeHolding", symbol: "FIH"). Each token represents ownership of exactly one note in a FixedIncomeProduct. Only factory-authorized products can mint or burn tokens.
Each token stores a HoldingInfo struct:
issuingProduct-- which product the note belongs tofaceValue-- face value at time of purchasepurchasedAt-- block timestamp of the purchase
A protocol-level event sink shared across all products. Emits canonical events that indexers can subscribe to from a single contract address, regardless of how many products exist.
Events emitted: ProductCreated, NotePurchased, NoteRedeemed, NAVUpdated.
Only the factory and authorized products can call the emit functions.
A pure math library (src/libraries/CouponMath.sol) with three functions:
| Function | Description |
|---|---|
accruedCoupon(principal, rateBps, start, end) |
Coupon for an arbitrary time window |
termCoupon(principal, rateBps, issuedAt, maturity) |
Full-term coupon at maturity |
accruedCouponAt(principal, rateBps, issuedAt, maturity, queryTime) |
Accrued coupon up to queryTime, capped at maturity |
The issuer calls FixedIncomeFactory.createProduct(...):
address product = factory.createProduct(
address(usdc), // paymentToken
1_000e6, // faceValue: 1,000 USDC per note
500, // couponBps: 5% annual
block.timestamp + 365 days, // maturity
100 // maxSupply: up to 100 notes
);The factory deploys a FixedIncomeProduct, authorizes it on HoldingNFT and the event emitter, and emits ProductCreated.
The investor approves the product contract for faceValue tokens, then calls purchase():
usdc.approve(product, 1_000e6);
uint256 tokenId = FixedIncomeProduct(product).purchase();faceValuetokens are transferred from the investor to the product contract (held as reserve)- A
HoldingNFTis minted to the investor totalNotesSoldandinvestorNoteCount[investor]are incrementedNotePurchasedis emitted on both the product and the event emitter
Principal is already held in the contract from purchases. The issuer must deposit the coupon portion before investors can redeem:
uint256 coupon = product.couponPerNote(); // e.g. 50 USDC for a 1,000 USDC / 5% / 1yr bond
usdc.approve(product, coupon * notesOutstanding);
product.depositReserve(coupon * notesOutstanding);The reserve required to cover all outstanding notes is readable at any time:
product.requiredReserve(); // totalNotesSold * (faceValue + couponPerNote)Once block.timestamp >= maturity and the reserve is funded, the investor redeems their token:
product.redeem(tokenId);- The
HoldingNFTis burned faceValue + couponPerNote()tokens are transferred to the investortotalNotesRedeemedandinvestorNoteCount[investor]are updatedNoteRedeemedis emitted on both the product and the event emitter
Redemption reverts if:
- Called before
maturity--NotMatured() - Caller is not the token owner --
NotTokenOwner() - Reserve balance is below the payout --
InsufficientReserve()
At any point, an investor's current exposure can be read:
IFixedIncomeProduct.InvestorPosition memory pos = product.getInvestorPosition(investor);
// pos.noteCount -- number of notes held
// pos.principalValue -- noteCount * faceValue
// pos.currentAccruedCoupon -- coupon accrued across all notes up to now
// pos.redemptionValue -- full payout if redeemed today (post-maturity)Full bond state snapshot:
IFixedIncomeProduct.BondInfo memory info = product.getBondInfo();git clone <repo>
cd fixed-income-evm
forge installforge build
forge test -vvAll 61 tests should pass (44 integration in FixedIncome.t.sol + 17 unit/fuzz in CouponMath.t.sol).
The demo script exercises the full bond lifecycle -- create, purchase, warp to maturity, redeem -- using vm.startPrank and vm.warp. No running node or private key is needed.
forge script script/Demo.s.sol --tc Demo -vvDeploy the factory (which automatically deploys HoldingNFT and FixedIncomeEventEmitter):
forge script script/Deploy.s.sol \
--rpc-url <RPC_URL> \
--private-key <PRIVATE_KEY> \
--broadcast \
--verifyHedera testnet requires the --legacy flag (no EIP-1559 support):
forge script script/Deploy.s.sol \
--rpc-url https://testnet.hashio.io/api \
--private-key <PRIVATE_KEY> \
--broadcast \
--legacyThe script logs the three deployed contract addresses:
FixedIncomeFactory : 0x...
HoldingNFT : 0x...
FixedIncomeEventEmitter: 0x...
After deployment, any issuer can create a product on-chain via cast:
cast send <FACTORY_ADDRESS> \
"createProduct(address,uint256,uint256,uint256,uint256)" \
<PAYMENT_TOKEN> <FACE_VALUE> <COUPON_BPS> <MATURITY_UNIX> <MAX_SUPPLY> \
--rpc-url <RPC_URL> \
--private-key <ISSUER_PRIVATE_KEY>src/
FixedIncomeFactory.sol # Protocol entry point
FixedIncomeProduct.sol # Bond issuance logic
HoldingNFT.sol # ERC-721 note registry
FixedIncomeEventEmitter.sol # Protocol-level event sink
interfaces/
IFixedIncomeProduct.sol
IHoldingNFT.sol
IFixedIncomeEventEmitter.sol
libraries/
CouponMath.sol # Simple-interest coupon math
test/
FixedIncome.t.sol # Integration tests
CouponMath.t.sol # Unit + fuzz tests for the math library
script/
Deploy.s.sol # Production deployment
Demo.s.sol # End-to-end lifecycle simulation
A web interface for the protocol lives in frontend/. Built with React, Vite, wagmi v2, and RainbowKit.
- Connect wallet (MetaMask, WalletConnect, etc.)
- Browse all bond issuances — face value, APY, maturity, supply, status
- Purchase notes (ERC-20 approve + purchase in two steps)
- Redeem matured notes — token IDs resolved from on-chain events
- Issue new bonds via the factory
- Portfolio view — positions across all bonds, accrued coupon, redemption value
- Issuer panel — deposit reserve, update NAV
cd frontend
cp .env.example .env
# Fill in VITE_FACTORY_ADDRESS and VITE_WALLETCONNECT_PROJECT_ID
npm install
npm run devOpen http://localhost:5173 and connect your wallet to the Hoodi network.
| Variable | Description |
|---|---|
VITE_FACTORY_ADDRESS |
Deployed FixedIncomeFactory address |
VITE_WALLETCONNECT_PROJECT_ID |
Project ID from cloud.walletconnect.com |
- Push the repo to GitHub
- Import the project in Vercel — set Root Directory to
frontend - Add the two environment variables above in Settings → Environment Variables
- Deploy — every
git pushtriggers a redeploy automatically
MIT