Condition-Hashed Pacts: How to Make a Behavioral Commitment Tamper-Evident on Base L2
A behavioral pact stored only in a database can be modified, backdated, or denied. By publishing a deterministic hash of pact conditions to Base L2, you make the commitment tamper-evident, publicly verifiable, and timestamped forever.
Continue the reading path
Topic hub
Behavioral ContractsThis page is routed through Armalo's metadata-defined behavioral contracts hub rather than a loose category bucket.
The Problem With Off-Chain Behavioral Commitments
Every AI agent deployment involves a moment of commitment. An operator deploys an agent and declares: this agent will not execute financial transactions over $10,000 without human approval. This agent will not share customer PII with third parties. This agent will escalate to a human when confidence falls below 80%.
These commitments are real. They shape how buyers trust the agent. They determine what insurance coverage is available. They define the agent's legal obligations under the EU AI Act. They set the standard against which behavioral drift is measured.
But where do these commitments live? In almost every AI deployment today, they live in a database. A row in a pacts table. A JSON blob. A YAML config file. Text in a README.
And a commitment that lives only in a database is not a commitment at all. It is a claim.
Here is why this matters operationally:
The backdating problem. When an incident occurs — an agent executes a transaction it shouldn't have — the first question is: what were the stated constraints at the time? Without a tamper-evident record, any party can claim the constraints were different. An operator can silently update the pact database to remove the constraint that was violated. A vendor can claim their agent always had a carve-out for this scenario. The incident response team has no way to prove what conditions were actually in effect.
The denial problem. Buyers and operators enter into implicit contracts when deploying AI agents. "We agreed the agent would only operate in read-only mode for the first 30 days." "That was just the default — the agent's actual pact always allowed writes." Who is right? Without a tamper-evident timestamp proving when the pact was established and what it contained, disputes become definitional arguments with no resolution path.
The drift problem. Behavioral pacts are supposed to evolve — agents earn expanded autonomy as they demonstrate reliability. But without tamper-evident versioning, it is impossible to audit whether a scope expansion was legitimately earned or quietly inserted to cover a capability the agent had been exercising without authorization. The pact history becomes untrustworthy, which makes the trust score built on top of it equally untrustworthy.
The audit problem. Compliance teams, insurers, and regulatory bodies need to verify what an AI agent was authorized to do at a specific point in time. "Show me the exact conditions this agent was operating under on March 15, 2026, at 14:32 UTC." A mutable database cannot answer this question with cryptographic certainty. At best you have audit logs, which are themselves mutable.
The third-party trust problem. When an agent from Company A wants to collaborate with an agent from Company B, Company B's agent needs to verify Company A's agent's behavioral commitments before accepting instructions. Querying Company A's database is trusting Company A's honesty. That is not verification — that is delegation.
The root issue is that every off-chain behavioral commitment system relies on trusting the operator's database. It is integrity by policy rather than integrity by architecture.
Condition-hashed pacts on Base L2 solve this at the infrastructure level.
What Condition-Hashed Pacts Solve
The core insight is simple: if you publish a cryptographic hash of a pact's conditions to a public blockchain, you establish an immutable, timestamped anchor that anyone can verify without trusting any intermediary.
Here is what changes when you add a Base L2 commitment layer:
Tamper-evidence by construction. A SHA-256 hash is a one-way function. Given hash(conditions), you cannot recover conditions. But given conditions, you can verify sha256(canonicalize(conditions)) == stored_hash in milliseconds. If anyone modifies even a single character of the pact conditions — adds a word, changes a threshold, removes a constraint — the hash changes. The on-chain record becomes definitive proof that the presented conditions are not what was originally committed to.
Authoritative timestamping. Base L2 block timestamps are produced by validators and included in the block header. When your pact commitment transaction is mined, the block timestamp becomes the authoritative "signed on" date. This is not your application server's clock — it is a timestamp that validators across the network have agreed on and that is computationally expensive to falsify.
Public verifiability. Anyone with the pact ID can call PactRegistry.verify(pactId, conditionsHash) on Base L2 and receive a cryptographic answer: was this hash committed on-chain, when, and is it still valid? This verification requires no trust in Armalo, no API key, no account. It is as verifiable as checking a wallet balance.
Revocation resistance. Even if Armalo's servers go offline, are seized, or are destroyed, the on-chain record persists. The commitment lives in the Base L2 state machine, replicated across thousands of validators. An agent's behavioral history cannot be disappeared by taking down a database.
Cross-party trustlessness. When Company B's agent queries Company A's agent's behavioral commitments, it does not need to trust Company A's database. It queries the smart contract directly. The verification is trustless because it relies on cryptographic proof rather than organizational trust.
Jurisdictional portability. A blockchain transaction satisfies tamper-evident record requirements across multiple legal frameworks simultaneously — EU AI Act Article 12 (technical documentation), GDPR audit requirements, Wyoming DAO LLC enforceable agreements, and US evidence admissibility standards for electronically stored information.
None of these properties require the pact conditions themselves to be on-chain. Only the hash is stored. This preserves privacy (conditions may contain sensitive business logic), keeps gas costs minimal, and keeps the on-chain footprint small while providing all the cryptographic guarantees.
The Condition Hashing Protocol
The protocol has four steps. Each step is deterministic — given the same input, every implementation must produce the same output. This is what makes independent verification possible.
Step 1: Canonical Serialization
The first challenge is that JSON has no canonical form. {"a": 1, "b": 2} and {"b": 2, "a": 1} represent the same data but produce different byte sequences and therefore different hashes.
Canonical serialization eliminates this ambiguity:
function canonicalize(obj: unknown): string {
if (obj === null || typeof obj!== 'object') {
return JSON.stringify(obj);
}
if (Array.isArray(obj)) {
// Arrays preserve order — order is semantically significant
return '[' + obj.map(canonicalize).join(',') + ']';
}
// Objects: sort keys lexicographically, recurse into values
const sorted = Object.keys(obj as Record<string, unknown>)
.sort()
.map(key => `${JSON.stringify(key)}:${canonicalize((obj as Record<string, unknown>)[key])}`)
.join(',');
return '{' + sorted + '}';
}
The rules are:
- Sort object keys lexicographically at every nesting level
- Preserve array order —
["read", "write"]and["write", "read"]are different permissions - No whitespace — no spaces, no newlines, no indentation
- Standard JSON encoding for all primitive types — numbers without trailing zeros, strings with standard escape sequences, null as
null, booleans astrue/false
For a pact with conditions:
{
"maxTransactionAmount": 10000,
"allowedOperations": ["read", "write"],
"requiresApproval": true,
"escalationThreshold": 0.8
}
The canonical form is:
{"allowedOperations":["read","write"],"escalationThreshold":0.8,"maxTransactionAmount":10000,"requiresApproval":true}
Note that keys are alphabetically sorted but array elements are unchanged.
This canonical form is then encoded as UTF-8 bytes. No BOM. No trailing newline.
Step 2: Hash Computation
SHA-256 of the canonical bytes produces the conditions hash:
import { createHash } from 'crypto';
function hashConditions(conditions: PactConditions): string {
const canonical = canonicalize(conditions);
const bytes = Buffer.from(canonical, 'utf-8');
return createHash('sha256').update(bytes).digest('hex');
}
The output is a 64-character hex string (32 bytes) — the conditions fingerprint.
For our example conditions, this might produce:
a3f8c2d91e4b7865f0c3a1d2e5f8b9c4d7e0a3f6c9d2e5f8a1b4c7d0e3f6a9b2
Change any character of the canonical input and the output changes completely — this is the avalanche effect that makes SHA-256 tamper-evident.
Why SHA-256 and not keccak256 (the Ethereum hash function)? SHA-256 is computable in any language on any platform, including within the agent runtime, CI systems, and audit tooling. keccak256 requires Ethereum-specific libraries. We use SHA-256 for the conditions hash and can convert to bytes32 for on-chain storage.
Step 3: Metadata Commitment Construction
The conditions hash alone is not sufficient for the on-chain record. We need to bind it to:
- The specific agent making the commitment (
agentId) - The specific pact (
pactId) - The version number (for amendment tracking)
- The chain ID (prevents replay across networks)
This binding is constructed using ABI encoding:
import { encodeAbiParameters, keccak256 } from 'viem';
function buildCommitmentHash(
agentId: string,
pactId: string,
conditionsHash: string,
version: number,
chainId: number
): `0x${string}` {
const encoded = encodeAbiParameters(
[
{ type: 'bytes32' }, // agentId (UUID as bytes32)
{ type: 'bytes32' }, // pactId (UUID as bytes32)
{ type: 'bytes32' }, // conditionsHash
{ type: 'uint256' }, // version
{ type: 'uint256' }, // chainId
],
[
uuidToBytes32(agentId),
uuidToBytes32(pactId),
`0x${conditionsHash}` as `0x${string}`,
BigInt(version),
BigInt(chainId),
]
);
return keccak256(encoded);
}
function uuidToBytes32(uuid: string): `0x${string}` {
// Remove hyphens from UUID and left-pad to 32 bytes
const hex = uuid.replace(/-/g, '');
return `0x${hex.padStart(64, '0')}` as `0x${string}`;
}
This produces the pact commitment hash — the 32-byte value that gets stored on-chain and represents the complete, unforgeable binding between an agent, a specific set of pact conditions, and a specific point in time.
Step 4: Base L2 Publication
With the commitment hash constructed, the final step is publishing it to the PactRegistry smart contract on Base L2:
import { createWalletClient, http } from 'viem';
import { base } from 'viem/chains';
import { privateKeyToAccount } from 'viem/accounts';
async function publishToBaseL2(
pactId: string,
conditionsHash: string,
metadataUri: string,
signingKey: `0x${string}`
): Promise<`0x${string}`> {
const account = privateKeyToAccount(signingKey);
const walletClient = createWalletClient({
account,
chain: base,
transport: http(process.env.BASE_RPC_URL),
});
const pactIdBytes32 = uuidToBytes32(pactId);
const conditionsHashBytes32 = `0x${conditionsHash}` as `0x${string}`;
const txHash = await walletClient.writeContract({
address: PACT_REGISTRY_ADDRESS,
abi: pactRegistryAbi,
functionName: 'commit',
args: [pactIdBytes32, conditionsHashBytes32, metadataUri],
});
return txHash;
}
The transaction is submitted to Base L2. Within seconds it is included in a block. The block timestamp is permanent. The commitment is immutable.
Gas cost on Base L2: approximately 0.0002 ETH ($0.001–0.01 depending on network conditions). Compared to Ethereum mainnet (0.002–0.02 ETH, $5–50), Base L2 makes it economically rational to commit every pact individually rather than batching.
Architecture: Pact → Hash → IPFS → L2
Before diving into the smart contract, here is the full architecture as a flow diagram:
┌─────────────────────────────────────────────────────────────────────┐
│ PACT CREATION FLOW │
└─────────────────────────────────────────────────────────────────────┘
Agent Operator
│
│ 1. Define pact conditions
│ { maxAmount: 10000, requiresApproval: true,... }
▼
┌──────────────┐
│ Armalo API │
│ /v1/pacts │
└──────┬───────┘
│
│ 2. Validate + store in Postgres
│ pacts table: id, conditions, agentId,...
│
▼
┌──────────────────────────────────┐
│ COMMITMENT PIPELINE │
│ │
│ canonicalize(conditions) │
│ ↓ │
│ sha256(canonical_bytes) │ ← conditionsHash (32 bytes)
│ ↓ │
│ uploadToIPFS(full_pact_json) │ ← metadataUri (IPFS CID)
│ ↓ │
│ buildCommitmentHash( │
│ agentId, pactId, │
│ conditionsHash, version, │
│ chainId) │
└──────────────┬───────────────────┘
│
┌───────┴───────────────────────────────────┐
│ │
▼ ▼
┌─────────────┐ ┌─────────────────┐
│ IPFS Node │ │ Base L2 │
│ │ │ │
│ Full pact │ │ PactRegistry │
│ JSON pinned│ │.commit( │
│ by: │ │ pactId, │
│ - Armalo │ │ condHash, │
│ - Agent │ │ ipfsCid │
│ - Pinata │ │ ) │
└─────────────┘ │ │
│ → tx hash │
│ → block.ts │
└────────┬────────┘
│
│ 3. Store commitment
│ back in Postgres
│ pact.onChainTxHash
│ pact.conditionsHash
│ pact.ipfsCid
│
▼
COMMITTED ✓
┌─────────────────────────────────────────────────────────────────────┐
│ VERIFICATION FLOW │
└─────────────────────────────────────────────────────────────────────┘
Third-Party Verifier (no Armalo account needed)
│
│ 1. Get pact conditions from:
│ a) Armalo API (if online)
│ b) IPFS (if Armalo offline, using stored CID)
▼
┌──────────────────────────────────┐
│ LOCAL VERIFICATION │
│ │
│ sha256(canonicalize(conditions))│ ← compute hash locally
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ BASE L2 QUERY │
│ │
│ PactRegistry.verify( │
│ pactId, │
│ computedHash │
│ ) │
│ │
│ → valid: true/false │
│ → timestamp: unix epoch │
│ → revoked: true/false │
└──────────────┬───────────────────┘
│
┌───────┴───────────┐
│ │
▼ ▼
valid = true valid = false
timestamp matches TAMPERED ✗
VERIFIED ✓
The critical insight from this diagram: the verification path never touches Armalo's servers. It goes directly from the verifier to IPFS (for the full conditions) and Base L2 (for the hash commitment). Armalo can be completely offline and third parties can still verify every pact's integrity.
The PactRegistry Smart Contract
Here is the complete PactRegistry.sol with annotation:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/**
* @title PactRegistry
* @notice On-chain registry for behavioral pact commitments.
*
* Stores a mapping from pactId (bytes32) to a Commitment struct
* containing the conditionsHash, registrant address, block timestamp,
* revocation status, and IPFS CID for the full pact JSON.
*
* This contract does not store conditions themselves — only their hash.
* This preserves privacy and minimizes gas costs while providing
* tamper-evident proof of commitment.
*
* Deployed on Base L2 (chainId: 8453).
*/
contract PactRegistry {
// ─── Data Structures ────────────────────────────────────────────────────
/**
* @notice A committed behavioral pact.
* @param conditionsHash SHA-256 hash of canonicalized pact conditions
* @param registrant Address that submitted the commitment
* @param timestamp Block timestamp when commitment was recorded
* @param revoked Whether the pact has been revoked
* @param metadataUri IPFS CID for full pact JSON (for verification)
*/
struct Commitment {
bytes32 conditionsHash;
address registrant;
uint256 timestamp;
bool revoked;
string metadataUri;
}
// ─── State ───────────────────────────────────────────────────────────────
/// @notice Maps pactId → Commitment
mapping(bytes32 => Commitment) public commitments;
/// @notice Maps registrant address → list of committed pactIds
/// Used to enumerate all pacts committed by a given agent wallet
mapping(address => bytes32[]) public agentCommitments;
/// @notice Maps pactId → amendment history (previous conditionsHashes)
mapping(bytes32 => bytes32[]) public amendmentHistory;
// ─── Events ──────────────────────────────────────────────────────────────
/**
* @notice Emitted when a new pact commitment is recorded.
* @param pactId The pact identifier (UUID as bytes32)
* @param conditionsHash SHA-256 hash of the canonical conditions
* @param registrant Address of the committing agent/operator
* @param timestamp Block timestamp of commitment
*/
event PactCommitted(
bytes32 indexed pactId,
bytes32 indexed conditionsHash,
address indexed registrant,
uint256 timestamp
);
/**
* @notice Emitted when a pact is revoked.
* @param pactId The revoked pact
* @param registrant Address that performed the revocation
* @param timestamp Block timestamp of revocation
*/
event PactRevoked(
bytes32 indexed pactId,
address indexed registrant,
uint256 timestamp
);
/**
* @notice Emitted when a pact is amended (new version committed).
* @param pactId The amended pact
* @param previousHash The previous conditions hash
* @param newHash The new conditions hash
* @param registrant Address that performed the amendment
* @param timestamp Block timestamp of amendment
*/
event PactAmended(
bytes32 indexed pactId,
bytes32 previousHash,
bytes32 newHash,
address indexed registrant,
uint256 timestamp
);
// ─── Write Functions ─────────────────────────────────────────────────────
/**
* @notice Commit a new behavioral pact.
* @param pactId Unique identifier for this pact (UUID as bytes32)
* @param conditionsHash SHA-256 hash of canonical pact conditions
* @param metadataUri IPFS CID for the full pact JSON
*
* Requirements:
* - pactId must not already be committed (prevents overwrites)
* - conditionsHash must be non-zero
* - metadataUri must be non-empty
*/
function commit(
bytes32 pactId,
bytes32 conditionsHash,
string calldata metadataUri
) external {
require(
commitments[pactId].timestamp == 0,
"PactRegistry: pact already committed"
);
require(
conditionsHash!= bytes32(0),
"PactRegistry: conditions hash cannot be zero"
);
require(
bytes(metadataUri).length > 0,
"PactRegistry: metadata URI cannot be empty"
);
commitments[pactId] = Commitment({
conditionsHash: conditionsHash,
registrant: msg.sender,
timestamp: block.timestamp,
revoked: false,
metadataUri: metadataUri
});
agentCommitments[msg.sender].push(pactId);
emit PactCommitted(
pactId,
conditionsHash,
msg.sender,
block.timestamp
);
}
/**
* @notice Revoke a committed pact.
*
* Revocation does not delete the record — it marks it as revoked
* while preserving the original commitment for historical audit.
* The original commitment timestamp, conditions hash, and registrant
* remain permanently on-chain.
*
* @param pactId The pact to revoke
*
* Requirements:
* - Caller must be the original registrant
* - Pact must not already be revoked
*/
function revoke(bytes32 pactId) external {
Commitment storage c = commitments[pactId];
require(
c.registrant == msg.sender,
"PactRegistry: caller is not the registrant"
);
require(
c.timestamp!= 0,
"PactRegistry: pact not found"
);
require(
!c.revoked,
"PactRegistry: pact already revoked"
);
c.revoked = true;
emit PactRevoked(pactId, msg.sender, block.timestamp);
}
/**
* @notice Amend an existing pact with a new set of conditions.
*
* Amendments create a new commitment for the same pactId,
* preserving the previous conditionsHash in amendmentHistory.
* This enables full version history while keeping the current
* state queryable via the primary commitments mapping.
*
* Only the original registrant can amend a pact.
*
* @param pactId The pact to amend
* @param newConditionsHash SHA-256 hash of the new conditions
* @param newMetadataUri IPFS CID for the new full pact JSON
*/
function amend(
bytes32 pactId,
bytes32 newConditionsHash,
string calldata newMetadataUri
) external {
Commitment storage c = commitments[pactId];
require(
c.registrant == msg.sender,
"PactRegistry: caller is not the registrant"
);
require(
c.timestamp!= 0,
"PactRegistry: pact not found"
);
require(
!c.revoked,
"PactRegistry: cannot amend revoked pact"
);
require(
newConditionsHash!= bytes32(0),
"PactRegistry: new conditions hash cannot be zero"
);
require(
newConditionsHash!= c.conditionsHash,
"PactRegistry: new hash identical to current hash"
);
// Archive previous hash
amendmentHistory[pactId].push(c.conditionsHash);
bytes32 previousHash = c.conditionsHash;
// Update commitment
c.conditionsHash = newConditionsHash;
c.metadataUri = newMetadataUri;
// Note: timestamp reflects original commitment date
// Use event timestamp for amendment date
emit PactAmended(
pactId,
previousHash,
newConditionsHash,
msg.sender,
block.timestamp
);
}
// ─── View Functions ──────────────────────────────────────────────────────
/**
* @notice Verify whether a given conditionsHash matches the on-chain record.
*
* This is the primary verification function for third-party verifiers.
* It returns:
* - valid: true if the hash matches AND the pact is not revoked
* - timestamp: the block timestamp when the commitment was made
*
* @param pactId The pact to verify
* @param conditionsHash The hash to check against the on-chain record
* @return valid Whether the hash matches and the pact is active
* @return timestamp When the commitment was recorded
*/
function verify(
bytes32 pactId,
bytes32 conditionsHash
) external view returns (bool valid, uint256 timestamp) {
Commitment memory c = commitments[pactId];
valid = (
c.timestamp!= 0 &&
c.conditionsHash == conditionsHash &&
!c.revoked
);
timestamp = c.timestamp;
}
/**
* @notice Verify a conditions hash against the historical record.
*
* Used in dispute resolution to verify that a hash was valid at
* a specific point in time, even if the pact has since been amended.
*
* @param pactId The pact to query
* @param conditionsHash The hash to look for in history
* @return found Whether the hash was ever committed for this pact
* @return wasActive Whether it was the active version (not yet amended)
*/
function verifyHistorical(
bytes32 pactId,
bytes32 conditionsHash
) external view returns (bool found, bool wasActive) {
Commitment memory c = commitments[pactId];
// Check current
if (c.conditionsHash == conditionsHash) {
return (true,!c.revoked);
}
// Check amendment history
bytes32[] memory history = amendmentHistory[pactId];
for (uint256 i = 0; i < history.length; i++) {
if (history[i] == conditionsHash) {
return (true, false); // found but superseded
}
}
return (false, false);
}
/**
* @notice Get the full commitment record for a pact.
* @param pactId The pact to retrieve
*/
function getCommitment(
bytes32 pactId
) external view returns (
bytes32 conditionsHash,
address registrant,
uint256 timestamp,
bool revoked,
string memory metadataUri
) {
Commitment memory c = commitments[pactId];
return (
c.conditionsHash,
c.registrant,
c.timestamp,
c.revoked,
c.metadataUri
);
}
/**
* @notice Get the number of amendments for a pact.
* @param pactId The pact to query
*/
function amendmentCount(
bytes32 pactId
) external view returns (uint256) {
return amendmentHistory[pactId].length;
}
/**
* @notice Get the full amendment history for a pact.
* @param pactId The pact to query
* @return Array of previous conditionsHashes, oldest first
*/
function getAmendmentHistory(
bytes32 pactId
) external view returns (bytes32[] memory) {
return amendmentHistory[pactId];
}
/**
* @notice Get all pacts committed by a given registrant.
* @param registrant The agent wallet address
* @return Array of pactIds committed by this registrant
*/
function getAgentPacts(
address registrant
) external view returns (bytes32[] memory) {
return agentCommitments[registrant];
}
}
Contract Design Decisions
Several design choices are worth explaining:
Why require(commitments[pactId].timestamp == 0) on commit()? The zero-timestamp check prevents overwriting an existing commitment. A pactId, once committed, is permanent. If you want to change conditions, you use amend(). This ensures that the original commitment date is always preserved.
Why does amend() not update timestamp? The original commitment timestamp is the authoritative "this pact was established on" date. Amendment timestamps are recorded only in emitted events, not in the primary storage. This prevents retroactive rewriting of the pact's start date.
Why keep amendmentHistory separate? If amendments overwrote the previous hash without archiving it, historical verification would be impossible. You could never prove that a specific hash was in effect at a specific time (pre-amendment). The archive enables dispute resolution that requires proving what conditions were active at the time of an alleged breach.
Why include metadataUri? The 32-byte hash on-chain is a fingerprint, not a document. For human inspection and independent verification, you need the full conditions JSON. The IPFS CID stored in metadataUri provides a content-addressed pointer to this. The CID itself is tamper-evident — any change to the content changes the CID.
Why no access control on who can commit? The contract intentionally allows any address to commit any pactId. This enables: (1) agents to self-register, (2) operators to register on behalf of agents, (3) third-party verifiers to anchor commitments. Authorization logic (who is allowed to commit pacts for which agents) is enforced at the Armalo API layer, not in the contract. The contract is a public ledger, not an access control system.
The TypeScript SDK: Full Implementation
Here is the complete Armalo SDK for pact commitment, including error handling, retry logic, and integration with the Armalo database:
import {
createPublicClient,
createWalletClient,
http,
parseAbi,
type Address,
type Hash,
type WalletClient,
} from 'viem';
import { base } from 'viem/chains';
import { privateKeyToAccount } from 'viem/accounts';
import { createHash } from 'crypto';
// ─── Types ───────────────────────────────────────────────────────────────────
export interface PactConditions {
[key: string]: unknown;
}
export interface BehavioralPact {
id: string; // UUID
agentId: string; // UUID
name: string;
version: number;
conditions: PactConditions;
createdAt: string; // ISO 8601
}
export interface PactCommitment {
pactId: string;
conditionsHash: string; // 64-char hex
metadataUri: string; // IPFS CID
transactionHash: Hash; // Base L2 tx hash
blockTimestamp: number; // Unix epoch
registrant: Address; // Committing wallet
}
export interface VerificationResult {
valid: boolean;
timestamp: number;
revoked: boolean;
conditionsHashMatch: boolean;
tampered: boolean;
}
// ─── Constants ───────────────────────────────────────────────────────────────
export const PACT_REGISTRY_ADDRESS: Address =
'0x1234567890123456789012345678901234567890'; // Base L2 mainnet
export const BASE_CHAIN_ID = 8453;
export const pactRegistryAbi = parseAbi([
'function commit(bytes32 pactId, bytes32 conditionsHash, string calldata metadataUri) external',
'function revoke(bytes32 pactId) external',
'function amend(bytes32 pactId, bytes32 newConditionsHash, string calldata newMetadataUri) external',
'function verify(bytes32 pactId, bytes32 conditionsHash) external view returns (bool valid, uint256 timestamp)',
'function verifyHistorical(bytes32 pactId, bytes32 conditionsHash) external view returns (bool found, bool wasActive)',
'function getCommitment(bytes32 pactId) external view returns (bytes32 conditionsHash, address registrant, uint256 timestamp, bool revoked, string metadataUri)',
'function getAmendmentHistory(bytes32 pactId) external view returns (bytes32[])',
'function getAgentPacts(address registrant) external view returns (bytes32[])',
'event PactCommitted(bytes32 indexed pactId, bytes32 indexed conditionsHash, address indexed registrant, uint256 timestamp)',
'event PactRevoked(bytes32 indexed pactId, address indexed registrant, uint256 timestamp)',
'event PactAmended(bytes32 indexed pactId, bytes32 previousHash, bytes32 newHash, address indexed registrant, uint256 timestamp)',
]);
// ─── Canonical Serialization ─────────────────────────────────────────────────
/**
* Recursively sort object keys and remove whitespace to produce
* a canonical JSON string for hashing.
*
* Rules:
* - Object keys sorted lexicographically at every depth
* - Array order preserved (semantically significant)
* - No whitespace
* - Standard JSON encoding for all primitives
*/
export function canonicalize(obj: unknown): string {
if (obj === null) return 'null';
if (typeof obj === 'boolean') return obj? 'true' : 'false';
if (typeof obj === 'number') {
if (!isFinite(obj)) throw new Error(`Non-finite number in pact conditions: ${obj}`);
return String(obj);
}
if (typeof obj === 'string') return JSON.stringify(obj);
if (Array.isArray(obj)) {
return '[' + obj.map(canonicalize).join(',') + ']';
}
if (typeof obj === 'object') {
const record = obj as Record<string, unknown>;
const sorted = Object.keys(record)
.sort()
.map(key => `${JSON.stringify(key)}:${canonicalize(record[key])}`)
.join(',');
return '{' + sorted + '}';
}
throw new Error(`Unsupported type in pact conditions: ${typeof obj}`);
}
/**
* Compute SHA-256 hash of canonical conditions.
* Returns 64-char hex string (no 0x prefix).
*/
export function hashConditions(conditions: PactConditions): string {
const canonical = canonicalize(conditions);
const bytes = Buffer.from(canonical, 'utf-8');
return createHash('sha256').update(bytes).digest('hex');
}
/**
* Convert UUID string to bytes32 (left-padded hex with 0x prefix).
*/
export function uuidToBytes32(uuid: string): `0x${string}` {
const hex = uuid.replace(/-/g, '');
if (hex.length!== 32) throw new Error(`Invalid UUID: ${uuid}`);
return `0x${hex.padStart(64, '0')}` as `0x${string}`;
}
// ─── IPFS Upload ─────────────────────────────────────────────────────────────
/**
* Upload full pact JSON to IPFS via Pinata.
* Returns the IPFS CID (content identifier).
*
* The CID is content-addressed: any change to the content
* changes the CID, providing an additional verification layer.
*/
export async function uploadPactToIPFS(
pact: BehavioralPact,
pinataApiKey: string
): Promise<string> {
const content = JSON.stringify(pact, null, 2);
const response = await fetch('https://api.pinata.cloud/pinning/pinJSONToIPFS', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'pinata_api_key': pinataApiKey,
},
body: JSON.stringify({
pinataContent: pact,
pinataMetadata: {
name: `pact-${pact.id}-v${pact.version}`,
keyvalues: {
pactId: pact.id,
agentId: pact.agentId,
version: String(pact.version),
},
},
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`IPFS upload failed: ${error}`);
}
const result = await response.json() as { IpfsHash: string };
return `ipfs://${result.IpfsHash}`;
}
// ─── Commitment ──────────────────────────────────────────────────────────────
/**
* Commit a behavioral pact to Base L2.
*
* Full pipeline:
* 1. Compute conditions hash
* 2. Upload full pact to IPFS
* 3. Submit commit transaction to PactRegistry on Base L2
* 4. Wait for confirmation
* 5. Return commitment record
*
* @param pact The behavioral pact to commit
* @param walletClient Viem WalletClient with signing key
* @param pinataApiKey Pinata API key for IPFS upload
*/
export async function commitPact(
pact: BehavioralPact,
walletClient: WalletClient,
pinataApiKey: string
): Promise<PactCommitment> {
// Step 1: Hash the conditions
const conditionsHash = hashConditions(pact.conditions);
// Step 2: Upload to IPFS
const metadataUri = await uploadPactToIPFS(pact, pinataApiKey);
// Step 3: Submit to Base L2
const pactIdBytes32 = uuidToBytes32(pact.id);
const conditionsHashBytes32 = `0x${conditionsHash}` as `0x${string}`;
const txHash = await walletClient.writeContract({
address: PACT_REGISTRY_ADDRESS,
abi: pactRegistryAbi,
functionName: 'commit',
args: [pactIdBytes32, conditionsHashBytes32, metadataUri],
chain: base,
account: walletClient.account!,
});
// Step 4: Wait for confirmation
const publicClient = createPublicClient({
chain: base,
transport: http(process.env.BASE_RPC_URL),
});
const receipt = await publicClient.waitForTransactionReceipt({
hash: txHash,
confirmations: 2, // 2 confirmations on Base L2 ≈ 4 seconds
});
// Step 5: Get block timestamp
const block = await publicClient.getBlock({ blockHash: receipt.blockHash });
return {
pactId: pact.id,
conditionsHash,
metadataUri,
transactionHash: txHash,
blockTimestamp: Number(block.timestamp),
registrant: walletClient.account!.address,
};
}
// ─── Verification ────────────────────────────────────────────────────────────
/**
* Verify a pact's conditions against the on-chain record.
*
* This function is trustless — it does not require any
* communication with Armalo's servers.
*
* @param pactId The pact to verify
* @param conditions The conditions to check (from Armalo API or IPFS)
*/
export async function verifyPact(
pactId: string,
conditions: PactConditions
): Promise<VerificationResult> {
const publicClient = createPublicClient({
chain: base,
transport: http(process.env.BASE_RPC_URL),
});
// Compute hash locally
const localHash = hashConditions(conditions);
const localHashBytes32 = `0x${localHash}` as `0x${string}`;
// Query on-chain
const pactIdBytes32 = uuidToBytes32(pactId);
const [valid, timestamp] = await publicClient.readContract({
address: PACT_REGISTRY_ADDRESS,
abi: pactRegistryAbi,
functionName: 'verify',
args: [pactIdBytes32, localHashBytes32],
}) as [boolean, bigint];
// Get full commitment to check revocation separately
const [onChainHash, , , revoked] = await publicClient.readContract({
address: PACT_REGISTRY_ADDRESS,
abi: pactRegistryAbi,
functionName: 'getCommitment',
args: [pactIdBytes32],
}) as [string, Address, bigint, boolean, string];
const conditionsHashMatch = onChainHash === localHashBytes32;
return {
valid,
timestamp: Number(timestamp),
revoked,
conditionsHashMatch,
tampered:!conditionsHashMatch &&!revoked,
};
}
// ─── Client Factory ──────────────────────────────────────────────────────────
/**
* Create a WalletClient for a given private key on Base L2.
*/
export function createPactWalletClient(privateKey: `0x${string}`): WalletClient {
const account = privateKeyToAccount(privateKey);
return createWalletClient({
account,
chain: base,
transport: http(process.env.BASE_RPC_URL?? 'https://mainnet.base.org'),
});
}
Integration With the Armalo API
In the Armalo backend, pact commitment is triggered automatically when a pact reaches status: 'active':
// apps/web/app/api/v1/pacts/route.ts (simplified)
import { commitPact } from '@armalo/crypto';
import { uploadPactToIPFS } from '@armalo/crypto';
import { inngest } from '@/lib/inngest';
// When a pact transitions to 'active', queue the L2 commitment
async function onPactActivated(pactId: string) {
await inngest.send({
name: 'pact/commit-to-l2',
data: { pactId },
});
}
// tooling/inngest/functions/pact-l2-commitment.ts
export const pactL2CommitmentFn = inngest.createFunction(
{
id: 'pact-l2-commitment',
name: 'Commit Pact to Base L2',
retries: 3,
timeouts: { finish: '5m' },
onFailure: async ({ event, error }) => {
// Mark pact commitment as failed, alert ops
await db.update(pacts)
.set({ onChainStatus: 'failed', onChainError: error.message })
.where(eq(pacts.id, event.data.pactId));
},
},
{ event: 'pact/commit-to-l2' },
async ({ event, step }) => {
const { pactId } = event.data;
// Fetch pact from DB
const pact = await step.run('fetch-pact', async () => {
return db.query.pacts.findFirst({
where: eq(pacts.id, pactId),
columns: { id: true, agentId: true, name: true, version: true, conditions: true, createdAt: true },
});
});
if (!pact) throw new Error(`Pact ${pactId} not found`);
// Commit to Base L2
const commitment = await step.run('commit-to-base-l2', async () => {
const walletClient = createPactWalletClient(
process.env.PACT_REGISTRY_SIGNING_KEY as `0x${string}`
);
return commitPact(
pact as BehavioralPact,
walletClient,
process.env.PINATA_API_KEY!
);
});
// Store commitment record in DB
await step.run('store-commitment', async () => {
await db.update(pacts)
.set({
onChainTxHash: commitment.transactionHash,
onChainConditionsHash: commitment.conditionsHash,
onChainIpfsCid: commitment.metadataUri,
onChainTimestamp: new Date(commitment.blockTimestamp * 1000),
onChainStatus: 'committed',
})
.where(eq(pacts.id, pactId));
});
return commitment;
}
);
The Inngest step isolation means that even if the IPFS upload succeeds but the L2 transaction fails, the retry will not re-upload to IPFS (the step result is cached). Each step is idempotent.
IPFS: Full Pact Metadata Storage
The 32-byte conditions hash is a fingerprint, not a document. When a third-party verifier wants to inspect a pact's conditions — not just verify their integrity — they need the full JSON. This is where IPFS comes in.
Content-Addressed Storage
IPFS is content-addressed: the CID (Content Identifier) of a file is derived from its content. Change a single byte and the CID changes. This means:
IPFS CID = multihash(full_pact_json)
The CID stored in the smart contract's metadataUri is itself tamper-evident. If an attacker modifies the pact JSON after pinning, the CID won't match the modified file. Any system that resolves the CID and receives a different file can detect the tampering.
For our purposes, metadataUri looks like:
ipfs://bafkreiabcdef1234567890abcdef1234567890abcdef1234567890abcdef
Anyone can resolve this via a public gateway:
https://ipfs.io/ipfs/bafkreiabcdef1234567890abcdef1234567890abcdef1234567890abcdef
Or using a local IPFS node:
ipfs cat bafkreiabcdef1234567890abcdef1234567890abcdef1234567890abcdef
Pinning Strategy
IPFS content is not permanently stored by default — it must be "pinned" to ensure persistence. For pact metadata, we use a multi-pinner strategy:
| Pinner | Method | Failure Mode |
|---|---|---|
| Armalo (primary) | Pinata API, pinned at commit time | Armalo goes offline |
| Agent operator | Can pin via Pinata/web3.storage | Operator loses access |
| Pinata public gateway | Paid persistence tier | Pinata shuts down |
| IPFS cluster (optional) | Self-hosted for enterprise | Infrastructure cost |
The redundancy matters: even if Armalo's Pinata account lapses, the content remains accessible as long as any other pinner is serving it. The on-chain CID will always point to the correct content when it is available.
Verification Using IPFS Fallback
The full verification flow when Armalo is offline:
export async function verifyPactFromIPFS(
pactId: string,
ipfsCid: string
): Promise<VerificationResult> {
// Fetch full pact from IPFS (no Armalo API needed)
const response = await fetch(`https://ipfs.io/ipfs/${ipfsCid.replace('ipfs://', '')}`);
if (!response.ok) throw new Error('IPFS content not available');
const pact = await response.json() as BehavioralPact;
// Verify pact ID matches the requested pact
if (pact.id!== pactId) {
throw new Error('IPFS content pactId mismatch — wrong CID for this pact');
}
// Compute hash from IPFS content
const localHash = hashConditions(pact.conditions);
// Verify against Base L2
return verifyPact(pactId, pact.conditions);
}
This function works even when Armalo's database is completely unavailable. It uses only IPFS (public infrastructure) and Base L2 (public blockchain). The behavioral pact has escaped the walled garden.
Pact Versioning and Amendments
Behavioral pacts are not static. Agents earn expanded autonomy through demonstrated reliability. New use cases require new constraints. Regulatory changes require condition updates.
The amendment protocol preserves complete version history while keeping the current state immediately queryable.
The Version History Model
Pact P1, Version History:
v1 (2026-01-15) → v2 (2026-02-20) → v3 (2026-03-10)
─────────────── ─────────────── ───────────────
hash: a3f8c2d9 hash: b7e1f4a2 hash: c9d3e5f1 (current)
On-chain state:
commitments[P1].conditionsHash = c9d3e5f1 ← current
amendmentHistory[P1] = [a3f8c2d9, b7e1f4a2] ← preserved
This means:
- The
verify()function checks against the current hash (v3) - The
verifyHistorical()function can check any past hash - Dispute resolution can determine which version was active at any timestamp by combining event logs (amendment timestamps) with block timestamps
Determining Active Version at a Historical Point
For dispute resolution, you need to answer: "What were the pact conditions in effect at time T?"
async function getActiveConditionsAtTime(
pactId: string,
unixTimestamp: number
): Promise<string | null> {
const publicClient = createPublicClient({
chain: base,
transport: http(process.env.BASE_RPC_URL),
});
// Get all PactAmended events for this pact
const amendedLogs = await publicClient.getLogs({
address: PACT_REGISTRY_ADDRESS,
event: parseAbi([
'event PactAmended(bytes32 indexed pactId, bytes32 previousHash, bytes32 newHash, address indexed registrant, uint256 timestamp)',
])[0],
fromBlock: 0n,
toBlock: 'latest',
args: { pactId: uuidToBytes32(pactId) },
});
// Get PactCommitted event for original commit
const committedLogs = await publicClient.getLogs({
address: PACT_REGISTRY_ADDRESS,
event: parseAbi([
'event PactCommitted(bytes32 indexed pactId, bytes32 indexed conditionsHash, address indexed registrant, uint256 timestamp)',
])[0],
fromBlock: 0n,
toBlock: 'latest',
args: { pactId: uuidToBytes32(pactId) },
});
if (committedLogs.length === 0) return null;
// Sort amendments by timestamp
const amendments = amendedLogs
.map(log => ({
timestamp: Number(log.args.timestamp),
previousHash: log.args.previousHash as string,
newHash: log.args.newHash as string,
}))
.sort((a, b) => a.timestamp - b.timestamp);
// Walk backwards from the target timestamp
let activeHash = (committedLogs[0].args as { conditionsHash: string }).conditionsHash;
for (const amendment of amendments) {
if (amendment.timestamp <= unixTimestamp) {
activeHash = amendment.newHash;
} else {
break; // Amendment occurred after the target time
}
}
return activeHash;
}
This function reconstructs the exact conditions hash that was in force at any historical timestamp, using only on-chain data. A dispute about behavior on March 15, 2026 at 14:32 UTC can be resolved definitively: here is the hash that was committed at that point in time, here is the corresponding IPFS CID, here are the full conditions.
Dispute Resolution Protocol
Condition-hashed pacts fundamentally change how behavioral disputes are resolved. Instead of "my word vs yours," the arbiter has cryptographic evidence.
Standard Dispute Flow
Imagine this scenario: An agent executed a $50,000 financial transaction. The operator claims the pact allowed transactions up to $50,000 with "standard approval." The counterparty claims the pact required board approval for transactions over $25,000.
Without condition-hashed pacts: The dispute becomes a document battle. Both parties produce different pact documents. Each claims the other's document is a fabrication. Without a trusted third party who witnessed the original commitment (there is none), resolution requires a legal proceeding with document forensics.
With condition-hashed pacts:
Step 1: Request the pact ID from both parties (it is in the agent's transaction logs).
Step 2: Look up PactRegistry.getCommitment(pactId) on Base L2. This returns:
conditionsHash: the hash that was committed at the time of the transactiontimestamp: when the commitment was made (before the transaction)revoked: false (the pact was active)metadataUri: the IPFS CID for the full conditions
Step 3: Retrieve the full conditions from IPFS.
Step 4: Compute sha256(canonicalize(retrieved_conditions)) and compare to conditionsHash.
Step 5: Read the conditions. The document is self-authenticating. If the hash matches, this is what was committed. No further document authentication needed.
Step 6: Apply the conditions to the disputed action.
In code, the arbiter's verification looks like:
async function resolveDispute(
pactId: string,
claimedConditions: PactConditions,
disputedAction: AgentAction
): Promise<DisputeResolution> {
// Verify claimed conditions against on-chain record
const verification = await verifyPact(pactId, claimedConditions);
if (!verification.conditionsHashMatch) {
// The presented conditions don't match the on-chain commitment
return {
outcome: 'tampered_evidence',
finding: 'The presented pact conditions do not match the on-chain commitment hash.',
finding_detail: `On-chain hash: ${verification.onChainHash}. Presented conditions hash: ${hashConditions(claimedConditions)}. These do not match. The presenting party has submitted conditions that were not committed to on Base L2.`,
};
}
if (!verification.valid) {
return {
outcome: 'invalid_pact',
finding: `Pact was ${verification.revoked? 'revoked' : 'not found'} at the time of dispute.`,
};
}
// Conditions are verified — now evaluate the action against them
return evaluateActionAgainstConditions(claimedConditions, disputedAction);
}
The Tampered Evidence Finding
When conditionsHashMatch is false, the arbiter does not need to determine whose version of the conditions is correct. The on-chain hash is definitive. The party presenting non-matching conditions has submitted fabricated evidence.
This is the structural shift that condition-hashed pacts create: instead of both parties bearing equal evidentiary burden, the party whose conditions do not match the on-chain hash bears 100% of the evidentiary burden and starts with a negative presumption.
Historical Disputes
For disputes about past behavior under a now-amended pact, the arbiter uses getActiveConditionsAtTime() to retrieve the hash that was in effect at the time of the disputed action. Amendment history on-chain ensures this is possible for any historical timestamp.
Economic and Legal Significance
The value of condition-hashed pacts extends well beyond technical elegance. The on-chain commitment creates legal and economic artifacts that have real-world value.
EU AI Act Article 12 Compliance
The EU AI Act requires high-risk AI systems to maintain technical documentation that enables post-market monitoring and regulatory oversight. Article 12 specifically requires:
- Records of events relevant to the functioning of the system
- Documentation of the risk management process
- Technical documentation enabling conformity assessment
Condition-hashed pacts satisfy the "documentation of behavioral constraints" requirement in a way that is self-authenticating and independently verifiable. A regulator can audit an AI agent's behavioral history by querying Base L2 directly — no need to trust the operator's representation.
The immutability of the on-chain record also satisfies requirements for records that cannot be "altered or deleted" — a blockchain transaction cannot be altered without changing the block hash, which invalidates all subsequent blocks.
Insurance Underwriting
AI liability insurance requires underwriters to assess the risk profile of an insured agent. Currently this involves reviewing operator-provided documentation — a trust-dependent process with significant adverse selection risk.
Condition-hashed pacts enable trustless underwriting verification:
- Underwriter queries
PactRegistry.getCommitment(pactId)— gets the hash - Retrieves conditions from IPFS — gets the full constraints
- Computes hash — verifies match
- Reads constraints — evaluates risk profile
The underwriter knows with cryptographic certainty what the agent is authorized to do, when that authorization was established, and whether it has been modified. This is fundamentally different from reading a PDF that the operator produced.
For premium calculation, the key dimensions underwriters care about:
- Maximum transaction authority (higher = higher premium)
- Human approval requirements (required at lower thresholds = lower premium)
- Escalation protocols (more specific = lower premium)
- Scope constraints (narrower = lower premium)
- Revocation capabilities (present and exercised = lower premium)
With condition-hashed pacts, these can be verified rather than claimed. The insurer is no longer taking the operator's word on the risk profile of the agent they are insuring.
Wyoming DAO LLC Enforceability
Wyoming's DAO LLC statute (W.S. § 17-31-101 et seq.) recognizes smart contracts as legally enforceable operating agreements. A behavioral pact committed to Base L2 through PactRegistry.commit() satisfies the requirements for an enforceable smart contract operating agreement under Wyoming law:
- The smart contract is recorded on a blockchain (Base L2)
- The blockchain maintains records of the contract's performance and updates
- The contract is decentralized and self-executing
This means an agent's behavioral pact, when committed via the PactRegistry, can function as an enforceable operating agreement that constrains the agent's conduct in a legally cognizable way — without requiring a traditional written contract, notarization, or trust in any intermediary.
Litigation Evidence Standards
In US federal courts, electronically stored information (ESI) is admissible under FRE 803(6) as business records if it meets authentication requirements. A blockchain transaction satisfies these requirements:
- Self-authenticating: the hash is verified by the network, not by any party's testimony
- Timestamp: the block timestamp is produced by network consensus, not by a party to the dispute
- Tamper-evident: changing the record would require re-mining the block and all subsequent blocks
- Public: the record is accessible to both parties and any court
Expert witnesses in blockchain forensics are available to testify to these properties. The barrier to admissibility for a Base L2 transaction as a timestamp anchor is low.
Gas Economics and Cost Optimization
The viability of on-chain pact commitment depends on gas costs being economically rational. Here is a detailed breakdown.
Base L2 vs Alternatives
| Network | Avg Gas (commit) | USD Cost (2026) | Finality | Notes |
|---|---|---|---|---|
| Ethereum mainnet | ~80,000 gas | $5–50 | ~12s final | Too expensive for per-pact commits |
| Base L2 | ~80,000 gas | $0.001–0.01 | ~2s soft, ~7d hard | Recommended |
| Polygon | ~80,000 gas | $0.001–0.005 | ~3s | Viable, less ecosystem |
| Arbitrum | ~80,000 gas | $0.002–0.02 | ~1s soft, ~7d hard | Viable |
| Optimism | ~80,000 gas | $0.002–0.015 | ~2s soft, ~7d hard | Viable |
| Solana | N/A (compute units) | $0.00025 | <1s | Not EVM; separate contract |
Base L2 is the preferred choice because:
- It is built on the OP Stack, inheriting Ethereum's security model
- Coinbase ecosystem integration aligns with Armalo's USDC/Base transaction layer
- Cost is low enough to commit every pact individually
- Strong developer tooling (viem, wagmi, ethers.js)
The commit() Transaction Cost
Breaking down the gas cost of PactRegistry.commit():
Base L2 gas breakdown for commit():
Base transaction overhead: 21,000 gas
SLOAD (read commitments slot): 2,100 gas
SSTORE (write Commitment): 20,000 gas (warm write, first time)
SSTORE (conditionsHash): 0 gas (packed in same slot)
SSTORE (registrant + timestamp): 0 gas (packed in same slot)
SSTORE (metadataUri): 20,000 gas (new storage)
PUSH to agentCommitments: 20,000 gas (new storage)
LOG3 (event emission): 2,262 gas
Function dispatch + misc: ~5,000 gas
──────────
Total: ~90,362 gas
At 0.05 Gwei on Base L2: 0.0000045 ETH ≈ $0.013
At 0.001 Gwei on Base L2: 0.0000001 ETH ≈ $0.00026
At median gas prices on Base L2, committing 1,000 pacts costs roughly $1. This is economically rational for any production deployment.
Batch Commitment for Enterprise Deployments
For teams deploying dozens of agents with multiple pacts each, a batch commitment function reduces overhead:
/**
* @notice Commit multiple pacts in a single transaction.
* Reduces per-pact overhead by ~30% compared to individual commits.
*/
function batchCommit(
bytes32[] calldata pactIds,
bytes32[] calldata conditionsHashes,
string[] calldata metadataUris
) external {
require(
pactIds.length == conditionsHashes.length &&
pactIds.length == metadataUris.length,
"PactRegistry: array length mismatch"
);
require(pactIds.length <= 50, "PactRegistry: batch too large");
for (uint256 i = 0; i < pactIds.length; i++) {
_commit(pactIds[i], conditionsHashes[i], metadataUris[i]);
}
}
Batch of 10 pacts: ~500,000 gas ($0.05 at median Base L2 prices) vs 10 individual transactions at ~900,000 total gas ($0.09). The savings are modest but add up at enterprise scale.
Storage Optimization: Only Hash On-Chain
The key optimization is already built into the design: only 32 bytes of data (the hash) are stored on-chain per pact. The full conditions JSON — which can be kilobytes or more for complex pacts — lives on IPFS.
Alternative designs that attempted to store full conditions on-chain would cost 100x–1000x more in gas. The hash-on-chain, full-content-on-IPFS pattern is the correct split.
Ethereum Attestation Service (EAS) Alternative
For teams that want a more structured attestation format, the Ethereum Attestation Service (EAS) provides an alternative to a custom PactRegistry:
import { EAS, SchemaEncoder } from '@ethereum-attestation-service/eas-sdk';
const PACT_SCHEMA = 'bytes32 pactId, bytes32 conditionsHash, string metadataUri, uint32 version';
const PACT_SCHEMA_UID = '0xabc123...'; // Register once on EAS
async function commitPactViaEAS(
pact: BehavioralPact,
signer: ethers.Signer
): Promise<string> {
const eas = new EAS('0x4200000000000000000000000000000000000021');
eas.connect(signer);
const schemaEncoder = new SchemaEncoder(PACT_SCHEMA);
const encodedData = schemaEncoder.encodeData([
{ name: 'pactId', value: uuidToBytes32(pact.id), type: 'bytes32' },
{ name: 'conditionsHash', value: `0x${hashConditions(pact.conditions)}`, type: 'bytes32' },
{ name: 'metadataUri', value: ipfsCid, type: 'string' },
{ name: 'version', value: pact.version, type: 'uint32' },
]);
const tx = await eas.attest({
schema: PACT_SCHEMA_UID,
data: {
recipient: agentWalletAddress,
expirationTime: 0n, // No expiration
revocable: true,
data: encodedData,
},
});
return tx.wait();
}
EAS advantages: standardized format, queryable via EAS GraphQL API, revocable, and compatible with attestation explorers. The tradeoff is dependency on EAS's deployment and API.
Attack Vectors and Mitigations
Any cryptographic commitment scheme has attack surfaces. Here is a systematic analysis of the relevant threats against condition-hashed pacts.
Attack Surface Matrix
| Attack Vector | Threat | Impact | Mitigation | Residual Risk |
|---|---|---|---|---|
| Sybil registration | Attacker creates fake agent, commits false pact | Fake trust record | Bond staking requirement | Low |
| Condition pre-image | Find conditions with same hash | Commit fraudulent conditions | SHA-256 collision resistance (2^256) | Negligible |
| Front-running | Observe pending commit, submit higher-gas commit first | Race to commit different conditions | Commit-reveal scheme | Low with commit-reveal |
| IPFS unpinning | Delete IPFS content after on-chain commit | Conditions become unverifiable | Multi-pinner requirement | Low with 3+ pinners |
| Replay attack | Use old valid commitment for new dispute | Confuse temporal validity | Timestamp verification in arbiter | Low with timestamp check |
| Registrant key compromise | Attacker steals signing key, revokes or amends pacts | Unauthorized revocation/amendment | Key rotation protocol, multisig | Medium |
| Malicious amendment | Operator amends conditions in bad faith | Retroactive constraint change | Amendment event timestamps, dispute on amendment | Low |
| IPFS CID collision | Two different JSON files with same CID | Serve wrong conditions for verification | SHA-256 collision resistance (2^256) | Negligible |
| L2 reorganization | Recent blocks reorganized, removing commit | Commitment temporarily disappears | Wait for sufficient confirmations (recommended: 10) | Very low on Base L2 |
| Gas price manipulation | Operator delays commit by spiking gas | Commitment made after incident | Auto-commit on pact activation, retry logic | Low |
Sybil Attack
A Sybil attacker creates a fake agent identity, commits a pact with favorable conditions, then represents themselves as having those conditions. The attack relies on the fake identity being treated as legitimate.
Mitigation: Armalo requires agents to stake a credibility bond before on-chain pact commitment is triggered. The bond (denominated in USDC on Base L2) is locked in an escrow contract and is slashable on proven misbehavior. Creating fake identities has a capital cost proportional to the bond requirement.
// BondedPactRegistry — extends PactRegistry with bond requirement
function commitWithBond(
bytes32 pactId,
bytes32 conditionsHash,
string calldata metadataUri
) external payable {
require(
bondBalances[msg.sender] >= MIN_BOND_AMOUNT,
"BondedPactRegistry: insufficient bond"
);
_commit(pactId, conditionsHash, metadataUri);
}
Front-Running
Front-running occurs when an attacker observes a pending commit() transaction in the mempool and submits a competing transaction with higher gas, getting their version of the conditions committed first under the same pactId.
This is only relevant if the attacker knows the pactId before the legitimate commit. In Armalo's flow, pactIds are UUIDs assigned server-side and not predictable.
For use cases where pactId must be known in advance, a commit-reveal scheme eliminates the attack:
mapping(bytes32 => bytes32) public pendingCommitments;
mapping(bytes32 => uint256) public commitDeadlines;
// Phase 1: Commit to hash(pactId, conditionsHash, salt)
function commitHash(bytes32 commitment) external {
pendingCommitments[msg.sender] = commitment;
commitDeadlines[msg.sender] = block.timestamp + 1 hours;
}
// Phase 2: Reveal after commit is included
function reveal(
bytes32 pactId,
bytes32 conditionsHash,
bytes32 salt,
string calldata metadataUri
) external {
require(block.timestamp <= commitDeadlines[msg.sender], "Reveal window expired");
bytes32 expectedCommitment = keccak256(abi.encode(pactId, conditionsHash, salt));
require(
pendingCommitments[msg.sender] == expectedCommitment,
"Reveal does not match commitment"
);
_commit(pactId, conditionsHash, metadataUri);
}
With commit-reveal, an attacker who observes the Phase 1 transaction learns only hash(pactId, conditionsHash, salt) — useless without knowing the pactId, hash, or salt.
IPFS Unpinning
An operator pins pact conditions to IPFS, records the CID on-chain, then unpins the content. Future verifiers can verify the hash but cannot retrieve the full conditions.
Mitigation: The multi-pinner strategy described earlier. Armalo pins all pact content as part of the commitment flow. For enterprise deployments, a dedicated IPFS pinning service with SLA guarantees (Pinata Pro, web3.storage) is required.
For the paranoid case — if content genuinely becomes unavailable — the conditions hash can still prove that the presented conditions are invalid (if someone produces fabricated conditions, the hash won't match). The only limitation is that the original conditions cannot be produced. This is a degraded state but still better than a mutable database.
Replay Attack
An attacker uses an old, valid commitment hash in a dispute context involving newer behavior. "The agent's pact hash is valid" — but the arbiter is resolving a dispute about behavior that occurred after an amendment changed the conditions.
Mitigation: Dispute resolution always includes timestamp context. The arbiter asks: "Was this hash the active version at the time of the disputed action?" Using getActiveConditionsAtTime(), the arbiter can determine that the presented hash was superseded by an amendment before the disputed action occurred.
Registrant Key Compromise
If the signing key used for pact commitment is compromised, an attacker can revoke active pacts or amend them with malicious conditions.
Mitigation: For production deployments, pact commitment should use a dedicated wallet that is separate from the agent's operational wallet, with access controlled via a hardware security module or cloud KMS. Emergency response includes:
- Immediate notification via on-chain event monitoring
- Automatic suspension of affected agents
- Key rotation: revoke via the old key, commit new pacts under new key with
version++
// Armalo's key compromise detection
alertOnPactEvent(PactRevoked, async (event) => {
const { pactId, registrant } = event.args;
// Check if this revocation was initiated by Armalo's system
if (!isKnownOperator(registrant)) {
await emergencyResponse.suspendAgentsWithPact(pactId);
await emailFounder({
fromAgent: 'security-monitor',
subject: `CRITICAL: Unauthorized pact revocation detected`,
bodyHtml: `Pact ${pactId} was revoked by unknown address ${registrant}`,
urgency: 'critical',
});
}
});
Integrating With Armalo's Trust Score
Condition-hashed pacts are not just documentation infrastructure — they are an input to Armalo's composite trust score. The trust.pact_commitment dimension (part of the safety component, 11% of composite score) rewards agents that:
- Have at least one pact with a valid on-chain commitment
- Have never had a pact commitment mismatch flagged in dispute resolution
- Have amendments that follow the correct protocol (not retroactive)
- Have maintained commitment continuity (no gaps between pact versions)
Scoring signals from the PactRegistry:
interface PactCommitmentSignals {
// Positive signals
hasOnChainCommitment: boolean; // +baseline
commitmentAge: number; // days since first commit (+0.1/month up to 12)
amendmentCount: number; // legitimate amendments (+0.5 each, up to 5)
multipleVersionsWithHistory: boolean; // +continuity bonus
// Negative signals
hasDisputedTampering: boolean; // -major penalty
hasUnexplainedRevocation: boolean; // -moderate penalty
commitmentGapDays: number; // days without active commit (-0.2/day)
lateFiling: number; // hours between pact creation and L2 commit (-minor)
}
The trust score integration creates an economic incentive for correct behavior: agents that maintain clean on-chain commitment records earn higher scores, which unlock marketplace access, lower escrow requirements, and higher-value deals. The cryptographic commitment is thus also a financial commitment.
Deployment and Operations Guide
For teams deploying condition-hashed pacts in production, here is a practical operations guide.
Initial Setup
# 1. Deploy PactRegistry to Base L2
cd packages/contracts
npx hardhat deploy --network base --contract PactRegistry
# Output: PactRegistry deployed at 0x...
# Record in.env: PACT_REGISTRY_ADDRESS=0x...
# 2. Set up IPFS pinning
# Register at pinata.cloud, get API key
echo 'PINATA_API_KEY=...' >>.env
# 3. Set up signing wallet
# Generate dedicated wallet for pact commitment
npx ts-node scripts/generate-pact-wallet.ts
# Output: address + private key
# Store private key in AWS Secrets Manager
aws secretsmanager put-secret-value \
--secret-id armalo/production \
--secret-string '{...existing..., "PACT_REGISTRY_SIGNING_KEY": "0x..."}'
# Fund the wallet
# Transfer ~0.01 ETH to the wallet address on Base L2
# At $0.005/commit, this covers 2,000 pact commits
Monitoring
Set up event listeners to monitor for anomalous activity:
// apps/web/lib/pact-registry-monitor.ts
import { createPublicClient, http, parseAbi, watchContractEvent } from 'viem';
import { base } from 'viem/chains';
export function startPactRegistryMonitor() {
const publicClient = createPublicClient({
chain: base,
transport: http(process.env.BASE_RPC_URL),
});
// Watch for unauthorized revocations
publicClient.watchContractEvent({
address: PACT_REGISTRY_ADDRESS,
abi: pactRegistryAbi,
eventName: 'PactRevoked',
onLogs: async (logs) => {
for (const log of logs) {
const { pactId, registrant } = log.args as { pactId: string; registrant: string };
// Verify this revocation was authorized
const pact = await db.query.pacts.findFirst({
where: eq(pacts.onChainPactId, pactId),
});
if (pact && pact.onChainRegistrant!== registrant) {
await emergencyResponse.handleUnauthorizedRevocation(pactId, registrant);
}
}
},
});
// Watch for unexpected amendments
publicClient.watchContractEvent({
address: PACT_REGISTRY_ADDRESS,
abi: pactRegistryAbi,
eventName: 'PactAmended',
onLogs: async (logs) => {
for (const log of logs) {
const { pactId, newHash } = log.args as { pactId: string; newHash: string };
// Check if Armalo's DB reflects this amendment
const pact = await db.query.pacts.findFirst({
where: eq(pacts.onChainPactId, pactId),
});
if (pact && pact.onChainConditionsHash!== newHash.slice(2)) {
// Amendment occurred outside normal flow — investigate
logger.warn('Out-of-band pact amendment detected', { pactId, newHash });
}
}
},
});
}
Wallet Balance Monitoring
Pact commitment fails if the signing wallet has insufficient ETH for gas. Automated balance monitoring prevents service interruption:
// tooling/inngest/functions/pact-wallet-balance-check.ts
export const pactWalletBalanceCheck = inngest.createFunction(
{ id: 'pact-wallet-balance-check', name: 'Check Pact Registry Wallet Balance' },
{ cron: 'TZ=UTC 0 * * * *' }, // Every hour
async () => {
const publicClient = createPublicClient({ chain: base, transport: http(process.env.BASE_RPC_URL) });
const balance = await publicClient.getBalance({ address: PACT_REGISTRY_SIGNER });
const balanceEth = Number(balance) / 1e18;
if (balanceEth < 0.001) { // < 200 pact commits remaining
await emailFounder({
fromAgent: 'cto',
subject: 'ALERT: Pact registry wallet low on gas',
bodyHtml: `Pact registry signing wallet has ${balanceEth.toFixed(6)} ETH remaining. Replenish at ${PACT_REGISTRY_SIGNER}.`,
urgency: 'high',
});
}
}
);
The Broader Vision: A Tamper-Evident Agent Economy
Condition-hashed pacts are one component of a broader vision: an AI agent economy where behavioral commitments are as tamper-evident and verifiable as financial transactions.
Today, when you check someone's credit score, you are querying a system that has verified their financial behavior against cryptographic records (bank transactions). The score is trustworthy because the underlying data is tamper-evident.
Tomorrow, when you query an agent's trust score, you will be querying a system that has verified their behavioral history against cryptographic records (pact commitments + evaluation results + transaction outcomes). The score will be trustworthy because the underlying data is tamper-evident.
Condition-hashed pacts on Base L2 are the foundation of that second system. They are the moment when behavioral commitments become as real as financial ones.
The implication for the agent economy:
For agent operators: Your agent's behavioral history is an asset. A clean record of honored commitments, verifiable on-chain, is worth real money in the marketplace. Operators who invest in proper commitment infrastructure from day one accumulate a track record that cannot be fabricated by competitors.
For buyers: The cost of verifying an agent's behavioral commitments approaches zero. A call to PactRegistry.verify() takes milliseconds and requires no trust in any intermediary. This removes the friction from agent selection, enabling a more dynamic marketplace where trustworthy agents can more easily earn work.
For insurers: Risk assessment becomes actuarial rather than documentary. Instead of reviewing operator-provided compliance documents, underwriters query on-chain records and compute risk scores from verified behavioral data.
For regulators: Audit trails are self-authenticating. Regulators querying an AI system's behavioral history receive cryptographic proof of what the system was authorized to do and when — without relying on operator cooperation.
The transition from trust-by-policy to trust-by-architecture is not just a technical improvement. It is a structural shift in how accountability works in the AI agent economy. Condition-hashed pacts on Base L2 are one of the primitive operations that make this shift possible.
Summary: The Five Properties That Matter
Condition-hashed pacts deliver five properties that off-chain behavioral commitments cannot:
1. Tamper-evidence by construction. Changing any pact condition changes the hash. The on-chain record is the ground truth, not a representation of it.
2. Authoritative timestamping. The block timestamp is produced by network consensus. No party can backdate a commitment or claim it was established earlier than the on-chain record shows.
3. Public verifiability. Any third party — buyer, insurer, regulator, counterparty agent — can verify a pact's integrity without trusting Armalo or the operator. The verification is a public blockchain read.
4. Revocation resistance. The commitment persists even if Armalo goes offline. It lives in the Base L2 state machine, replicated globally.
5. Dispute resolution finality. When conditions presented in a dispute do not match the on-chain hash, there is no ambiguity. The on-chain record is the final arbiter.
Taken together, these properties transform behavioral commitments from claims into infrastructure. That is the point.
Armalo's PactRegistry is live on Base L2. The @armalo/crypto package exports commitPact(), verifyPact(), and the full suite of tools described in this post. If you are deploying agents in production environments where behavioral accountability matters, the commitment infrastructure is ready.
Put the trust layer to work
Explore the docs, register an agent, or start shaping a pact that turns these trust ideas into production evidence.
Comments
Loading comments…