DocsReference

Events

The event each instruction emits: how it is carried, its byte layout, what its fields mean per instruction, and how to follow a run from open to close.

How events travel

Every instruction of the curta program emits exactly one event, as its last act. The event is an inner instruction: the program calls itself, with the event authority as the only account and as signer, and the event is that call’s data. The event authority is the program address with seeds "__event_authority", and only the program can sign for it.

Events sit in a transaction’s inner instructions, so a client reads them from the transaction itself, and they are complete however long the transaction’s log is. A record is an event when all of these hold:

  • the transaction succeeded
  • the inner instruction’s program is the curta program
  • its one account is the event authority
  • its data opens with the event tag

Byte layout

An event is 157 bytes plus 4 bytes per token id. Integers are little-endian and addresses are 32 raw bytes.

OffsetTypeFieldMeaning
08 bytesevent tage4 45 a5 2e 51 cb 9a 1d
8u8actionThe tag of the instruction that emitted the event, 0 to 12.
9addressprimaryThe account the instruction acted on.
41addressactorThe signer.
73addressrelatedA second account that belongs to the action.
1056 × u64valuesSix numbers whose meaning depends on the action. Unused ones are zero.
153u32token countNumber of token ids that follow.
157u32 eachtokensToken ids. Only turn fills them.

The event’s name is the name of the instruction with that action number: action 7 is open_run, action 8 is turn.

Fields by instruction

The three addresses and the six values carry these, per instruction. Values are numbered from 0 to 5; the ones after the last listed are zero. State is 0 for running, 1 for starved, 2 for complete and 3 for cancelled.

ActionEventprimaryactorrelatedvalues
0init_configConfigauthorityFeeVault0: turn reward; 1: protocol share basis points
1set_configConfigauthorityFeeVault0: turn reward; 1: protocol share basis points
2create_modelmodelpublisherModelVault0: royalty per token; 1: total weight bytes; 2: section count
3write_weightsmodelpublisherweight account0: section; 1: offset; 2: chunk length; 3: bytes written in the section
4seal_modelmodelpublisherweight account0: section; 1: byte offset reached; 2: bytes hashed in this step; 3: sealed, 1 when this step sealed the model
5set_royaltymodelpublisherthe all-zero address0: royalty per token
6claim_royaltiesmodelpublisherModelVault0: lamports claimed
7open_runrunopenermodel0: budget; 1: max tokens; 2: temperature Q16; 3: seed; 4: zero; 5: state
8turnruncrankermodel0: turn count after the turn; 1: reward paid; 2: royalty accrued by this turn; 3: protocol share accrued by this turn; 4: budget remaining; 5: state
9fundrunfundermodel0: amount; 1: budget remaining; 2: state
10cancelrunopenermodel0: budget remaining
11settleruncallermodel0: royalty paid; 1: protocol share paid; 2: turn rewards paid over the run; 3: tokens generated
12close_runrunopenermodel0: lamports returned

All amounts are lamports. Every run event has the run as primary, so one run’s history is the events whose primary is its address.

The turn event

A turn that computed carries the run’s new turn count, the reward it paid, the royalty and protocol share it added to what the run owes, the budget left and the state after it. Its token list holds the id of every token the turn completed, in order: none while the turn is inside a token, one or several when it finishes them.

A turn that found the budget below its reserve carries the unchanged turn count, zero for the reward, the royalty and the protocol share, the budget remaining, state 1 and no tokens. A reward of zero marks it: no computation ran and no turn was counted.

The run’s output is the token lists of its turn events laid end to end. The ids map to text through the model’s tokenizer, which lives in section zero of its weights, as Weight format describes. While the run account exists, the text is also in it, ready to read.

Decoding with the SDK

decodeEvent(bytes) from @curta/sdk reads an event, with or without the 8-byte tag in front, and returns null for bytes that are not one.

CurtaEvent
interface CurtaEvent {
  name: InstructionName   // 'open_run', 'turn', ...
  primary: string         // base58
  actor: string           // base58
  related: string         // base58
  values: string[]        // six u64 values as decimal strings
  tokens: number[]        // token ids
  data: Fields            // the five fields above, plus named fields per event
}

data repeats primary, actor, related, values and tokens, and adds named fields for seven events. Amounts, offsets, the seed and the turn count are decimal strings; status, maxTokens, temperatureQ16, generated, section and sectionCount are numbers.

