Skip to content

Latest commit

 

History

History
267 lines (209 loc) · 10.5 KB

File metadata and controls

267 lines (209 loc) · 10.5 KB

MandateVault protocol specification

Role of this document: normative technical reference for the deployed MandateVault transfer 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.

1. Domain constants

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

2. Canonical intent preimage

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.

3. executeTransfer derivation rules

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, code 4) relaxes the deterministic point relation executedAmount == activeCap to the bounded space 0 < executedAmount ≤ activeCap ∧ executedAmount < originalAmount, so the agent may choose any candidate inside the envelope. This is not part of the deployed MandateVault path above and is not reflected in the pinned vectors; see §9.

4. Canonical event

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.

5. Off-chain identities

Canonical calldata

executeCalldata is the executeTransfer selector followed by ABI-encoded arguments:

recipient, originalAmount, executedAmount,
initialIntentHash, executedIntentHash,
recoveryCode(uint8), expectedPolicyVersion(uint64)

calldataHash = keccak256(executeCalldata).

Execution payload identity

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.

Causal idempotency 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.

6. Pinned Solidity ↔ viem vectors

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.

7. Recomputable resolution record

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.

8. KeeperHub integration contract

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.

D2 — fail closed before broadcast

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.

D3 — exact canonical calldata

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.

9. V2 bounded-adaptation experiment (offline, non-normative)

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 recipient value 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_executes makes this boundary explicit: a different but allowlisted recipient still executes.
  • Binding the original intent independently (an owner-signed mandate / original recipient) is future work.