You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
MAGI / go-vsc-node — findings from building a large contract
Context: I'm migrating LasseCash (a 2019 Hive-Engine token, ~31M supply) to MAGI.
The core contract is ~90KB of TinyGo WASM with 28 entrypoints, and the migration
puts ~2,000 accounts through a Merkle-proof claim. Three throwaway deploys on
mainnet plus a local devnet so far.
Four things came out of that. Two are verifiable in your own source, one is a
question I can't cleanly answer, one is an API request.
1. rc_limit freezes the full declared limit, not the RC actually used
The declared limit is consumed at admission. Consumed RC thaws linearly over 5
days, so an over-declared rc_limit locks the account for days regardless of
what the call actually cost.
What it cost me: six calls at rc_limit: 100,000 took @lassecashmagi from
usable to 0 available and locked it out for days. The account's capacity was
~22,000 RC (12 HBD + the 10,000 free), so the first call alone over-committed it.
Worse, one of those calls reported ok: true in its output and its state was
silently discarded at settlement. From the client side there is no way to tell
that apart from a success.
Second-order trap in the same area: rc_limit also reserves HBD (PullBalance
reserves rc_limit − free_rc), so a call that draws HBD needs a LOW rc_limit
or it starves its own draw. That one took a while to work out.
Ask: document it prominently — "declare what you're willing to lock for 5
days, not a safe upper bound" is the opposite of the intuition every EVM user
brings. Refunding the unused portion at settlement would be better, if that's
compatible with how admission works.
2. A missing state key reads as a non-nil pointer to an empty string
This one permanently bricked a real deploy.
sdk.StateGetObject returns *string. For a key that was never written, it
returns a valid pointer to "" — not nil. So the obvious guard is wrong:
On my in-memory test store a virgin contract looked uninitialised and 40 tests
passed. On MAGI the same contract reported "already initialised" on its first
call, so init could never run. The contract was unusable from birth:
vsc1BhcH9JyH8VGHJHpHF6XFAxyiAnXkWXHDy8 (10 HBD, dead on arrival)
The SDK itself already knows — StateGetU64 checks val == nil || *val == "".
The knowledge is in the code but not in the docs or the contract template, so
every new contract author gets to rediscover it by spending 10 HBD.
Ask: either collapse empty to absent in the SDK's own getter, or put a
warning in the go-contract-template README. This is the cheapest fix on the list
and it's the one that stops someone bricking a deploy.
Worth stating plainly why I care: LasseCash burns its owner keys at launch, so
the contract can never be updated. If those keys had been burned before I found
this, the project would have been permanently dead with no recovery path.
3. Question, not a claim: nested (/) keys and state that never lands
I don't have a clean attribution here, so I'm reporting the observation rather
than asserting a bug.
Observed on a mainnet throwaway whose state keys contained / (bal/hive:alice, shr/hive:alice, …):
calls executed, outputs reported ok: true
metadata.currentSize grew
state_merkle stayed at the empty-root CID QmX4ymp…
no key was ever readable back via getStateByKeys
I re-keyed every key to flat _ separators (bal_hive:alice), redeployed, and
state persisted immediately — real merkle CID, everything readable. That contract
is vsc1BqLfLpKdMSfmHCe4o15ssWMiWJZw3yoZ8C and it has been working since.
The confound: I lowered my rc_limit defaults at the same time, and per
finding #1 an RC-starved tx has its state discarded while still reporting ok: true. Two variables changed together, so I can't honestly say the / was
the cause.
What makes me still think it's worth your time: DataBin.Set splits paths on / into real directories (lib/datalayer/dir.go:149), nested directories are
what produce HAMT shards, and HEAD carries materializeGetNode fixing a HAMT
"two-path serialization divergence that causes non-deterministic CIDs" — dated
19 Aug 2026, days before I hit this. Non-deterministic CIDs across witnesses
would produce exactly the symptom I saw: execution fine, consensus on the
resulting state impossible, output still ok: true.
Questions:
Do the mainnet witnesses currently run that fix, or do deployed nodes lag HEAD?
Is / in contract state keys supported and intended? Every working mainnet
contract I looked at (hive_hbd_lp etc.) uses flat keys, which is either
convention or everyone else learning the same lesson quietly.
If it is supported, a round-trip test — write nested keys, save, reload, read
back — would settle it permanently.
I've moved to flat keys and I'm staying there regardless, so this isn't blocking
me. It's the next person I'd worry about.
4. Contract outputs carry no error text over GraphQL
For a real (non-simulated) call, results { ok ret } is the whole type. When a
call fails there is no error string anywhere in the normal API — the only way to
get one is dropping to the raw DAG:
getDagByCID(output.id) → errMsg
simulateContractCalls does return ErrMsg (modules/gql/gqlgen/schema.resolvers.go),
so the field exists on the simulate path but not on real outputs. Diagnosing a
failed mainnet call currently means knowing the DAG trick exists.
Ask: surface errMsg on contract outputs in GraphQL.
5. A request: RC_HIVE_FREE_AMOUNT is load-bearing for us
modules/common/params/params.go:194
var RC_HIVE_FREE_AMOUNT int64 = 10_000 // 5 HBD worth of RCs for Hive accounts
The LasseCash migration is pull-based: one Merkle root on-chain, and ~2,000
holders each claim their own leaf paying their own RC. Measured on mainnet 2026-08-22 against a current build: a large staked claim simulates at 6,187 RC (gas 618,683,092). It still fits inside a fresh account's free tier, but the margin is under 4,000 RC. An earlier build measured 5,892; the contract has grown since, so treat 6,187 as the live figure. That is the entire reason the design works —
the alternative (owner pushes every account) needs ~8.8M RC, which is thousands
of HBD parked on MAGI, which I do not have.
So this parameter is a hard external dependency for us, and it is a var that
moves in node releases.
The ask: if RC_HIVE_FREE_AMOUNT is ever going to drop, I would like to know
before it happens rather than from support messages. Anything below ~7,000 breaks
claiming for staked positions. I am not asking anyone to freeze a parameter for
me — advance notice is enough, because I can extend the claim window or publish
guidance if I know it is coming.
Related question while I am asking: is there a channel where node releases and
consensus-parameter changes are announced? I could not find one, and I would
subscribe to it.
6. Question: does anything in MAGI assume a contract still has an owner?
LasseCash burns its owner keys at an announced height shortly after launch, so
the contract can never be updated again. That is a deliberate product decision,
not something I want talked out of — but it means I cannot adapt to anything
afterwards.
What I would like to know before that height:
What version do mainnet witnesses currently run, and how far do deployed
nodes typically lag HEAD? (This is also what would settle finding Bls #3.)
Does any part of the protocol require a contract owner to act, ever? For
example a future migration, a re-registration, a storage-renewal, or anything
where an ownerless contract would be treated as abandoned. If such a thing is
planned or even considered, an ownerless contract is a landmine and I need to
know now rather than after the keys are gone.
Is there any precedent for an ownerless contract on MAGI, or would mine be the
first?
Nobody may have thought about this case yet, which is exactly why I am asking
early. Happy to be told "no idea, you are the first" — that is a useful answer.
Everything above is reproducible and I am glad to provide tx ids, CIDs, or a
minimal repro for any of it.
MAGI / go-vsc-node — findings from building a large contract
Context: I'm migrating LasseCash (a 2019 Hive-Engine token, ~31M supply) to MAGI.
The core contract is ~90KB of TinyGo WASM with 28 entrypoints, and the migration
puts ~2,000 accounts through a Merkle-proof claim. Three throwaway deploys on
mainnet plus a local devnet so far.
Four things came out of that. Two are verifiable in your own source, one is a
question I can't cleanly answer, one is an API request.
1.
rc_limitfreezes the full declared limit, not the RC actually usedmodules/block-producer/blockProducer.go:437The declared limit is consumed at admission. Consumed RC thaws linearly over 5
days, so an over-declared
rc_limitlocks the account for days regardless ofwhat the call actually cost.
What it cost me: six calls at
rc_limit: 100,000took@lassecashmagifromusable to 0 available and locked it out for days. The account's capacity was
~22,000 RC (12 HBD + the 10,000 free), so the first call alone over-committed it.
Worse, one of those calls reported
ok: truein its output and its state wassilently discarded at settlement. From the client side there is no way to tell
that apart from a success.
Second-order trap in the same area:
rc_limitalso reserves HBD (PullBalancereserves
rc_limit − free_rc), so a call that draws HBD needs a LOWrc_limitor it starves its own draw. That one took a while to work out.
Ask: document it prominently — "declare what you're willing to lock for 5
days, not a safe upper bound" is the opposite of the intuition every EVM user
brings. Refunding the unused portion at settlement would be better, if that's
compatible with how admission works.
2. A missing state key reads as a non-nil pointer to an empty string
This one permanently bricked a real deploy.
sdk.StateGetObjectreturns*string. For a key that was never written, itreturns a valid pointer to
""— notnil. So the obvious guard is wrong:On my in-memory test store a virgin contract looked uninitialised and 40 tests
passed. On MAGI the same contract reported "already initialised" on its first
call, so
initcould never run. The contract was unusable from birth:The SDK itself already knows —
StateGetU64checksval == nil || *val == "".The knowledge is in the code but not in the docs or the contract template, so
every new contract author gets to rediscover it by spending 10 HBD.
Ask: either collapse empty to absent in the SDK's own getter, or put a
warning in the go-contract-template README. This is the cheapest fix on the list
and it's the one that stops someone bricking a deploy.
Worth stating plainly why I care: LasseCash burns its owner keys at launch, so
the contract can never be updated. If those keys had been burned before I found
this, the project would have been permanently dead with no recovery path.
3. Question, not a claim: nested (
/) keys and state that never landsI don't have a clean attribution here, so I'm reporting the observation rather
than asserting a bug.
Observed on a mainnet throwaway whose state keys contained
/(bal/hive:alice,shr/hive:alice, …):ok: truemetadata.currentSizegrewstate_merklestayed at the empty-root CIDQmX4ymp…getStateByKeysI re-keyed every key to flat
_separators (bal_hive:alice), redeployed, andstate persisted immediately — real merkle CID, everything readable. That contract
is
vsc1BqLfLpKdMSfmHCe4o15ssWMiWJZw3yoZ8Cand it has been working since.The confound: I lowered my
rc_limitdefaults at the same time, and perfinding #1 an RC-starved tx has its state discarded while still reporting
ok: true. Two variables changed together, so I can't honestly say the/wasthe cause.
What makes me still think it's worth your time:
DataBin.Setsplits paths on/into real directories (lib/datalayer/dir.go:149), nested directories arewhat produce HAMT shards, and HEAD carries
materializeGetNodefixing a HAMT"two-path serialization divergence that causes non-deterministic CIDs" — dated
19 Aug 2026, days before I hit this. Non-deterministic CIDs across witnesses
would produce exactly the symptom I saw: execution fine, consensus on the
resulting state impossible, output still
ok: true.Questions:
/in contract state keys supported and intended? Every working mainnetcontract I looked at (
hive_hbd_lpetc.) uses flat keys, which is eitherconvention or everyone else learning the same lesson quietly.
back — would settle it permanently.
I've moved to flat keys and I'm staying there regardless, so this isn't blocking
me. It's the next person I'd worry about.
4. Contract outputs carry no error text over GraphQL
For a real (non-simulated) call,
results { ok ret }is the whole type. When acall fails there is no error string anywhere in the normal API — the only way to
get one is dropping to the raw DAG:
simulateContractCallsdoes returnErrMsg(modules/gql/gqlgen/schema.resolvers.go),so the field exists on the simulate path but not on real outputs. Diagnosing a
failed mainnet call currently means knowing the DAG trick exists.
Ask: surface
errMsgon contract outputs in GraphQL.5. A request:
RC_HIVE_FREE_AMOUNTis load-bearing for usmodules/common/params/params.go:194The LasseCash migration is pull-based: one Merkle root on-chain, and ~2,000
holders each claim their own leaf paying their own RC. Measured on mainnet 2026-08-22 against a current build: a large staked claim simulates at 6,187 RC (gas 618,683,092). It still fits inside a fresh account's free tier, but the margin is under 4,000 RC. An earlier build measured 5,892; the contract has grown since, so treat 6,187 as the live figure. That is the entire reason the design works —
the alternative (owner pushes every account) needs ~8.8M RC, which is thousands
of HBD parked on MAGI, which I do not have.
So this parameter is a hard external dependency for us, and it is a
varthatmoves in node releases.
The ask: if
RC_HIVE_FREE_AMOUNTis ever going to drop, I would like to knowbefore it happens rather than from support messages. Anything below ~7,000 breaks
claiming for staked positions. I am not asking anyone to freeze a parameter for
me — advance notice is enough, because I can extend the claim window or publish
guidance if I know it is coming.
Related question while I am asking: is there a channel where node releases and
consensus-parameter changes are announced? I could not find one, and I would
subscribe to it.
6. Question: does anything in MAGI assume a contract still has an owner?
LasseCash burns its owner keys at an announced height shortly after launch, so
the contract can never be updated again. That is a deliberate product decision,
not something I want talked out of — but it means I cannot adapt to anything
afterwards.
What I would like to know before that height:
nodes typically lag HEAD? (This is also what would settle finding Bls #3.)
example a future migration, a re-registration, a storage-renewal, or anything
where an ownerless contract would be treated as abandoned. If such a thing is
planned or even considered, an ownerless contract is a landmine and I need to
know now rather than after the keys are gone.
first?
Nobody may have thought about this case yet, which is exactly why I am asking
early. Happy to be told "no idea, you are the first" — that is a useful answer.
Everything above is reproducible and I am glad to provide tx ids, CIDs, or a
minimal repro for any of it.