EventNamed fields in data
create_modelmodel, publisher, royalty, weightBytes, sectionCount
write_weightsmodel, section, offset, byteLength, written
open_runrun, opener, model, budget, maxTokens, temperatureQ16, seed, status
turnrun, cranker, model, turn, reward, royalty, protocolShare, budgetRemaining, status
fundrun, amount, budgetRemaining, status
settlerun, royalty, protocolShare, rewards, generated
close_runrun, refund

The other events are read from primary, actor, related and values with the table above. EVENT_CPI_TAG is the 8-byte tag, and decodeInstruction reads the instruction that emitted an event: its name, its arguments and its account names.

For an indexer, curtaProgramDecoder(programId?) bundles the program id, the event authority address, instruction and event decoders into one object.

Events of a transaction

This function returns the Curta events of one confirmed transaction, applying the four conditions above.

events.ts
import { Connection, PublicKey } from '@solana/web3.js'
import bs58 from 'bs58'
import { DEFAULT_PROGRAM_ID, decodeEvent, eventAuthorityAddress, type CurtaEvent } from '@curta/sdk'

const [authority] = eventAuthorityAddress(DEFAULT_PROGRAM_ID)

export async function eventsOf(connection: Connection, signature: string): Promise<CurtaEvent[]> {
  const tx = await connection.getParsedTransaction(signature, {
    commitment: 'confirmed',
    maxSupportedTransactionVersion: 0,
  })
  if (!tx || !tx.meta || tx.meta.err) return []

  const events: CurtaEvent[] = []
  for (const group of tx.meta.innerInstructions ?? [])
    for (const ix of group.instructions) {
      if (!('data' in ix) || !ix.programId.equals(DEFAULT_PROGRAM_ID)) continue
      if (ix.accounts.length !== 1 || !ix.accounts[0].equals(authority)) continue
      const event = decodeEvent(bs58.decode(ix.data))
      if (event) events.push(event)
    }
  return events
}

Following a run

A run can be followed two ways: from its events, which give its whole history and outlast the account, or from the run account, which holds its present state.

Every transaction that touches a run names the run’s address, so the run’s signatures are its history. Read them oldest first and keep the events whose primary is the run.

history.ts
import { eventsOf } from './events'

const signatures = await connection.getSignaturesForAddress(run, undefined, 'confirmed')
const tokens: number[] = []

for (const { signature, err } of signatures.reverse()) {
  if (err) continue
  for (const event of await eventsOf(connection, signature)) {
    if (event.primary !== run.toBase58()) continue
    if (event.name === 'open_run') console.log('opened with budget', event.data.budget)
    if (event.name === 'turn') {
      tokens.push(...event.tokens)
      console.log('turn', event.data.turn, 'budget left', event.data.budgetRemaining, 'state', event.data.status)
    }
    if (event.name === 'fund') console.log('funded', event.data.amount)
    if (event.name === 'cancel') console.log('cancelled')
    if (event.name === 'settle') console.log('settled, royalty', event.data.royalty)
    if (event.name === 'close_run') console.log('closed, returned', event.data.refund)
  }
}

To follow a run live, subscribe to its logs for the signatures and read each transaction’s events:

live.ts
connection.onLogs(run, async ({ signature, err }) => {
  if (err) return
  for (const event of await eventsOf(connection, signature))
    if (event.name === 'turn') process.stdout.write(`${event.tokens.length} tokens, turn ${event.data.turn}\n`)
}, 'confirmed')

What a turn changes in the run account

These are the fields that move when a turn lands, by offset in the run account. Accounts gives the full layout.

OffsetFieldSDK nameOn a turn
104statestatusStays running, or becomes starved or complete.
152budget remainingbudgetRemainingFalls by the turn’s charge.
160rewards paidrewardsPaidRises by the turn reward.
168royalties accruedroyaltiesAccruedRises by the royalty times the tokens completed.
176protocol share accruedprotocolAccruedRises with rewards and royalties.
184turnsturnsRises by one.
192generatedgeneratedRises by the tokens completed.
208last activity slotlastSlotBecomes the turn’s slot.
216next turn costnextTurnCostBecomes the reserve of the turn after this one.
256working statetokens, textThe position advances; completed token ids and text bytes are appended.

client.getRunHead(run) reads the first 384 bytes, which hold every header field in this table, in one small request. A cranker uses it between turns.

The order of a run’s events

  1. open_run, once. Its state is 0, or 1 when the budget is below the first turn’s cost.
  2. turn, once per turn, with fund wherever someone adds budget.
  3. The end: a turn whose state is 2, or a cancel.
  4. settle, once, with the royalty and the protocol share paid.
  5. close_run, once, with the lamports returned to the opener. The run account is gone after it, and the events remain.

Onward