All pages

Developer reference

Private triggers and the enclave

How a PRIVATE rule keeps its threshold inside a TEE, what the chain verifies, what has to be trusted, and the two bypasses that were found and closed.

Working with an agent? Every page here has a markdown twin, the whole site is one file at llms-full.txt, and trimmy-mcp lets it read live rules and compose a payment it cannot send.

Testnet. Trimmy runs on Flare Coston2. Nothing described here has run on Flare mainnet, and no page on this site describes real money.

A PRIVATE rule keeps its threshold price out of chain state. The chain holds a commitment, an enclave holds the number, and the rule cannot fire without a fresh signed verdict from a machine the TEE registry vouches for. Coston2 only (chain id 114); nothing has run on Flare mainnet. Source: TrimmyConfidentialTrigger.sol, extension.go.

What crosses between the chain and the enclave, and what never does.
requestEvaluation crosses in, a signed yes or no crosses out.

What runs privately inside the TEE

The enclave is a Go handler in a GCP Confidential Space VM on AMD SEV. It keeps one map, rule id to {threshold, commitment, direction, account}, in process memory: nothing is written to disk, logged, or returned by any handler. GET /state reports counts, never contents.

Three commands under op type TRIMMY. The contract's bytes32 literals and the strings in config.go must match byte for byte; a mismatch falls through to "unsupported op type", silently.

command what the enclave does
PROVISION ECIES-decrypts the payload via the node's loopback /decrypt, stores the secret
EVALUATE compares an observed price against the threshold, returns one boolean
FORGET deletes the secret, refusing a caller whose account does not match

PROVISION takes {ruleId, threshold, salt, direction, account} as JSON with DisallowUnknownFields. The threshold is a decimal string, never a float. It refuses a non-positive threshold, a salt under 16 characters, a direction other than 0 or 1, an empty account, and an overwrite by another account. An earlier version read plaintext off the instruction, which would have published every threshold on chain.

The commitment, literally:

keccak256( decimal(threshold) || 0x1F || salt || 0x1F || byte(direction) )
direction: 0 = below, 1 = above

0x1F is the ASCII unit separator, so "1" + "23" cannot collide with "12" + "3". The direction byte is inside the hash so a threshold provisioned for below cannot satisfy a rule armed as above.

EVALUATE fires when observed <= threshold (below) or observed >= threshold (above). The response carries ruleId, fire, commitment, nonce and issuedAt, and no distance, bounds or margin: a distance would let repeated queries binary-search the secret. A missing secret is a refusal, and enclave memory is volatile, so a restart loses every secret. 14 Go tests pin this, including TestVerdictLeaksNothingAboutTheThreshold.

What is verified or consumed on chain

The trigger is the InstructionSender bound to FCC extension 66052. The registry rejects sendInstructions from any other msg.sender.

function selector access
commitTrigger(uint256,bytes32) 0x5f1ddc2a anyone, write-once, records the owner
provision(uint256,bytes) 0x4863f523 rule owner only
requestEvaluation(uint256,uint64) 0x654e2eef permissionless
acceptVerdict(...) 0x8666b696 permissionless
consumeVerdict(uint256,uint64) 0x20531b43 onlyTrimmy, view
isAuthorisedTee(address) 0xe35ebda3 view

requestEvaluation has no price parameter, and that is the security of the function. It calls Trimmy.currentPrice(ruleId), which re-derives the two-legged relative price from the same FTSO feeds the execution reads, builds {"ruleId":N,"observedPrice":"P","nonce":K}, sends it, and records the returned instruction id in pendingAction[ruleId][nonce]. A caller picks only when to ask.

acceptVerdict checks, in order: the commitment exists and equals the rule's; the nonce is unused; pendingAction[ruleId][nonce] is non-zero and equals actionId; issuedAt is not negative, not older than MAX_VERDICT_AGE (90 s), and not more than MAX_CLOCK_SKEW (10 s) ahead of the block clock; the signature recovers to an authorised TEE; and fire is true. _recover refuses any signature that is not 65 bytes, and the upper half of the curve order. The signature covers a domain-separated payload, not the bare result hash, which fails against real node signatures.

