Skip to content

Add OwnerApprovalHook (Profile A) — human spending-approval gate via off-chain owner signature#49

Open
with0utwhy wants to merge 1 commit into
erc-8183:mainfrom
with0utwhy:add-owner-approval-hook
Open

Add OwnerApprovalHook (Profile A) — human spending-approval gate via off-chain owner signature#49
with0utwhy wants to merge 1 commit into
erc-8183:mainfrom
with0utwhy:add-owner-approval-hook

Conversation

@with0utwhy

Copy link
Copy Markdown

OwnerApprovalHook — Profile A (Simple Policy)

A policy hook that blocks fund() until a designated owner has approved the
job's budget, where approval is an off-chain EIP-712 signature presented in the
fund optParams. No token custody.

Use case

An AI agent's human operator wants to gate the agent's spending: the agent can
create and run jobs, but money only moves once the human signs off on the
budget. This adds a human-in-the-loop oversight lane to ERC-8183 without a
trusted intermediary — useful for spend controls and regulatory human-oversight
requirements.

Flow

  1. setBudget optParams carry abi.encode(owner). The hook records the owner
    from optParams; if none is supplied the job stays owner-less and cannot be
    funded (fail-closed). Once an owner is set, it emits
    ApprovalRequested(jobId, owner, client, budget) for an off-chain notifier.
  2. The owner signs EIP-712 typed data — Approval(uint256 jobId,uint256 budget, uint256 deadline) under this contract's EIP712("OwnerApprovalHook","1")
    domain — gasless, in a wallet. The wallet shows the named fields, not an
    opaque hash.
  3. fund optParams carry abi.encode(signature, deadline). _preFund validates
    the signature against the recorded owner — an EOA (ECDSA) or a smart-contract
    wallet (ERC-1271), via OpenZeppelin SignatureChecker — and requires the
    deadline to be in the future and within a bounded window. A missing or invalid
    signature reverts, blocking the funding.

requiredSelectors() returns [setBudget, fund] because the owner is captured
at setBudget and consumed at fund.

Trust model / assumptions

  • Trustless: the hook holds no approval state and exposes no mutating functions
    outside the ERC-8183 callbacks. Only a valid signature from the recorded owner
    (EOA or ERC-1271 wallet) can unblock funding. No admin, no upgradeability.
  • The owner is locked on first setBudget (no later swap). The budget is bound
    into the signed payload, so raising the budget invalidates a prior approval
    (no escalation). The EIP-712 domain binds the signature to this hook on this
    chain, and jobId binds it to one job (no cross-job/chain/hook replay). The job
    is flagged funded in _postFund, so the same approval can't be replayed even if
    the core ever permitted a second fund() (no same-job replay).
  • The deadline is bounded both ways: in the future and no more than
    MAX_APPROVAL_WINDOW (30 days) ahead, so an approval cannot accidentally be
    made effectively non-expiring.
  • Denial needs no transaction: an unsigned job can never be funded.
  • No token custody (Profile A); reentrancy is not applicable.

Off-chain integration

The hook is intentionally thin: it emits ApprovalRequested and verifies a
signature. The owner-facing pieces live off-chain and follow a simple pattern any
integrator can implement:

  1. Watch ApprovalRequested(jobId, owner, client, budget); notify the owner.
  2. Have the owner sign the EIP-712 Approval payload (wallet shows the fields).
  3. Relay the signature to the funder, who passes abi.encode(signature, deadline)
    as fund optParams.

A minimal integration is just two off-chain steps — no relay infrastructure required:

// 1) Owner approves: sign the job terms (EIP-712, gasless — no transaction).
const domain = { name: "OwnerApprovalHook", version: "1", chainId, verifyingContract: hook };
const types  = { Approval: [
  { name: "jobId",    type: "uint256" },
  { name: "budget",   type: "uint256" },
  { name: "deadline", type: "uint256" },
]};
const signature = await ownerWallet.signTypedData(domain, types, { jobId, budget, deadline });

// 2) Funder funds: pass (signature, deadline) as the fund optParams.
const optParams = AbiCoder.defaultAbiCoder().encode(["bytes", "uint256"], [signature, deadline]);
await core.fund(jobId, amount, optParams); // _preFund verifies the signature on-chain

A reference implementation of this layer — an event listener with
Telegram / ntfy / web-push notifications and a one-click owner approval UI — is
hosted at https://humantaste.app/gatekeeper, so integrators don't have to build
the notification + signature-relay plumbing themselves.

Conformance checklist

  • Inherits BaseERC8183Hook + IERC8183HookMetadata; supportsInterface advertises both
  • Implements requiredSelectors(); overrides only _postSetBudget, _preFund, and _postFund
  • No external mutating functions outside callbacks; immutable, no proxy
  • Zero-address dependency reverts in the constructor (via BaseERC8183Hook)
  • EIP-712 typed-data approval; signature via OZ SignatureChecker (EOA + ERC-1271); deadline lower- and upper-bounded
  • Replay-safe: EIP-712 domain (chain + verifyingContract) + jobId + budget in the payload — no cross-job/chain/hook reuse, no escalation; _funded guard in _postFund blocks same-job replay
  • Single .sol, ^0.8.20, SPDX MIT, named imports, vendor-neutral, no test files
  • NatSpec USE CASE / FLOW / TRUST MODEL; MultiHookRouter-safe (inherited onlyERC8183 guard)

Deployed + exercised end-to-end (setBudget → off-chain owner signature → fund) on
Base Sepolia; mainnet instance at 0xB2d871a9cE05ABE8B4A4Ea2BBbD666bE4aF4CBbF.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants