Pull Mode Contract Example
A pull-mode contract integration is fully passive on-chain. The owner signs an EIP-712 Offer off-chain naming the contract as the maker (useCallback = true), submits it via the BF API, and nothing else needs to happen until a 3F operator settles it. At that moment, the Request calls back into the contract to source capital, grants an exact-amount allowance, and pulls the funds — all inside one transaction.
This page walks through SimplePull, a minimal reference contract published in 3FLabs/request-integration-examples. It focuses on the spots where a real integration would extend the reference — sourcing liquidity just-in-time, separating signing from administration, routing redeemed funds to a downstream venue. The full code is at the bottom of the page.
Architecture
Section titled “Architecture”graph LR
O[Owner] -->|1. Sign Offer EIP-712| API[BF API]
API -->|2. Match| OP[3F Operator]
OP -->|3. consume| R[Request]
R -->|4. isValidSignature| C[SimplePull]
R -->|5. onRequestConsumed| C
C -->|6. approve| R
R -->|7. transferFrom| C
R -->|8. PT + YT| C
- Owner signs an EIP-712
Offermessage namingSimplePullas the maker withuseCallback = true, and submits it to the BF API. - The auction engine selects the offer.
- A 3F operator calls
Request.consumewith the signed payload. - The Request validates the signature through EIP-1271 by calling
isValidSignatureonSimplePull, which delegates ECDSA recovery to the owner. - Because the offer was signed with
useCallback = true, the Request invokesonRequestConsumed(...)onSimplePullbefore pulling funds. - The callback sources liquidity (if needed) and grants an exact-amount allowance for
principal. Only principal is pulled —yieldis minted as YT and paid out pro rata at repayment from any surplus above PT supply. - The Request executes
transferFrominside the same transaction. - PT and YT land on
SimplePulland stay there until repayment.
Step-by-Step
Section titled “Step-by-Step”On-chain prep — authorize the Request:
- Owner calls
simplePull.setRequestMetadata(request, maxPrincipal, minOutput)to allocate a budget.maxPrincipalis the cumulativeprincipalthe Request is allowed to pull (covers partial fills and multiple offers);minOutputis the redemption floor.
Off-chain (step 1) — sign and submit the offer:
- Sign in to the BF API with the owner’s key (see Onboarding).
- Fetch the Request’s EIP-712 domain via
GET /v1/auction?domain=true(or readeip712Domain()on the Request itself for an authoritative cross-check). - Build the
Offerpayload withmaker = address(simplePull),useCallback = true, a nonce strictly greater than the Request’s stored minimum nonce forsimplePull, and a short-livedexpiration. - Owner signs the typed-data payload with their EOA key.
Request.consumewill route signature verification throughSimplePull.isValidSignature, which Solady’sERC1271resolves back to the owner. POST /v1/offerwith the signature attached.
On-chain (step 2) — Request pulls funds:
- A 3F operator calls
Request.consume(offer, signature). There is nothing for the owner to do at this stage — the offer sits passively until matched or until expiration. - Inside
consume, the Request validates the signature, invokes the callback (where sourcing happens), then pullsprincipaland mints PT/YT tosimplePull. Theyieldamount is only used to size the YT mint; it is not transferred at consume time.
Redemption — When 3F marks the Request repaid:
- Anyone calls
simplePull.unlock(request). The contract burns all PT/YT it holds and receives the underlying asset. - The owner sweeps (or — see below —
unlockitself can route the proceeds onward).
Annotated Extension Points
Section titled “Annotated Extension Points”The reference contract is the minimum that works. Real integrations diverge from it in four predictable places.
1. onRequestConsumed — source capital just-in-time
Section titled “1. onRequestConsumed — source capital just-in-time”This is the hook the Request calls before it pulls funds. The reference contract assumes the asset is already sitting on the contract, but the entire point of pull mode is the opposite: capital lives somewhere productive (a lending market, a perp position, an ERC-4626 vault, another chain) until the moment it’s needed.
function onRequestConsumed( Offer calldata offer, bytes calldata signature, uint256 principal, uint256 yield) external override { _ensureWhitelisted(msg.sender);
RequestMetadata memory metadata = requestMetadata[msg.sender];
if (principal > metadata.maxPrincipal) { revert InsufficientAllowance(); } metadata.maxPrincipal -= uint128(principal); requestMetadata[msg.sender] = metadata;
address asset = IRequest(msg.sender).asset();
// EXTENSION POINT: source `principal` of `asset` here.
asset.safeApprove(msg.sender, principal);
emit Pulled(msg.sender, principal, yield);}Two typical ways to source the principal:
- Borrowing. Open a debt position against existing collateral and draw down
principalunits of the asset. The BF keeps its collateral productive elsewhere and only carries the debt for the duration of the bridge loan;unlocklater closes it out. - Removing from another LP position. Withdraw
principalunits of the asset from an existing position — a lending market supply, an ERC-4626 yield vault, an AMM LP. The position is unwound for the duration of the bridge loan and restored at unlock.
Two hard constraints on whatever you put in the extension block:
- It must complete inside this call. The Request’s
transferFromruns in the same transaction; there is no opportunity for an asynchronous fill. If sourcing reverts, the wholeconsumereverts and the offer remains valid for the next attempt (until itsexpiration). - It must not over-approve. Always approve exactly
principal. The Request only pulls the principal amount at consume time — theyieldparameter is the YT mint size, not capital to transfer. The reference uses Solady’ssafeApprove, which handles non-standard tokens like USDT; it does not leave residual allowance after the transfer because the Request only pulls what it needs.
2. _erc1271Signer — split admin and signer in production
Section titled “2. _erc1271Signer — split admin and signer in production”The reference returns owner(), meaning the same EOA both administers the contract (calls setRequestMetadata, pull) and signs offers. That’s the simplest setup, but it concentrates risk: a compromise of the signing key is also a compromise of the configuration role.
function _erc1271Signer() internal view override returns (address) { return owner();}In a real deployment you would typically:
- Store a separate
signerslot, rotatable byowner, and return it here. - Optionally tighten verification: decode the
Offerfrom the original calldata insideisValidSignature, enforce caps onamount/expectedReturn, requireuseCallback == true, pin maximumexpirationwindows — and only then delegate to ECDSA recovery. This narrows what a leaked signer key can authorize.
3. setRequestMetadata — rolling per-Request budget
Section titled “3. setRequestMetadata — rolling per-Request budget”struct RequestMetadata { uint128 maxPrincipal; uint128 minOutput;}
mapping(address request => RequestMetadata) public requestMetadata;
function setRequestMetadata(address request, uint128 maxPrincipal, uint128 minOutput) external onlyOwner{ _ensureWhitelisted(request); requestMetadata[request] = RequestMetadata({maxPrincipal: maxPrincipal, minOutput: minOutput}); emit SetRequestMetadata(request, maxPrincipal, minOutput);}maxPrincipal is cumulative, not per-fill. It’s decremented inside onRequestConsumed, so a single configuration covers an off-chain budget that may be expressed as several offers and may be filled partially. To revoke, set it back to 0 — any in-flight consume then reverts at the InsufficientAllowance check.
Note that this is the contract’s cap and is independent of the Request’s nonce-based offer invalidation. For full cryptographic finality on outstanding offers, also bump the on-chain minimum nonce on the Request (see Settlement § Nonce Management).
4. unlock — route redeemed funds to a destination
Section titled “4. unlock — route redeemed funds to a destination”After repayment, burnAll returns the underlying asset to the contract. The reference leaves it sitting there for the owner to sweep with pull. That’s the default, but the same function can route proceeds straight into the next venue, in the same transaction.
function unlock(address request) external returns (address asset, uint256 amount) { _ensureWhitelisted(request);
asset = IRequest(request).asset(); { (,, uint256 principal, uint256 yield) = IVaultController(request).burnAll(address(this), address(this)); amount = principal + yield; }
if (amount < requestMetadata[request].minOutput) { revert InsufficientOutput(); }
// EXTENSION POINT: route `amount` of `asset` to its destination here.
emit Unlocked(request, amount);}Two typical destinations (the inverse of the sourcing options above):
- Repaying a borrow. If the principal was sourced by opening a debt position in
onRequestConsumed, repay it here to close the leg. - Putting back into an LP position. Deposit
amountback into the lending market / yield vault / AMM that originally provided the principal, restoring the pre-loan position.
Permissionless redemption with a destination wired in is operationally convenient (a keeper can sweep without an owner key), but it also means anyone can decide when redemption happens. If the destination has its own deposit conditions (capacity caps, paused state, queued withdrawals), make sure the routing logic is robust to all of them — or gate unlock on onlyOwner so timing stays in your control.
Full Code
Section titled “Full Code”// SPDX-License-Identifier: MITpragma solidity ^0.8.30;
import {Ownable} from "lib/solady/src/auth/Ownable.sol";import {ERC1271} from "lib/solady/src/accounts/ERC1271.sol";import {IRequestCallback, Offer} from "lib/grunt/src/interfaces/request/IRequestCallback.sol";import {IWhitelist} from "lib/3f-request-whitelist/src/IWhitelist.sol";import {IRequest} from "lib/grunt/src/interfaces/request/IRequest.sol";import {SafeTransferLib} from "lib/solady/src/utils/SafeTransferLib.sol";import {IVaultController} from "lib/grunt/src/interfaces/request/IVaultController.sol";
/// @title SimplePull/// @author 3F Protocol/// @notice Minimal reference integration of the 3F `Request` contract for/// bridge facilitators using the **pull** funding model with/// just-in-time (JIT) capital sourcing.contract SimplePull is Ownable, ERC1271, IRequestCallback { using SafeTransferLib for address;
error InsufficientAllowance(); error InsufficientOutput(); error NotWhitelisted();
event Pulled(address indexed request, uint256 principal, uint256 yield); event SetRequestMetadata(address indexed request, uint128 maxPrincipal, uint128 minOutput); event Unlocked(address indexed request, uint256 amount);
address public immutable REQUEST_WHITELIST;
struct RequestMetadata { uint128 maxPrincipal; uint128 minOutput; }
mapping(address request => RequestMetadata) public requestMetadata;
constructor(address owner, address requestWhitelist) { REQUEST_WHITELIST = requestWhitelist; _initializeOwner(owner); }
function setRequestMetadata(address request, uint128 maxPrincipal, uint128 minOutput) external onlyOwner { _ensureWhitelisted(request); requestMetadata[request] = RequestMetadata({maxPrincipal: maxPrincipal, minOutput: minOutput}); emit SetRequestMetadata(request, maxPrincipal, minOutput); }
function pull(address to, address asset, uint256 amount) external onlyOwner { asset.safeTransfer(to, amount); }
function onRequestConsumed( Offer calldata offer, bytes calldata signature, uint256 principal, uint256 yield ) external override { offer; signature;
_ensureWhitelisted(msg.sender);
RequestMetadata memory metadata = requestMetadata[msg.sender];
if (principal > metadata.maxPrincipal) { revert InsufficientAllowance(); } metadata.maxPrincipal -= uint128(principal); requestMetadata[msg.sender] = metadata;
address asset = IRequest(msg.sender).asset(); asset.safeApprove(msg.sender, principal);
emit Pulled(msg.sender, principal, yield); }
function unlock(address request) external returns (address asset, uint256 amount) { _ensureWhitelisted(request);
asset = IRequest(request).asset(); { (,, uint256 principal, uint256 yield) = IVaultController(request).burnAll(address(this), address(this)); amount = principal + yield; }
if (amount < requestMetadata[request].minOutput) { revert InsufficientOutput(); } emit Unlocked(request, amount); }
function _ensureWhitelisted(address request) internal view { if ( IWhitelist(REQUEST_WHITELIST).isWhitelisted(request) != IWhitelist.WhitelistStatus.Whitelisted ) { revert NotWhitelisted(); } }
function _erc1271Signer() internal view override returns (address) { return owner(); }
function _domainNameAndVersion() internal pure override returns (string memory name, string memory version) { return ("SimplePull", "1.0.0"); }}