resultHash  = keccak256( keccak256(resultData) || actionId || keccak256(submissionTag) || status )
payloadHash = keccak256( abi.encode(bytes32("TEE_ACTION_RESULT"), block.chainid, resultHash) )
digest      = keccak256("\x19Ethereum Signed Message:\n32" || payloadHash)

isAuthorisedTee requires all three of: the machine serves extension 66052; status is 2; attested platform is bytes32("GCP_AMD_SEV"). Each read sits in a try/catch, because the registry reverts for an address that was never a machine. Without the platform check a simulated machine passes, and its code hash is a constant shared by 254 machines.

Trimmy.execute calls consumeVerdict(ruleId, uint64(rule.spent)), so the nonce advances per executed part and a verdict cannot be replayed onto a later one. The live verdict for rule 0 (0x9a2af814, 97,079 gas) used submissionTag = "threshold", status = 1, and an actionId equal to the recorded pendingAction(0,0).

What trust assumptions exist

A PRIVATE rule's trust model is strictly weaker than the other three triggers, and the code says so. PRICE_BELOW, PRICE_ABOVE and SCHEDULE are permissionless and re-derive every bound on chain from live FTSO. A PRIVATE rule's fire decision is asserted by one enclave at a point in time and no verifier can recompute it.

What the operator cannot do matters as much. They cannot fire a rule at a price the chain did not produce, cannot move funds (PersonalAccount.executeUserOp is onlyController, and proceeds go to rule.account), and cannot exceed the rule's exact allowance, verb, venue, token pair, budget or expiry, all fixed at arm time. A PRIVATE rule is a bounded mandate an agent can compose and cannot exceed, and the agent never learns the threshold.

Why this needs confidential compute rather than a normal contract

Contract storage is public. An ordinary rule stores triggerValue in the clear, so anyone reading ruleAt(id) knows the price at which a large, price-insensitive sell arrives. The related review finding (A2) is that the execution floor is a publicly computable target.

The Flare Data Connector cannot substitute. Web2Json's source identifier is PublicWeb2: the request and its headers go on chain and roughly 100 providers each fetch it. That property makes FDC trustworthy and stops it holding a secret. A commitment alone does not help either: the comparison needs the plaintext.

Two bypasses were found while driving the first real PRIVATE rule. Both are closed.

The caller supplied the price. requestEvaluation(uint256,string,uint64) (selector 0x987e0204) took the observed price as a string. Any stranger could ask "does rule N fire at a price of 1?", read the genuine signed fire verdict off the proxy's public, unauthenticated result endpoint, and push someone else's rule through acceptVerdict at a price its owner never chose. Asked repeatedly, each verdict is one bit of "is the threshold above or below the number I picked", so roughly twenty queries binary-search the secret.

The verdict was unbound. acceptVerdict never checked that a verdict answered an instruction this contract had sent, so a verdict minted by any other instruction satisfied it, including one an attacker sent directly. Every other check became decoration.

One root cause: the number the enclave compared against came from the attacker, not the chain. The fix deleted the price parameter and added the pendingAction binding. Pinned by VerdictBinding.t.sol, 8 tests: test_theEnclaveIsAskedAboutTheChainsPrice, test_theQuestionTracksTheChainPrice, test_valueAccounting, test_anUnrequestedVerdictIsRefused, test_aVerdictForAnotherInstructionIsRefused, test_theAnswerToOurOwnQuestionIsAccepted, test_reAskingInvalidatesTheEarlierAnswer, test_requestBeforeBindingIsRefused. Also ConfidentialTrigger.t.sol (11 tests, test_simulatedPlatformIsRejected among them) and PrivateTrigger.t.sol (7, including test_theThresholdIsNotOnChain).

The binding is checked before the signature, so a verdict nobody asked for is refused whether or not the enclave really signed it. Against the live contract:

cast call 0x02EA709e2278EACDbA00D4A88caA604E3b35293b \
  "acceptVerdict(uint256,bool,bytes32,uint64,int64,bytes,bytes32,string,uint8,bytes)" \
  0 true 0xeca87739390ec330326bcb95dfbb5541b2126ada46d3f329b6ce64dcc12c8162 \
  7 1786297877 0x00 0xdead...beef "end" 1 0x00 --rpc-url $R
# reverts 0xe6cf311f... = NoEvaluationRequested(ruleId = 0, nonce = 7)
Source on GitHub Read this page as markdown: /developers/private-triggers/index.md