Lesson 5 of 6

Set a Validity Window on a Transaction

In 102.2, your transaction was valid as soon as you submitted it, and it stayed valid until the ledger no longer accepted it for some other reason.

In this lesson, you will add one small rule to that same transaction:

This transaction may be included only from slot X until slot Y.

Apollo handles this with two builder calls:

SetValidityStart(validFrom)
SetTtl(validUntil)

That is the whole code idea. The only extra work is choosing validFrom and validUntil from the current chain slot.


What the Two Numbers Mean

Cardano measures transaction validity with slot numbers. On preprod, slots advance about once per second.

Builder call

Meaning

Plain English

SetValidityStart(slot)

The transaction is invalid before this slot

"not before slot X"

SetTtl(slot)

The transaction is invalid at or after this slot

"expires at slot Y"

For a five-minute window, use the current slot as the start and add 300 slots for the end:

validFrom := int64(currentSlot)
validUntil := validFrom + 300

Prerequisites

  • Completed 102.2 with a working Apollo transaction
  • A Blockfrost API key for preprod
  • A funded preprod wallet

You do not need to run Dolos or query a local node for this lesson. The Blockfrost chain context from 102.2 can read the latest block slot.


Step 1: Start From Your 102.2 Program

Open the tx.go file you built in 102.2.

Keep the setup the same:

  • Load environment variables
  • Create the Blockfrost chain context
  • Load the wallet
  • Fetch UTxOs
  • Build, sign, and submit the transaction

The validity window belongs in step 5, right before Complete().


Step 2: Read the Current Slot

After you fetch UTxOs and before you build the transaction, ask the same Blockfrost context for the latest block slot:

currentSlot, err := bfc.LastBlockSlot()
if err != nil {
	log.Fatal(err)
}

validFrom := int64(currentSlot)
validUntil := validFrom + 300 // about 5 minutes on preprod

LastBlockSlot() returns the slot from the latest block Blockfrost knows about. Apollo's validity methods expect int64, so convert the value before passing it into the builder.


Step 3: Add the Window Before Complete()

In 102.2, the builder line looked like this:

apollob, _, err = apollob.
	AddLoadedUTxOs(utxos...).
	PayToAddressBech32(recipient, 1_000_000).
	Complete()

Add SetValidityStart(...) and SetTtl(...) before Complete():

apollob, _, err = apollob.
	AddLoadedUTxOs(utxos...).
	PayToAddressBech32(recipient, 1_000_000).
	SetValidityStart(validFrom).
	SetTtl(validUntil).
	Complete()
if err != nil {
	log.Fatal(err)
}

Read that chain as a sentence:

Spend these UTxOs, pay this address, make the transaction valid from the current slot, expire it five minutes later, then complete the transaction.

Complete() must come after the validity calls because Apollo uses the final transaction body when it balances the transaction and calculates the fee.


Step 4: Sign and Submit

The signing and submission code does not change:

apollob = apollob.Sign()
tx := apollob.GetTx()

txID, err := bfc.SubmitTx(*tx)
if err != nil {
	log.Fatal(err)
}

Submit within five minutes. If the transaction reaches the node after validUntil, the node rejects it with an OutsideValidityInterval error.


Full Changed Section

This is the part of tx.go that changes from 102.2:

utxos, err := bfc.Utxos(*apollob.GetWallet().GetAddress())
if err != nil {
	log.Fatal(err)
}

currentSlot, err := bfc.LastBlockSlot()
if err != nil {
	log.Fatal(err)
}

validFrom := int64(currentSlot)
validUntil := validFrom + 300

apollob, _, err = apollob.
	AddLoadedUTxOs(utxos...).
	PayToAddressBech32(recipient, 1_000_000).
	SetValidityStart(validFrom).
	SetTtl(validUntil).
	Complete()
if err != nil {
	log.Fatal(err)
}

Everything before and after this section can stay the same as the 102.2 example.


Why Use a Validity Window?

The most common reason is to stop an old transaction from lingering forever. Fees, UTxOs, and business rules can become stale. A TTL says, "if this does not land soon, let it fail instead of surprising me later."

The start slot is useful when a transaction must not be accepted too early. Examples include time-locked spending, launch windows, auction bids, and coordinated signing flows.

Most simple wallet transactions only need SetTtl(...). This lesson uses both ends so you can see the full validity interval.


Common Pitfalls

Setting the TTL too close

SetTtl(validFrom + 5) gives the network only a few seconds. The transaction may expire before a block producer includes it. Use 300 slots for interactive examples.

Passing a timestamp instead of a slot

Apollo expects absolute slot numbers, not Unix timestamps and not epoch numbers. Get the slot from the chain context.

Setting the window after Complete()

The validity interval is part of the transaction body. Set it before Complete().

Reusing an old transaction

If you build a transaction and wait too long, the TTL passes. Build a fresh transaction with a fresh slot instead of resubmitting the old one.


You'll Know You're Successful When

  • Your transaction submits successfully while the window is open
  • If you deliberately set SetTtl(validFrom - 100), submission fails with OutsideValidityInterval
  • You can explain SetValidityStart as "not before" and SetTtl as "expires at"

Practice Tasks

  • Build a transaction with SetTtl(validFrom + 5) and submit it. Did it land before it expired?
  • Build a transaction with SetValidityStart(validFrom + 600). Try to submit it immediately and inspect the error.
  • Remove SetValidityStart(...) but keep SetTtl(...). Explain why this is enough for many basic transactions.

What's Next

  • 102.6 — attach metadata to a transaction so off-chain services can tag and query it
  • Module 201 — watch validity intervals in action as transactions stream past