Role of this document: normative technical reference for the deployed
MandateVaulttransfer path and its off-chain mirror. Reviewers should start with the submission dossier and use the architecture reference for trust boundaries and broader scope.
Any divergence between the Solidity encoding in src/MandateVault.sol and the viem mirror in
packages/mandate-protocol/ breaks the pinned cross-vectors in CI.
| Constant | Value | Where |
|---|---|---|
ACTION_TRANSFER |
1 (uint8) |
Solidity and TypeScript |
RECOVERY_NONE |
0 |
Solidity and TypeScript |
RECOVERY_REDUCE_TO_LIMIT |
1 |
Solidity and TypeScript |
RECOVERY_EXACT_APPROVAL |
2, reserved and non-executable |
Solidity and TypeScript |
| USDC decimals | 6 |
toAtomicUSDC |
| Demonstration chain | Base Sepolia 84532 |
vectors and payload identities |
An intent hash binds action class, chain, asset, recipient, amount, and policy version:
intentHash = keccak256(abi.encode(
uint8 ACTION_TRANSFER,
uint64 chainId,
address asset,
address recipient,
uint256 amount,
uint64 policyVersion
))
Solidity uses MandateVault._hashTransferIntent; TypeScript uses hashTransferIntent with
encodeAbiParameters([uint8,uint64,address,address,uint256,uint64], ...). The bytes must be
identical.
The deployed vault applies these guards in order, each with a distinct custom error:
| # | Guard | Error |
|---|---|---|
| 1 | vault is not frozen | VaultFrozen |
| 2 | expected policy version equals the active version | StalePolicy |
| 3 | recipient is non-zero | ZeroAddress |
| 4 | recipient is allowlisted | RecipientNotAllowed |
| 5 | executed amount is non-zero | ZeroAmount |
| 6a | NONE: original equals executed |
RecoveryActionMismatch |
| 6b | REDUCE_TO_LIMIT: original exceeds cap |
RecoveryNotRequired |
| 6c | REDUCE_TO_LIMIT: executed equals cap |
InvalidRecoveredAmount |
| 6d | REDUCE_TO_LIMIT: executed is lower than original |
AmountNotReduced |
| 6e | EXACT_APPROVAL |
RecoveryActionMismatch |
| 6f | unknown code | InvalidRecoveryCode |
| 7 | executed amount does not exceed cap | AmountOverCap |
| 8 | initial hash equals the on-chain re-derivation | InitialIntentHashMismatch |
| 9 | executed hash equals the on-chain re-derivation | ExecutedIntentHashMismatch |
The verified relation for REDUCE_TO_LIMIT is exactly:
originalAmount > activeCap
∧ executedAmount == activeCap
∧ executedAmount < originalAmount
On success, the vault transfers the asset and emits MandatedExecution. The off-chain mirror is
verifyAmountDerivation.
V2 contrast (offline, non-normative). On the substitutable
ResolvingVault, a separate resolver (BoundedAdaptationResolver, code4) relaxes the deterministic point relationexecutedAmount == activeCapto the bounded space0 < executedAmount ≤ activeCap ∧ executedAmount < originalAmount, so the agent may choose any candidate inside the envelope. This is not part of the deployedMandateVaultpath above and is not reflected in the pinned vectors; see §9.
MandatedExecution is the trustless resolution record. activeCap is read from vault state, never
supplied by the caller.
event MandatedExecution(
bytes32 indexed initialIntentHash,
bytes32 indexed executedIntentHash,
uint64 indexed policyVersion,
uint8 recoveryCode,
address target,
address asset,
uint256 activeCap,
uint256 originalAmount,
uint256 executedAmount
);Its canonical topic is:
keccak256("MandatedExecution(bytes32,bytes32,uint64,uint8,address,address,uint256,uint256,uint256)")
= 0xfba25103189f88cf8bef537950b32e7e4081438ee3d530ab67405d7ed6a86622
Policy history is reconstructed from PolicyInitialized, RecipientAllowed, RecipientRemoved,
MaxTransferLowered, MaxTransferRaised, RaiseCapProposed, RaiseCapCancelled, and
VaultFrozenPermanently.
executeCalldata is the executeTransfer selector followed by ABI-encoded arguments:
recipient, originalAmount, executedAmount,
initialIntentHash, executedIntentHash,
recoveryCode(uint8), expectedPolicyVersion(uint64)
calldataHash = keccak256(executeCalldata).
executionPayloadHash = keccak256(abi.encode(
uint64 chainId,
address vaultAddress,
uint256 value,
bytes32 calldataHash
))
The HTTP simulate flag is deliberately excluded. Simulation and submission of the same operation
therefore share one payload identity.
idempotencyKey = keccak256(abi.encode(
bytes32 initialIntentHash,
bytes32 executedIntentHash,
uint8 recoveryCode,
uint64 policyVersion,
address vaultAddress,
uint64 chainId
))
| Condition | Outcome |
|---|---|
| keys differ | NEW_KEY |
| same key and same body | DEDUP |
| same key and different body | CONFLICT_409 |
The key identifies causality, not only the final economic effect.
Fixed inputs: chain ID 84532, asset 0x…0aBc, recipient 0x…A11E, vault 0x…dEaD, and
policy version 1.
| Vector | Value |
|---|---|
| intent, 150 USDC | 0x7e28b557045ba713f70ac9c9b96c7670e71b082f9052ef7c69c3cbbefc1f74e7 |
| intent, 20 USDC | 0x6dced3e681dddcfc7a82409099aaa7527399cdda98b552897584dd7e2d692ea7 |
| intent, 19 USDC | 0x6b85b4a1eb4101ac8be2416f6c35afd3b197ca4dde6f748cc3edecd43508dd84 |
calldata hash, 150→20 REDUCE_TO_LIMIT |
0xa27294f80e6989faa03aa381b45d8143fdb7b5267e3e1f84b7dcc7f468eecd4c |
| execution payload hash | 0x77d5ee844bcedca94e82c149ff2e11484286819a4308125c2a71335259ab791c |
| causal idempotency key | 0x10b8c1842565d1bb264009fcaea4285b55fc0a9bb277268c26fd31a8368824b6 |
MandatedExecution topic |
0xfba25103189f88cf8bef537950b32e7e4081438ee3d530ab67405d7ed6a86622 |
ERC-20 Transfer topic |
0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef |
These values are pinned independently in Foundry and Vitest.
verifyMandatedExecution extracts the event from receipt logs and checks the following deterministic
order:
TX_NOT_SUCCESS
→ TX_HASH_MISMATCH
→ CHAIN_MISMATCH
→ VAULT_MISMATCH
→ EVENT_MISSING / EVENT_NOT_UNIQUE / EVENT_DECODE
→ event-to-certificate field concordance
→ AMOUNT_DERIVATION
→ INITIAL_HASH_MISMATCH / EXECUTED_HASH_MISMATCH
→ ERC20_TRANSFER_MISMATCH / ERC20_TRANSFER_MISSING
The active cap comes from the vault event. For the USDC scenario, the verifier also requires a real
Transfer(vault → recipient, executedAmount) log.
KeeperHub Direct Execution receives the canonical function name and arguments:
POST https://app.keeperhub.com/api/execute/contract-call
Authorization: Bearer kh_...
Idempotency-Key: <causal idempotencyKey>
contractAddress = <MandateVault>
chainId = 84532
functionName = executeTransfer
functionArgs = [recipient, originalAmount, executedAmount,
initialIntentHash, executedIntentHash,
recoveryCode, expectedPolicyVersion]
simulate = true // preflight only
uint256 values are decimal strings, bytes32 values are hexadecimal, and no native value is sent
for the USDC vault.
The configured transport must expose non-sponsored-guaranteed before any request without
simulate:true can be sent. Production defaults to unknown and blocks before the real POST.
After confirmation, sponsorship must be exactly false; true, null, or a missing field is fatal.
For a new compliant direct, non-sponsored path, confirmed transaction input must equal
executeCalldata byte for byte. A mismatch is fatal before receipt recomputation.
Confirmation and receipt status are obtained from GET /api/execute/{executionId}/status.
Historical transport evidence and its policy chronology are intentionally documented only in the
submission dossier, not retroactively evaluated against D2/D3.
This section is experimental. It is not part of the deployed MandateVault path, the pinned
vectors, or the historical Base Sepolia evidence. It runs on ResolvingVault (the substitutable seam)
with one added resolver, src/resolvers/BoundedAdaptationResolver.sol (code 4), and is proven only
by test/ResolvingVaultV2Adaptation.t.sol. No constant, ABI, hash, event, or vector of the deployed
path changes.
Admissibility predicate. The resolver is a pure admissibility verifier of a candidate the agent supplies; it does not derive the candidate:
EXECUTE iff bound > 0 ∧ proposed > 0 ∧ requested > bound
∧ proposed ≤ bound ∧ proposed < requested
INVALID otherwise
Where REDUCE_TO_LIMIT admits the single point proposed == bound, this admits the space
proposed ∈ (0, bound], strictly below the recorded request. Under one mandate (bound 100, recorded
request 150): 150→83, 150→71, 150→100 execute; 150→101 and 150→150 revert; the same mandate
under REDUCE_TO_LIMIT admits only 150→100. ResolvingVault remains the unchanged enforcer: it
re-checks the recipient allowlist, the hard bound (AmountOverCap), the active policyVersion
(StalePolicy), and both intent hashes, regardless of the resolver.
Scope and honest limitations.
- The candidate is chosen by the agent (external caller); the resolver only verifies membership in the admissible space. In V2 vocabulary the resolver is an admissibility verifier, not a proposer.
- V2 does not claim a security primitive stronger than a programmable wallet policy.
- The original request is operator-asserted, not owner-signed in this MVP.
- Recipient continuity is allowlist-constrained and record-internal — one
recipientvalue builds both intent hashes — and is not a cryptographic proof that the candidate recipient equals an independently committed original recipient.test_v2_different_allowlisted_recipient_executesmakes this boundary explicit: a different but allowlisted recipient still executes. - Binding the original intent independently (an owner-signed mandate / original recipient) is future work.