Get the code
npx create-scaffold-hbar@latest --template hedera-dev/hedera-finance-templates#templates/bond-couponsOverview
A coupon is the periodic interest a bond pays its holders: a percentage of face value, on a fixed schedule, until maturity, when the final coupon is paid together with the principal. Someone has to make those payments happen on the day. In traditional finance that is a paying agent. On most EVM chains it is a keeper network such as Chainlink Automation, or a bot you run yourself — infrastructure that has to be deployed, funded and monitored, and that pays the coupons to the contract’s instructions rather than at the contract’s own initiative. On Hedera the contract can schedule itself. The Hedera Schedule Service system contract at0x16b (HIP-1215) lets a contract ask the network to call it later:
CouponBond, deployed through a factory, that pays its coupons in an HTS stablecoin.
The workflow has three actors:
- Issuer: the operator account the app runs with. Deploys the factory once, issues bonds, allocates positions, and funds each bond with stablecoin for its coupons and HBAR for its schedules
- Investors: two demo investors the app creates, and optionally your own MetaMask account. They receive coupons and principal without doing anything
- Hedera Schedule Service: calls the bond at each coupon date
- The Issuer issues a bond through the factory: face value, coupon rate per period, period length, number of coupons
- The Issuer allocates positions to investors and funds the bond
- The Issuer calls
start(), and the bond schedules coupon 1 for one period from now - At each coupon date the network calls
payCoupon(), which pays every holder and schedules the next coupon - At the last date the bond pays the final coupon and the principal, and schedules nothing further
Coupon periods are set in minutes here, from 1 to 60, so a bond’s whole life — several coupons and maturity — plays out while you watch. A real bond’s periods are months; nothing else about the mechanism changes.
Prerequisites
- Node.js 20.18.3 or later, and Yarn
- Foundry, to compile and deploy the contracts
- A Hedera testnet account from the Hedera Portal with an ECDSA key. It becomes the Issuer. JSON-RPC transactions are signed with ECDSA, so an ED25519 key cannot be used; the app says so at start.
- Testnet HBAR on that account. The factory is deployed once; each bond reserves 3 HBAR per coupon for its scheduled calls, and the desk’s setup creates a stablecoin and funds two demo accounts with 1 HBAR each.
- Optional, for Receive coupons with MetaMask: MetaMask with Hedera testnet (chain id 296) and an ECDSA testnet account holding a little HBAR for gas
Project structure
Configuration
Createpackages/nextjs/.env.local:
yarn coupons:deploy signs with HEDERA_OPERATOR_KEY rather than the scaffold’s encrypted keystore, which would ask for a password no script can answer, and regenerates packages/nextjs/contracts/deployedContracts.ts so the app finds the factory. It is idempotent: run again and it reports the factory is already deployed and exits.
You should see the factory’s address at #factory-address on the page, linked to HashScan.
Set up the desk
Press Set up desk. Two idempotent steps run:- The stablecoin. An HTS fungible token —
Coupon Desk Stablecoin(mUSD), 6 decimals, 10,000,000 minted to the Issuer as treasury. - Two demo investors. Each is a fresh ECDSA key turned into an account with
setECDSAKeyWithAlias, 1 HBAR, andsetMaxAutomaticTokenAssociations(-1)— unlimited automatic associations, so a coupon can reach them without their ever signing anything.
#setup-step-stablecoin and #setup-step-investors, the token id, and the two investors listed at #investors with their default allocations of 10 and 5 units.
Issue and fund a coupon bond
Fill in the form and press Issue bond. Every field starts with a value that works: face value in whole stablecoin, coupon rate between 0.01% and 20% of face value per period, a period of 1 to 60 minutes, and 1 to 12 coupons.BondCreated(issuer, bond), which the route reads for the new address.
A contract cannot hold an HTS token it has not associated, so the bond associates itself in its constructor and refuses to exist if that fails:
#bond-address and its contract id at #bond-contract-id, both linked to HashScan, with status CREATED at #bond-status.
Then allocate and fund:
- Allocate sets each investor’s units with
allocate(holder, units), which only the Issuer may call and only while the bond isCREATED. Positions are read back from the contract, not echoed from the form. - Fund sends the stablecoin you ask for, and separately whatever HBAR the bond is still missing. The stablecoin figure the card suggests comes from the bond’s own
stablecoinNeeded(): every remaining coupon for every holder, plus principal. The HBAR figure is not a choice — the route computes the shortfall against 3 HBAR per remaining coupon and sends exactly that.
#funding showing the bond’s stablecoin and HBAR balances against what it still needs, and Start staying disabled until both the positions and the full HBAR reserve are in place.
Schedule the coupons
Step 1: The first schedule
Press Start.start() moves the bond to RUNNING, sets coupon 1 due one period from now, and asks the schedule service for that second:
- Capacity is per second. A given consensus second can only hold so much scheduled gas, so
hasScheduleCapacityis checked first and the contract walks forward a second at a time, up toMAX_SCHEDULE_ATTEMPTS, rather than giving up on a full second. - Gas is sized, not guessed.
COUPON_GAS_BASEis 2,000,000 and each holder addsCOUPON_GAS_PER_HOLDERof 150,000. ThehasScheduleCapacityandscheduleCallpair alone measures about 1.4M gas on testnet: system-contract calls are far pricier than ordinary EVM gas. - The schedule fires a few seconds late on purpose.
SCHEDULE_FIRING_MARGINis 3 seconds, because a transaction’sblock.timestampreads slightly behind its own consensus time. Asking the network to fire exactly at the due second makespayCoupon’s ownblock.timestamp < dueTimeguard revert on the first attempt. The margin delays only when the call executes;dueTime, which the guard checks and the page shows, stays the clean period boundary.
RUNNING at #bond-status, the pending schedule’s address at #schedule-address, and the next coupon’s due time counting down at #next-coupon.
Step 2: The bond pays and reschedules itself
At the due second the network callspayCoupon(). Nobody presses anything. It computes what each holder is owed, pays them in the stablecoin, and schedules the next coupon:
units * faceValue * couponRateBps / 10_000, scaled by the stablecoin’s decimals. payCoupon is deliberately callable by anyone once the coupon is due — the schedule service is the expected caller, but the Pay now button at #pay-now calls the same function, which is how you catch up a coupon whose scheduling failed.
You should see investor balances rise at each coupon date without any action, and each payment appear in the history at #coupons. That history is read from the bond’s CouponPaid, CouponMissed and PrincipalRepaid events on the mirror node (/api/v1/contracts/{address}/results/logs), so it shows what the network recorded rather than what the app remembers.
Step 3: Maturity
The last coupon pays interest and principal in the same call, sets the status toMATURED, clears the pending schedule and schedules nothing further. stablecoinNeeded() then returns 0, because nothing more will ever be paid.
You should see status MATURED, no next coupon, and each investor holding their coupons plus units * faceValue.
When a coupon cannot be paid
The interesting cases are the ones where the bond is not in a position to pay, and none of them lose a coupon:- Underfunded. If the bond’s stablecoin balance is below what the coupon needs, it emits
CouponMissed, schedules the same coupon one period later, and leaves the coupon index where it is. Fund it and the retry pays. Nothing is skipped or silently dropped. - No capacity. If ten consecutive seconds are all full,
_schedulegives up and emitsSchedulingFailedwith the intended due time — butdueTimeis still set, so the coupon is due and Pay now pays it and schedules the next one. - Stopping. The Issuer can call
stop(), which deletes the pending schedule throughdeleteSchedule(currentSchedule)and moves the bond toSTOPPED. A schedule that cannot be deleted reverts withDeleteScheduleFailed(responseCode)rather than leaving the bond in a state its status does not describe.
Receive coupons with MetaMask
The demo investors are accounts with unlimited automatic associations, which is convenient and not how a real holder’s account usually looks. Your own wallet shows the honest path.- Connect MetaMask and switch it to Hedera testnet. The page says what to do next at
#wallet-state. - Press Add my wallet as an investor at
#add-wallet-investor. Your address joins the holders list and can be allocated units like any other. - Because your account does not associate tokens automatically, press Associate stablecoin at
#associate-stablecoin. MetaMask signs anassociate()call on the token’s own HIP-719 interface — the token address is the contract. - Allocate units to your wallet before the bond starts, and the coupons arrive in it on their own.
Going to production
- Real periods and conventions. Months rather than minutes, and a day-count convention (30/360, ACT/365) instead of a flat rate per period. Interest between coupon dates needs accrual, and a record date decides who is paid when positions move.
- Holders at scale. This bond pushes payments to at most
MAX_HOLDERS(10) holders in one call, because every transfer happens inside a single scheduled execution with a gas limit. Beyond a handful, make coupons pull-based — the bond records what each holder is owed and holders claim it — or pay in batches across several schedules. - A real stablecoin. The desk mints its own
mUSD. In production the coupon currency is an issued stablecoin with its own treasury, and the bond holds no minting power over it. - Funding monitoring. The bond pays its own schedules from its HBAR balance and its coupons from its stablecoin balance. Both need watching:
CouponMissedandSchedulingFailedare the events to alert on. - Transferable positions. Positions here are a mapping the Issuer sets. A bond whose holders trade needs the position to be a token — see the tokenised bond template for an ERC-1400 security with an allowlist, and give its holders coupons the same way.
Next steps
Hedera Schedule Service
The system contract this bond calls, with every function and its costs.
Schedule contract calls
A tutorial that builds up to a self-scheduling contract from scratch.
Scheduled transactions
What a schedule is natively, beneath the system contract.
Scaffold HBAR
The CLI and the other templates.