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
curtaprogram - 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.
| Offset | Type | Field | Meaning |
|---|---|---|---|
| 0 | 8 bytes | event tag | e4 45 a5 2e 51 cb 9a 1d |
| 8 | u8 | action | The tag of the instruction that emitted the event, 0 to 12. |
| 9 | address | primary | The account the instruction acted on. |
| 41 | address | actor | The signer. |
| 73 | address | related | A second account that belongs to the action. |
| 105 | 6 × u64 | values | Six numbers whose meaning depends on the action. Unused ones are zero. |
| 153 | u32 | token count | Number of token ids that follow. |
| 157 | u32 each | tokens | Token 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.
| Action | Event | primary | actor | related | values |
|---|---|---|---|---|---|
| 0 | init_config | Config | authority | FeeVault | 0: turn reward; 1: protocol share basis points |
| 1 | set_config | Config | authority | FeeVault | 0: turn reward; 1: protocol share basis points |
| 2 | create_model | model | publisher | ModelVault | 0: royalty per token; 1: total weight bytes; 2: section count |
| 3 | write_weights | model | publisher | weight account | 0: section; 1: offset; 2: chunk length; 3: bytes written in the section |
| 4 | seal_model | model | publisher | weight account | 0: section; 1: byte offset reached; 2: bytes hashed in this step; 3: sealed, 1 when this step sealed the model |
| 5 | set_royalty | model | publisher | the all-zero address | 0: royalty per token |
| 6 | claim_royalties | model | publisher | ModelVault | 0: lamports claimed |
| 7 | open_run | run | opener | model | 0: budget; 1: max tokens; 2: temperature Q16; 3: seed; 4: zero; 5: state |
| 8 | turn | run | cranker | model | 0: 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 |
| 9 | fund | run | funder | model | 0: amount; 1: budget remaining; 2: state |
| 10 | cancel | run | opener | model | 0: budget remaining |
| 11 | settle | run | caller | model | 0: royalty paid; 1: protocol share paid; 2: turn rewards paid over the run; 3: tokens generated |
| 12 | close_run | run | opener | model | 0: 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.
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.
| Event | Named fields in data |
|---|---|
| create_model | model, publisher, royalty, weightBytes, sectionCount |
| write_weights | model, section, offset, byteLength, written |
| open_run | run, opener, model, budget, maxTokens, temperatureQ16, seed, status |
| turn | run, cranker, model, turn, reward, royalty, protocolShare, budgetRemaining, status |
| fund | run, amount, budgetRemaining, status |
| settle | run, royalty, protocolShare, rewards, generated |
| close_run | run, 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.
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.
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:
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')The run account changes on every turn. Subscribe to it and decode each update:
import { decodeRun } from '@curta/sdk'
let shown = 0
const id = connection.onAccountChange(run, (account) => {
const state = decodeRun(account.data, run)
process.stdout.write(state.text.slice(shown))
shown = state.text.length
if (state.status !== 'running') console.log('\n' + state.status, 'after', state.turns, 'turns')
}, 'confirmed')curta.run does the same by polling: it opens the run, reads the run’s head every 200 ms, calls onTurn with the decoded run while it is running, and resolves with the text, the address and the token ids when the run is complete.
const { text, address, tokens } = await curta.run({
model,
prompt: 'Once upon a time',
onTurn: (run) => console.log(run.turns, run.text),
})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.
| Offset | Field | SDK name | On a turn |
|---|---|---|---|
| 104 | state | status | Stays running, or becomes starved or complete. |
| 152 | budget remaining | budgetRemaining | Falls by the turn’s charge. |
| 160 | rewards paid | rewardsPaid | Rises by the turn reward. |
| 168 | royalties accrued | royaltiesAccrued | Rises by the royalty times the tokens completed. |
| 176 | protocol share accrued | protocolAccrued | Rises with rewards and royalties. |
| 184 | turns | turns | Rises by one. |
| 192 | generated | generated | Rises by the tokens completed. |
| 208 | last activity slot | lastSlot | Becomes the turn’s slot. |
| 216 | next turn cost | nextTurnCost | Becomes the reserve of the turn after this one. |
| 256 | working state | tokens, text | The 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
open_run, once. Its state is 0, or 1 when the budget is below the first turn’s cost.turn, once per turn, withfundwherever someone adds budget.- The end: a
turnwhose state is 2, or acancel. settle, once, with the royalty and the protocol share paid.close_run, once, with the lamports returned to the opener. The run account is gone after it, and the events remain.