Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion HANDOFF.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
# goSVM session handoff

Updated October 16, 2026 (America/New_York).
Updated October 19, 2026 (America/New_York).
**Current phase:** the user's two pre-core-language hurdles (Fogo/Solana with
minimal user awareness; Go+Bend+Rust in one program) are met and merged to `main`;
core-language work has started. See the October 9 section below.

**Outgoing CPI bounds raised (October 19, branch `cpi-raise-limits`, local,
unreleased):** for Hyperlane's Sealevel programs (22 metas in IGP `PayForGas`
with quoted accounts, 17 in fee `QuoteFee`), `solana.Invoke`, the C runtime and
`sdk/cpi` now allow 64 account metas and 4 signer groups (seeds unchanged:
16 per group, 32 bytes, 1,024 encoded bytes); [bounds and justification](docs/SDK2.md#cpi-bounds).
`sol_Invoke` is `noinline` with a 2,240-byte frame; `cpi.Metas` keeps 16 records
inline and moves to one 576-byte heap array at the 17th `Add` (a fixed buffer cost
+324 CU per small CPI; a shorter `Add` pushed the full swap's `go_Process` over
4 KiB on v1.57). SDK-2 snapshot change: the escrow, full-swap and tutorial
snapshots and `sdk_sha256` were refreshed, and **existing SDK-2 projects must be
recreated** (Load rejects the old snapshot). Small builder CPIs +9–23 CU,
existing suites +0–21 CU, ELFs +1.6–1.8 KB (the allocator); the full-swap
reference ELFs changed (fogo-v0 `bcd9253e…`, solana-v3 `86f0ed15…`, now
`verify_rebuild.py`'s default reference); 33 corpus C hashes moved.
`gosvm features` does not report the SDK's allocation in `uses.allocation`.
Error-contract change (2006/2007/3002/3003) to announce to Qushta.
[Evidence](results/compiler/2026-10-19-cpi-limits/README.md).

**First-program tutorial and release walkthrough (October 18):**
[docs/TUTORIAL.md](docs/TUTORIAL.md) builds a PDA counter
([examples/tutorial](examples/tutorial/README.md), in the CI module loop): scaffold,
Expand Down
17 changes: 13 additions & 4 deletions docs/ERROR_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@ because fixtures compare them byte for byte:
nothing and never shrinks the table. Converting a map to an interface
allocates nothing. The full table is in
[GO_MAPS.md](GO_MAPS.md#heap-accounting).
SDK 2's `cpi.Metas` allocates 576 bytes at its 17th `Add` and nothing
before; no other SDK helper allocates ([SDK 2](SDK2.md#cpi-bounds)).
Package-level variables that are not read-only live in the globals block:
a C struct of those variables in `CheckedProgram.Globals` order (each at
its Go alignment), rounded up to 8 bytes (`GlobalBytes`). Every invocation
Expand Down Expand Up @@ -406,8 +408,8 @@ SDK 1 single-account programs use 10–14 for their input checks
| 2003 | SHA-256 input or output range out of bounds |
| 2004 | SHA-256 result not 32 bytes |
| 2005 | Account index out of range |
| 2006 | Malformed CPI account metas |
| 2007 | Malformed signer seeds or PDA input |
| 2006 | Malformed CPI account metas, or more than 64 |
| 2007 | Malformed signer seeds or PDA input, or more than 4 signer groups |
| 2008 | CPI instruction data longer than 10,240 bytes |
| 2009 | Native callback missing (native tests only) |
| 2010 | Resize or original length beyond limits |
Expand All @@ -424,8 +426,8 @@ SDK 1 single-account programs use 10–14 for their input checks
| Code | Meaning |
| --- | --- |
| 3001 | Seeds: more than 16, or one longer than 32 bytes, or nil where required |
| 3002 | CPI metas: more than 16, or nil |
| 3003 | CPI signers: more than 2 groups or 1,024 bytes |
| 3002 | CPI metas: more than 64, or nil |
| 3003 | CPI signers: more than 4 groups or 1,024 bytes |
| 3010 | Token account invalid (owner, executable, 165-byte layout, state, native) |
| 3011 | Token account identity |
| 3012 | Token mints differ |
Expand Down Expand Up @@ -648,3 +650,10 @@ Handler results propagate unchanged.
Process reaches in package "P", opt in with "trim" in gosvm.json or -trim
(docs/GO_PACKAGES.md#trimming)`. No recorded corpus entry's message
changed (their dependency packages are not module paths).
- 2026-10-19: CPI bounds raised (contract change; announce to Qushta): 2006
now allows up to 64 account metas (was 16) and 2007 up to 4 signer groups
(was 2) in `solana.Invoke`; 3002 allows 64 metas and 3003 four groups in
`sdk/cpi`. Inputs within the old bounds give the same codes in the same
order. Seeds keep 16 per group, 32 bytes each and 1,024 encoded bytes.
`cpi.Metas` beyond 16 records allocates 576 bytes (heap accounting above),
so such a CPI can now hit the out-of-memory trap.
58 changes: 49 additions & 9 deletions docs/SDK2.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,47 @@ of at most 32 bytes each, including any bump. `Address` derives an address using
an explicit program key; `Matches` compares it with a context account key.
These methods use supplied seeds; a bump-search convenience API is not provided.

`cpi.Metas.Add(index, writable, signer)` builds at most 16 account metas.
`cpi.Signers.Add(&seeds)` copies at most two seed groups into a 1,024-byte buffer;
the combined encoding must fit that buffer even if both groups separately fit.
`cpi.Metas.Add(index, writable, signer)` builds at most 64 account metas
(`cpi.MaxMetas`); repeated accounts count. `cpi.Signers.Add(&seeds)` copies at
most four seed groups (`cpi.MaxSigners`) into a 1,024-byte buffer; the combined
encoding must fit that buffer even if each group separately fits.
`cpi.Invoke` accepts an executable program's context index, metas, at most 10,240
instruction bytes, and optional signer groups. Generic CPI uses explicit account
indices and does not itself implement application owner, relationship or alias
policy. The SDK-2 account loader accepts up to 32 incoming account descriptors,
including duplicates; SDK 1 remains capped at 16. Outgoing CPI metas and schema-2
declared accounts retain their separate 16-account bounds. Custom handlers can
including duplicates; SDK 1 remains capped at 16. Schema-2 declared accounts
retain their separate 16-account bound. Custom handlers can
accept extra incoming accounts, while generated handlers retain their declared
count checks. See the [decoder extension and new pin](ACCOUNT_DECODER32.md).

### CPI bounds

| Bound | goSVM | Runtime (Agave 3.0) |
| --- | --- | --- |
| Account metas per CPI | 64 (`solana.Invoke` 2006, `Metas.Add` 3002) | 255 (`MAX_ACCOUNTS_PER_INSTRUCTION`) |
| Distinct accounts (account infos) | the incoming accounts, at most 32 | 64, or 128 with `increase_tx_account_lock_limit` |
| Signer groups | 4 (2007, `Signers.Add` 3003) | 16 (`MAX_SIGNERS`) |
| Seeds per group, bytes per seed | 16, 32 (2007; `pda.Seeds` 3001) | 16, 32 (`MAX_SEEDS`, `MAX_SEED_LEN`) |
| Encoded signer groups | 1,024 bytes (2007, 3003) | none |
| Instruction data | 10,240 bytes (2008) | 10,240 |

Every meta indexes the invocation's own accounts and `Invoke` passes those as
the account infos, so a CPI names at most 32 distinct accounts, within every
feature-set's account-info limit; more than 32 metas repeat accounts.
64 metas cover Hyperlane's Sealevel CPIs (22 for IGP `PayForGas` with quoted
accounts, 17 for fee `QuoteFee`) with room for repeated references, and keep the
C runtime's descriptor arrays (16 bytes per meta and per seed) in one SBF frame:
`sol_Invoke` is never inlined and takes 2,240 bytes of its own frame, so callers'
frames are unchanged. The runtime's 255 would need 4,080 bytes of metas alone.

`cpi.Metas` keeps its first 16 records (`cpi.InlineMetas`) in the builder.
The 17th `Add` allocates one 576-byte array on the invocation heap, copies the
inline records there and continues in it; that is the only SDK allocation, it
counts toward the heap budget like any other (the out-of-memory trap of
[ERROR_CONTRACT](ERROR_CONTRACT.md#traps)), and a builder that stays within 16
metas allocates nothing. Copying a `Metas` value after its 17th record shares
the heap array between the copies; pass builders by pointer.

`cpi.SingleSigner` encodes one signer group directly, avoiding the intermediate
`pda.Seeds` to `cpi.Signers` copy. It offers `AddBytes` and `AddByte`, with the
same maximum of 16 seeds, each at most 32 bytes including the explicit bump.
Expand All @@ -58,11 +87,11 @@ uses the existing invoke boundary and propagates its statuses unchanged. Nil
metas return 3002; a nil signer invokes without PDA signing, matching `Invoke`.
A nonnil empty builder encodes one empty group (`[1, 0]`). Keep the borrowed
`Bytes` backing value alive and unmodified through invocation. Use the general
builder for two groups. See the [adoption evidence](COMPACT_SIGNER_ADOPTION.md).
builder for two to four groups. See the [adoption evidence](COMPACT_SIGNER_ADOPTION.md).

Builders use caller-owned scalar arrays. `Bytes` returns a borrowed view that
must not escape its backing value. Failed additions leave builders unchanged.
There is no allocation on chain. These bounds are the SDK's experimental
Builders use caller-owned scalar arrays, except `Metas` beyond 16 records (above).
`Bytes` returns a borrowed view that must not escape its backing value. Failed
additions leave builders unchanged. These bounds are the SDK's experimental
contract, not a claim to cover all transactions accepted by Solana.

`token.Load(context, index, writable)` returns an opaque checked `token.Account`.
Expand Down Expand Up @@ -217,6 +246,17 @@ output length). These are experimental API codes, separate from schema-2 errors.

## Validation and open work

The [CPI bounds proof](../results/compiler/2026-10-19-cpi-limits/README.md) runs
[examples/cpi-limits](../examples/cpi-limits/program.go) on fogo-v0 and solana-v3:
3, 16, 17, 22, 32 and 64 metas with up to four PDA signer groups, through the
builders and the raw encoding, against a callee that checks its account count,
signers and every repeated-account position; 65 metas and five groups fail
before invoking (3002/3003, 2006/2007). A 3-meta builder CPI costs 9–10 CU more
than before and a 16-meta one 21–23 (the builder's heap pointer and branch); the
raw path is unchanged. Existing token/System CPIs in the escrow, swap and
tutorial suites cost 0–21 CU more per transaction, and every program using
`sdk/cpi` links the allocator (about 1.7 KB of ELF) even when it never spills.

The [checked-token fixture](../examples/checked-token/program.go) and
[evidence](../results/compiler/2026-10-05-checked-token-lifecycle-final/README.md)
cover multi-seed derivation, two signer groups, real classic Token/System CPIs,
Expand Down
47 changes: 47 additions & 0 deletions examples/cpi-limits/callee.c.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/* CPI callee for examples/cpi-limits: instruction data [metas, groups, plain].
Succeeds only when it received exactly `metas` instruction accounts, its
first `groups` accounts are signers, and the rest cycle through `plain`
distinct accounts: account i is new for i < groups+plain and otherwise a
duplicate of account groups+(i-groups)%plain, which checks every caller
meta record's index. Custom errors: 5000 short data, 5001+count wrong count,
5300+i account i not a signer, 5600+i account i not the expected
duplicate or new account. No syscalls, so one source builds for v0 and v3. */
typedef unsigned long long u64;
typedef unsigned char u8;

static u64 read64(const u8 *p) {
u64 v = 0;
for (u64 j = 0; j < 8; j++) v |= (u64)p[j] << (j * 8);
return v;
}

u64 entrypoint(u8 *input) {
u8 signer[256], dup[256];
u64 n = read64(input);
if (n > 255) return 5000;
u8 *p = input + 8;
for (u64 i = 0; i < n; i++) {
dup[i] = p[0];
if (p[0] != 255) {
signer[i] = signer[p[0]];
p += 8;
} else {
signer[i] = p[1];
u64 len = read64(p + 80);
p += 88 + len + 10240;
p = (u8 *)(((u64)p + 7) & ~7ULL);
p += 8;
}
}
u64 len = read64(p);
const u8 *data = p + 8;
if (len < 3) return 5000;
u64 groups = data[1], plain = data[2];
if (n != data[0]) return 5001 + n;
for (u64 i = 0; i < groups && i < n; i++) if (!signer[i]) return 5300 + i;
for (u64 i = 0; i < n; i++) {
u64 want = i < groups + plain || plain == 0 ? 255 : groups + (i - groups) % plain;
if (dup[i] != want) return 5600 + i;
}
return 0;
}
112 changes: 112 additions & 0 deletions examples/cpi-limits/program.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Package cpilimits drives one outgoing CPI with a chosen number of account
// metas and PDA signer groups, through the sdk/cpi builders or the raw
// solana.Invoke encoding. Accounts: 0 the callee program, 1..4 the PDAs of
// seeds ("cpi-limits", group) under this program, then plain accounts that
// the remaining metas cycle through (so counts above the distinct accounts
// repeat them). Instruction: mode, metas, groups, then one bump per group.
// The callee receives [metas, groups, plain] and asserts its account count,
// that its first groups accounts are signers and that the other metas cycle
// through the plain accounts (callee.c.txt).
package cpilimits

import (
"gosvm/sdk/cpi"
"gosvm/sdk/pda"
"gosvm/solana"
)

// Mode selects the encoding path.
const (
ModeBuilder byte = 0 // cpi.Metas, cpi.Signers, cpi.Invoke
ModeRaw byte = 1 // hand-encoded metas and signer groups, solana.Invoke
)

// FirstPlain is the first plain account; 1..4 are signer PDAs.
const FirstPlain uint64 = 5

// MaxRawMetas and MaxRawGroups bound the raw encoder, one past the limits.
const (
MaxRawMetas = 65
MaxRawGroups = 5
)

const ErrInput uint64 = 4000

// label is the first seed of every signer PDA.
var label = [10]byte{'c', 'p', 'i', '-', 'l', 'i', 'm', 'i', 't', 's'}

func meta(i, groups, plain uint64) (uint64, bool) {
if i < groups {
return 1 + i, true
}
return FirstPlain + (i-groups)%plain, false
}

func Process(c solana.Context) uint64 {
ix := solana.Instruction(c)
if len(ix) < 3 || solana.Count(c) <= FirstPlain {
return ErrInput
}
mode, n, groups := ix[0], uint64(ix[1]), uint64(ix[2])
if uint64(len(ix)) != 3+groups || groups > MaxRawGroups || n > MaxRawMetas || groups > n {
return ErrInput
}
plain := solana.Count(c) - FirstPlain
var data [3]byte
data[0], data[1], data[2] = byte(n), byte(groups), byte(plain)
if mode == ModeRaw {
return raw(c, ix, n, groups, plain, data[:])
}
var metas cpi.Metas
for i := uint64(0); i < n; i++ {
index, signer := meta(i, groups, plain)
if code := metas.Add(index, false, signer); code != 0 {
return code
}
}
var signers cpi.Signers
for g := uint64(0); g < groups; g++ {
var seeds pda.Seeds
if seeds.AddBytes(label[:]) != 0 || seeds.AddByte(byte(g)) != 0 || seeds.AddByte(ix[3+g]) != 0 {
return pda.ErrSeeds
}
if code := signers.Add(&seeds); code != 0 {
return code
}
}
return cpi.Invoke(c, 0, &metas, data[:], &signers)
}

// raw encodes up to 65 metas and 5 signer groups directly, so the boundary's
// own limits are reached without the builders' earlier checks.
func raw(c solana.Context, ix []byte, n, groups, plain uint64, data []byte) uint64 {
var metas [MaxRawMetas * 9]byte
for i := uint64(0); i < n; i++ {
index, signer := meta(i, groups, plain)
for j := uint64(0); j < 8; j++ {
metas[i*9+j] = byte(index >> (j * 8))
}
if signer {
metas[i*9+8] = 2
}
}
var seeds [1 + MaxRawGroups*16]byte
used := uint64(1)
seeds[0] = byte(groups)
for g := uint64(0); g < groups; g++ {
seeds[used] = 3
seeds[used+1] = byte(len(label))
used += 2
for j := uint64(0); j < uint64(len(label)); j++ {
seeds[used] = label[j]
used++
}
seeds[used], seeds[used+1], seeds[used+2], seeds[used+3] = 1, byte(g), 1, ix[3+g]
used += 4
}
signerSeeds := seeds[:used]
if groups == 0 {
signerSeeds = nil
}
return solana.Invoke(c, 0, metas[:n*9], data, signerSeeds)
}
Loading