Cross-Contract Call Provenance in Compact Circuits
| MPS | 0040 |
|---|---|
| Status | Proposed |
| Category | Core |
| Authors | Hector Bulgarini (hbulgarini), Nicolas Di Prima (NicolasDP) |
| Created | 15-Sep-2026 |
Abstract
A Compact contract invoked as the callee of a cross-contract call cannot learn
whether a contract invoked it, which contract invoked it, or which circuit of
that contract made the call. The asymmetry is sharp. The protocol already
enforces the pairing from the caller’s side: kernel.claimContractCall commits
the callee’s address, the hash of the callee’s circuit, and the communication
commitment to the caller’s transcript; verification refuses a transaction whose
claim matches no call in the segment, and refuses a second claim on the same
triple; and the caller’s effects reach the first public input of its proof, so
no claim enters a contract’s transcript except by its own circuit. Any observer can
reconstruct the edge from the transaction alone, and the ledger derives the
calling contract’s address per call frame and places it in the virtual machine
context. The one party that can read none of it is the callee, whose
authorisation logic depends on the answer. A merged MIP already waits on this:
MIP-0007 specifies contract-owned names, and that arm will stay unimplementable
even once cross-contract calls reach a public network, because the registry is
the callee. This document frames provenance between contracts and takes no
position on wallet identity, which is MPS-0029’s subject. Every claim is cited
in the Problem section against ledger-9.1.0.0-rc.3 (commit 4823b53) and
compactc 0.34.0.
Vision
A callee decides for itself who may compose with it. A name registry accepts an update because the account contract that owns the name called it, not because a preimage travelled in an argument. A contract that receives value attributes the payment to the contract that sent it. A circuit refuses to run except when driven by a designated counterparty. Composition becomes something a contract consents to rather than something that happens to it, and callee-side policy is stated once, on chain, where it binds.
Problem
Composition shipped without provenance, and no document reserves the gap. CoIP-0002, the Phase 1 design, names exactly three limitations (static implementation resolution, undefined recursion, and no witnesses in callees), MPS-0021 scopes Phase 2 to witnesses and private state, and provenance appears in neither, in no other CoIP, and in no upstream issue.
The protocol already enforces the pairing, from one side only.
kernel.claimContractCall(addr, entry_point, comm) records the pairing as a
transcript effect (compiler/midnight-ledger.ss:195-210), verification refuses
the transaction unless a matching call is present in the same segment
(ledger/src/verify.rs:1633-1653), and the caller’s transcript effects reach
the first public input of its proof (verify.rs:1978-1991, 1946-1960), so no
claim enters a contract’s transcript except by its own circuit. Any observer can
reconstruct the edge after the fact (verify.rs:1113-1200): an indexer can say
which contract called ours; our contract cannot.
The calling contract is computed per frame, reaches the virtual machine, and
the language cannot read it. The specification gives the caller derivation
in order as the calling contract’s address, then the single-distinct-owner rule
over UTXO inputs, then no caller (spec/contracts.md:205-211); the
implementation matches at ContractCall::context
(ledger/src/structure.rs:2678-2718); and the value lands at slot 6 of the
context array, self-describing through the PublicAddress discriminant
(onchain-runtime/src/context.rs:853-894, the caller slot at 886-889;
coin-structure/src/coin.rs:719-722 for the type and 799-806 for the
aligned-value encoding, whose leading boolean is the discriminant). The Compact
Kernel ADT declares sixteen operations and exactly one identity read, self
(compiler/midnight-ledger.ss:159, 256), and kernel.caller is rejected by
0.33.0-rc.2, 0.34.0-rc.0, and 0.34.0 alike with “operation caller undefined for
ledger field type Kernel”.
The calling circuit is recorded nowhere at all. ClaimedContractCallsValue
is (u64, ContractAddress, HashOutput, Fr), whose HashOutput is the callee’s
entry-point hash, recorded by the caller
(onchain-runtime/src/context.rs:578, predicate at
ledger/src/structure.rs:2739-2744); CallContext has no entry-point field.
No platform surveyed exposes the calling function either: Aztec’s
function_selector and Solidity’s msg.sig are each the callee’s own, Aleo’s
self.caller names a program, and Solana’s instructions sysvar excludes the
inner instructions that cross-program invocation creates. One mitigating fact
is worth stating: the caller derivation binds the caller’s whole ContractCall
and keeps only its address (ledger/src/structure.rs:2685-2693), so the
caller’s entry_point is in hand where CallContext is built and is simply
not carried.
The substitutes do not substitute. Authority travelling in arguments is
thirty-two caller-chosen bytes: a ContractAddress is constructible in-circuit
from arbitrary bytes, and an impostor contract forwarding a stored address
compiles cleanly on 0.34.0. A caller cannot authenticate itself either: a
contract’s only secrets live in witnesses, a contract that is itself a callee
has none at run time (“calls to witnesses in non-root contracts are not yet
supported”), and a secret a root caller does hold must be disclosed to cross
the boundary, after which it authenticates nobody. A callback attestation is
refused twice by the toolchain, by the compact-runtime re-entrancy guard, on by
default (compact-runtime 0.19.0, dist/contract.js:292 and 362,
dist/circuit-context.d.ts:151-159), and by the rejection of a non-forest call
graph at transaction construction (ledger/src/construct.rs:1049-1076). A
capability coin proves which contract minted a color, not which contract is
calling. Move and Sui omit a caller primitive deliberately, answering with
witness types and capability objects, and that answer does not port: Compact’s
contract types are structural, the implementation resolved from the caller’s
own application context (CoIP-0002, limitation 1) rather than authenticated at
publish time as Move’s verifier does, so a callee cannot require that a value
reaching it was constructed by a designated contract, and every
witness-derived value forwarded across the boundary is a hard disclose()
error under 0.34.0 where the wrapper is omitted. What survives is an
owner-signed bundle verified inside the callee, which authenticates the owner
rather than the caller, and so cannot express the converse rule: this operation
only when driven by contract C.
Whatever authority rests on must be unforgeable, and the one residual is lending.
The User arm rests on verified ownership of unshielded inputs; the Contract arm
rests on a claim a contract emits, and that claim is disciplined at
verification. Alongside the subset check above, effects_check rejects a
second claim on the same (callee address, entry-point hash, communication
commitment) triple within a segment (ledger/src/verify.rs:1610-1631); it runs
on every standard transaction (verify.rs:654); and the specification states
both checks as assertions (spec/intents-transactions.md:600, 611). With the
caller’s effects reaching the first public input of its proof, no third party
can fabricate or duplicate a claim. What the discipline does not prevent is
voluntary lending, and we have now observed it: kernel.claimContractCall
takes wholly chosen arguments from Compact source, and on node 2.1.0 with
ledger-9.1.0.0-rc.3 a contract exporting such a circuit claimed a root call
made by the client, which no circuit of the claimant had made; the transaction
was admitted (SucceedEntirely), and the ledger’s own ContractCall::context,
run over the admitted bytes off-node, resolves that call’s caller to the
claimant (find_map over the intent’s actions,
ledger/src/structure.rs:2685-2693) and the claimant’s own frame to no caller.
The three disciplines were refused at node admission by name, as
RealCallsSubsetCheckFailure, ClaimedCallsUniquenessFailure, and
CallSequencingViolation, the last of which forces a claimant to precede the
call it claims; and over refused bytes carrying two claims on one call, the
derivation taken alone prefers the earlier claimant to the contract that
genuinely made the call, so the arm is lendable but, on the checks exercised,
not overridable. A value read from slot 6 would therefore mean the contract
whose transcript claims this call, which need not be a contract that called
anything. No upstream test exercises the arm: every context in
ledger/tests/composable.rs is built with QueryContext::new, whose derived
default gives no caller. The arm is, as far as we can establish, specified,
implemented, unexercised upstream, and observed by us in the derivation over
admitted bytes, though never in a callee reading the value, which no released
Compact can express.
Non-goals
- Wallet or externally held identity and the unshielded-input-owner derivation,
any shielded equivalent, and the security status of
ownPublicKey()with its deprecation and diagnostics. All of that is MPS-0029. - Transaction origin as a value distinct from the immediate caller; goal 6 states the naming discipline that applies if one is ever offered.
- Re-entrancy control: provenance is an identity check, not a concurrency control.
- Delegated authority. Capability and signature seams remain the right answer,
as Aztec shows by needing AuthWit despite having an authenticated
msg_sender. - Witnesses and private state across the call boundary (MPS-0021), and dynamic selection of a callee implementation (the open CoIP draft, PR #628).
- The design of any primitive: no mechanism, type, or namespace is chosen here.
Relationship to adjacent work
MPS-0029. Both documents point at the same virtual machine context slot.
Nothing here contests MPS-0029’s account of ownPublicKey() or its request for
a caller primitive, and this document depends on neither, which is why
Requires is none. The factual position is the specification’s own: the
caller derivation is ordered, with the calling contract’s address first and
the UTXO-owner rule second (spec/contracts.md:205-211;
ledger/src/structure.rs:2678-2718). MPS-0029’s Goals scope the requested
return value to the unshielded-input case and the no-caller case, and its
Recommended MIP asks for a value “derived from verified unshielded UTXO input
ownership”, so a MIP written faithfully to those Goals leaves every goal below
unmet, which is why the cross-contract requirement is written down before any
such MIP is specified. Only one primitive should contend for the slot: a MIP
answering MPS-0029 that exposes it with full PublicAddress semantics,
Contract arm included, discharges goals 1 and 2 here. This is a separate
document rather than a comment because MPS-0001 holds that a document at
Proposed takes no further substantive modification, and because MPS-0029 has no
Discussion thread, its originating PR (#213) having merged on 2026/07/14.
MPS-0021. Its Expected Outcomes now include cryptographic binding of caller and callee proofs against replay or recombination. That is the closest upstream text to this problem, and it is a different property: binding two proofs is not the same as letting the callee read who the caller is.
CoIP-0002, the dynamic-selection CoIP draft, and the venue. Phase 1 lives
in the Compact repository rather than the MIPs repository, so a companion CoIP
may be the correct instrument for the language surface, with the MPS and its
MIP carrying the protocol semantics. The draft CoIP on dynamic selection of a
callee implementation (LFDT-Minokawa/compact PR #628, open, filed as
coips/coip-xxxx.md) strengthens the case: a callee resolved dynamically has
more reason to want to know who is calling it.
Use Cases
UC1: Contract-owned names
MIP-0007, under “Forward-looking authorization arms”, specifies arm 1 normatively: an owner record “MAY designate a contract address instead of a commitment”, and an owner-gated circuit then accepts “a call when it is made by the designated contract through a cross-contract call”. The arm’s availability condition is network availability of the capability: “implementations MUST NOT accept contract owners before the capability is available on the target network”. That condition names the wrong capability. Cross-contract calls have shipped in the stable Compact toolchain, and we have exercised them end to end on a ledger-9 localnet, although no public Midnight network runs that ledger yet. On whichever network the capability does reach, the arm stays unimplementable, because the registry is the callee and cannot identify its caller. The cost of the workaround is that a name held behind a designated key sits outside the account’s own multi-key revocation and recovery policy.
UC2: Callee-side composition policy
A callee cannot express that an operation may be driven only by a designated counterparty, nor that a circuit may run only as the transaction root rather than as a callee, even though composition is now live. NEAR’s instruction to mark callbacks private “so it can only be called by the contract itself” is the same class of rule on a platform whose callbacks arrive as separate transactions; the Midnight toolchain refuses re-entrant calls, so the self-call form of it does not arise here, and the two forms above are the ones blocked.
UC3: Attributing received value
A value-accepting callee credits a public mirror with no caller check of any kind, so escrow and settlement flows cannot bind received value to its payer. A claim circuit can be gated on an owner-signed bundle, as the account’s own seam is, but not on the identity of the contract that sent the value, which is the gate these flows need.
UC4: On-chain enforcement of dApp and wallet policy
Wallet-side evaluation is today the only place the “who is asking” question can be answered, so no dApp-connection request shape or policy vocabulary may assume a callee can verify who called it, and any rule the contract should also enforce is written twice, with a compromised client as the sole gate on the first copy. Our scoped-grants draft records the constraint directly: “a separate verifier contract would need the caller identity MPS-0029 records Compact lacks”.
Two candidate cases are explicitly not blocked by this gap, and saying so is part of the claim: account recovery is authorised behind the account’s own seam with no caller dependency, and Dust fee sponsorship is wallet-level rather than a contract paymaster. Goal 3 asks for the same candour: UC1, UC3, and UC4 need only goals 1 and 2, and goal 3 earns its place by tightening UC2 from a designated counterparty to a designated operation of one, its only consumer in our corpus.
Goals
- A callee can determine whether its invocation came from another contract or not, with the two cases unambiguously distinguishable.
- A callee can learn the
ContractAddressof its immediate caller, and of the immediate caller only. - A callee can learn which circuit of the calling contract made the call, in whatever identifier form the protocol already uses for entry points.
- Whatever answers goals 1 to 3 is unforgeable by the caller, by any other contract in the same intent, and by the submitting client, and is never witnessed. Aleo shows this is achievable without recursion, by making the caller a public input the verifier recomputes from the call graph.
- The semantics of every arm the derivation can produce are specified, including the absent case: the unshielded fallback consults only the intent’s unshielded offers, so a Dust-only fee path, or one with disagreeing input owners, yields no caller.
- Any origin-shaped value, if offered at all, is never interchangeable in
appearance with the immediate caller. NEAR’s
predecessor_account_idagainstsigner_account_idis the positive model; Solidity’s “Never usetx.originfor authorization” is the negative one. - The mechanism does not encourage “is this an externally owned account” reasoning. A Passport account is a contract, and ERC-4337 and EIP-7702 record what such checks cost elsewhere.
- It requires no proof recursion, which Compact does not have (MPS-0014), and adds no observer-visible information, which is achievable because the caller-callee edge is already public to an indexer.
- The failure surface when a callee refuses an invocation on provenance grounds is specified rather than left to implementations.
Expected Outcomes
Callee-side authorisation becomes expressible. MIP-0007’s contract-owner arm becomes implementable once cross-contract calls reach a public network, and contract-based accounts become first-class name owners and counterparties rather than names held behind a designated key. Value-accepting contracts attribute payments to their payer. Policy written twice today, in the wallet and in the circuit, is written once and enforced where it binds. The ergonomic delta is plain: a context read replaces a signature verification and its argument bytes at every composed seam, and those bytes, being call arguments, are formally disclosed under 0.34.0, while a context read need not appear in an argument at all; whether such a value is itself disclosed when a circuit branches on it is open question 5.
Open Questions
- Enforcement surface. Is a context read of the caller refused at node
admission with a
ReadMismatch, in the manner of a transcript read (onchain-vm/src/result_mode.rs:47-59), so that a provenance assertion fails at admission rather than failing to prove? We measured that forkernel.blockTimeLessThanon node 2.1.0, and the claim-discipline failures above likewise surfaced at admission rather than at proving, but neither is a context read, and the extrapolation is ours. - Cost and layering. The context array is already declared extensible “in
a minor version increment” (
spec/onchain-runtime.md:205-207). Do goals 1 and 2 therefore need only a language surface, while goal 3 needs new wire data and so a ledger schema change with redeploy consequences? And should goal 3 name the entry-point hash the ledger already matches on, or a contract-operation identity carrying verifier-key version, the two differing under contract maintenance? - Consent and binding. A callee has no say today in who names it, and a claimant need not have called at all: a claim is caller-side consent to be named, indistinguishable on chain from a call the claimant’s circuit made. Should the claim be bound to the runtime’s own call trace, so that only a contract that called may be named, or is consent the intended semantics? And if provenance is exposed, is caller-side suppression (Aztec permits a null sender for private-to-public calls, the callee deciding whether null is acceptable) worth the interface complexity where the edge is already public?
- Cross-intent composition. The matcher scans only the containing intent’s actions, and the uniqueness and subset checks are likewise keyed per segment, so what is the caller value for a call composed across intents of one transaction?
- In-circuit disclosure. Under 0.34.0’s
disclose()discipline, what is the status of a provenance value branched on inside a circuit, and can callee-side policy itself be private? We did not test this.
Recommended MIPs
- Cross-Contract Call Provenance in Compact. Specify what a callee may read
about its own invocation, covering goals 1 to 3, the unforgeability
obligation in goal 4, the arm-by-arm semantics in goal 5, the naming
discipline in goal 6, the failure surface in goal 9, and conformance
vectors. Upstream already carries an acceptance harness: the end-to-end
corpus holds a deliberately-red
kernel.caller()dapp whose cross-contract scenario asserts that a proxy’s forwarded call is observed as the proxy’s own contract address. This is the keystone the others hang from. If a MIP answering MPS-0029 exposes context slot 6 with fullPublicAddresssemantics including the Contract arm, it discharges goals 1 and 2 and this MIP narrows to goals 3 and 4. - MIP-0007 amendment: activate the contract-owned-names arm (follow-on). Re-state the availability condition for arm 1 against the capability it actually needs, and specify the owner check for a contract owner, so that contract-based accounts can own names under their own authorisation policy.
References
- Our evidence: the cross-contract-calls experiment, ten probes on a
ledger-9 localnet including atomic failure, unshielded value movement, and
shielded value movement (P0 to P7, 2026/09/03), caller derivation (P8,
2026/09/14), and voluntary lending with its three controls (P9,
2026/09/14), produced by Midnight Passport (ARC) at
midnightntwrk/passport,experiments/cross-contract-calls/. Pins: compactc 0.34.0, language 0.26.0, compact-runtime 0.19.0,ledger-9.1.0.0-rc.3, node2.1.0, indexer4.4.0-rc.2, proof server9.0.0-rc.6, midnight-js5.0.0-beta.7. P8 runs the ledger’s ownContractCall::context(rust/caller-context/, built frommidnightntwrk/midnight-ledgerat the same git tag) over our transaction bytes off-node, including bytes byte-identical to a finalised block; P9’s refusals are the node’s, named in its log and tied to each captured arm by transaction hash. Compiler probes, 2026/09/14, reconfirming thatkernel.calleris rejected on 0.33.0-rc.2, 0.34.0-rc.0, and 0.34.0, and that both an impostor contract forwarding a stored address and a contract emittingkernel.claimContractCallwith chosen arguments compile. No circuit of ours reads context slot 6, so no node has been observed evaluating such a read. One ARC working document is quoted in UC4 and is not a published standard: our scoped-grants MIP draft (midnightntwrk/passportPR #154, open; the quoted sentence is requirement R1). - Ledger sources,
midnightntwrk/midnight-ledgerat tagledger-9.1.0.0-rc.3(commit4823b53):spec/contracts.md:184-282(the ordered caller derivation in prose at205-211, thecontext()pseudocode at226-261, andcalls_with_seqat267-280);spec/intents-transactions.md:594-611;spec/onchain-runtime.md:205-207;ledger/src/structure.rs:2678-2718(ContractCall::context, the caller search at2685-2693) and2724-2747(calls_with_seq, the matching predicate at2742-2744);onchain-runtime/src/context.rs:308-318,578, and853-894;coin-structure/src/coin.rs:719-722and799-806;ledger/src/verify.rs:654,1113-1200(relate_nodes),1431(effects_check) with the uniqueness check at1610-1631and the subset check at1633-1653,1946-1960(public_inputs), and1962-2004(binding_input, the transcript effects serialised at1978and1988);ledger/src/construct.rs:1049-1076;onchain-vm/src/result_mode.rs:47-59. - Language sources,
LFDT-Minokawa/compactat branchmain, fetched 2026/09/14:compiler/midnight-ledger.ss(blobacf07ec2a46b7557610197207ebbd11ab60b3cf4), the Kernel ADT at159,claimContractCallat195-210, andselfat256;coips/coip-0002.md; the dynamic-selection CoIP draft as PR #628; and the compactc 0.34.0 release note (doc/release-notes/toolchain-0.34.0.md), which states thatownPublicKey()“always names the transaction submitter, never the calling contract”. The acceptance harness is upstream’s own:midnightntwrk/compact-end-2-endat commitb3a0504(2026/08/21),dapps/caller/contracts/Caller.compact:23-27(thecapture()circuit readingkernel.caller()),dapps/caller/contracts/Proxy.compact:14-26(the cross-calling proxy),dapps/caller/src/main.ts:158-190(the scenario asserting the proxy’s own contract address), anddapps/caller/README.md, section Current compile gap, which records the state as expected red. We compiledCaller.compactagainst three toolchains; we did not run the scenario. - Related documents: MPS-0029 (caller identity access in Compact circuits), MPS-0021 (Phase 2, contract to contract), MPS-0014 (proof verification and recursion), MPS-0001 (the MPS process), and MIP-0007 arm 1 (contract-owned names).
- Prior art: NEAR
predecessor_account_idagainstsigner_account_id, with its guidance on private callbacks; Aleo (snarkVM), where the caller is a public input the verifier recomputes from the call graph andtransfer_public_as_signeris separately named; Aztec, whoseCallContextcarriesmsg_senderand the callee’s ownfunction_selector, whose private kernel is recursive, and whose AuthWit remains necessary for delegated authority; Solana, whose instructions sysvar excludes inner instructions; Starknetget_caller_address; CosmWasmMessageInfo; the Move book on the witness and capability patterns; EIP-7, ERC-2771, ERC-4337, and EIP-7702; Solidity’s security considerations ontx.originand on re-entrancy; and Hardy (1988) on the confused deputy.
Acknowledgements
Alejandro Pestchanker, whose MPS-0029 diagnosed the ownPublicKey() hazard and
located the caller slot; Jonathan Sobel and Karmel E for the CoIP-0002 and
MPS-0021 design record, against which this document positions itself; and the
IOG ARC department, whose reviewers read the cross-contract experiment this
document rests on.
Copyright
This MPS is licensed under CC-BY-4.0.