Push Mode Contract Example
A push-mode contract integration is owner-driven on-chain. The owner submits an offer through the BF API; once a 3F operator records the acceptance with authorizeMinting, the owner calls a single function on the contract that approves the asset and mints PT/YT in the same transaction.
This page walks through SimplePush, a minimal reference contract published in 3FLabs/request-integration-examples. It focuses on the spots where a real integration would extend the reference — sourcing the principal before the mint, 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. POST /v1/offer| API[BF API]
API -->|2. Match + authorizeMinting| R[Request]
O -->|3. lend| C[SimplePush]
C -->|4. approve + mint| R
R -->|5. PT + YT| C
- Owner authenticates against the BF API and submits an offer naming
SimplePushas the funding address. - The auction engine accepts the offer and a 3F operator calls
Request.authorizeMinting(simplePush, principal, yield), recording the accepted amounts on-chain. The offer transitions toAWAITING_MINT. - Owner calls
SimplePush.lend(request, ...). lendsources the principal (if needed), approves the asset, callsRequest.mint, then revokes the residual allowance.- PT and YT land on
SimplePushand stay there until repayment.
Step-by-Step
Section titled “Step-by-Step”Off-chain (step 1) — submit the offer:
- Generate an API key against the BF identity (see Onboarding § Generate Your API Key).
POST /v1/offerwithfundingAddress = address(simplePush), the requestedamountandexpectedReturn, and the targetauctionId.- Poll
GET /v1/offeruntil the offer isAWAITING_MINT. That status is the signal thatauthorizeMintinghas landed on-chain.
On-chain (step 2) — push the funds:
- Call
simplePush.lend(request, maxPt, minYt, minOutput)from the owner.maxPtshould equal the offer’samountandminYtthe offer’sexpectedReturn; these are slippage bounds in case the pending authorization was mutated between matching and minting. - The contract reads
mintAuthorization(this)from the Request, sources the principal if needed, approves the asset, callsRequest.mint, and revokes any residual allowance. PT and YT now sit onSimplePush.
Redemption — When 3F marks the Request repaid (setRepaid()):
- Anyone calls
simplePush.unlock(request). The contract burns all PT/YT it holds viaVaultController.burnAlland receives the underlying. - Owner sweeps with
pull(to, asset, amount)— or, ifunlockis extended (see below), the proceeds route to a destination automatically.
Annotated Extension Points
Section titled “Annotated Extension Points”The reference contract is the minimum that works. Real integrations diverge from it in two predictable places.
1. lend — source the principal before the mint
Section titled “1. lend — source the principal before the mint”The reference assumes the asset is already sitting on the contract when lend is called. In practice that’s rarely the case: capital usually lives somewhere productive between bridge loans. lend is the natural place to source it, because everything that happens between the source call and Request.mint is in the same transaction.
function lend(address request, uint128 maxPt, uint128 minYt, uint256 minOutput) external onlyOwner returns (address asset, uint256 pt, uint256 yt){ _ensureWhitelisted(request);
(pt, yt) = IRequest(request).mintAuthorization(msg.sender); asset = IRequest(request).asset();
// EXTENSION POINT: source `pt` of `asset` here.
asset.safeApprove(request, pt); IRequest(request).mint(maxPt, minYt); asset.safeApprove(request, 0);
_setMinOutput(request, minOutput); emit Lended(request, pt, yt);}Two typical ways to source the principal:
- Borrowing. Open a debt position against existing collateral and draw down
ptunits 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
ptunits 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.
A few things worth flagging:
ptcomes frommintAuthorization, not from the caller. The owner only passes slippage bounds (maxPt,minYt). This means the contract sources exactly the authorized principal — no more, no less — even if the off-chain offer was partially filled.maxPtandminYtare belt-and-braces. They guard against the pending authorization being mutated between the time the owner reads the BF API and the timelendlands on-chain. SetmaxPtto the offer’samountandminYtto itsexpectedReturn; any deviation reverts insideRequest.mint.- Allowance is revoked after the mint.
safeApprove(request, 0)immediately aftermintmakes sure no standing allowance is ever left on the contract. Keep that property if you extend the function — never leave allowances open across calls. - If sourcing reverts, the offer stays in
AWAITING_MINTso the owner can retry. After the acceptance window expires, 3F may fail the offer and pick another BF — see Onboarding § Acceptance timeout.
2. unlock — route redeemed funds to a destination
Section titled “2. unlock — route redeemed funds to a destination”unlock is identical in shape to the pull-mode version. After repayment, burnAll returns the underlying asset to the contract; the reference leaves it sitting there for the owner to sweep with pull. The same function can route proceeds straight into the next venue.
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 < minOutputs[request]) { revert InsufficientOutput(); }
// EXTENSION POINT: route `amount` of `asset` to its destination here.}Two typical destinations (the inverse of the sourcing options above):
- Repaying a borrow. If the principal was sourced by opening a debt position in
lend, 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.
The same caveat as in pull mode applies: permissionless redemption with routing wired in means anyone can decide when redemption happens. If the destination has deposit conditions of its own (caps, paused state, withdrawal queues), either make the routing 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 {IRequest} from "lib/grunt/src/interfaces/request/IRequest.sol";import {IVaultController} from "lib/grunt/src/interfaces/request/IVaultController.sol";import {Ownable} from "lib/solady/src/auth/Ownable.sol";import {SafeTransferLib} from "lib/solady/src/utils/SafeTransferLib.sol";import {IWhitelist} from "lib/3f-request-whitelist/src/IWhitelist.sol";
/// @title SimplePush/// @author 3F Protocol/// @notice Minimal reference integration of the 3F `Request` contract for bridge/// facilitators using the **push** funding model.contract SimplePush is Ownable { using SafeTransferLib for address;
error NotWhitelisted(); error InsufficientOutput();
event Lended(address indexed request, uint256 pt, uint256 yt); event SetMinOutput(address indexed request, uint256 minOutput);
address public immutable REQUEST_WHITELIST;
mapping(address request => uint256) public minOutputs;
constructor(address owner, address requestWhitelist) { REQUEST_WHITELIST = requestWhitelist; _initializeOwner(owner); }
function lend(address request, uint128 maxPt, uint128 minYt, uint256 minOutput) external onlyOwner returns (address asset, uint256 pt, uint256 yt) { _ensureWhitelisted(request);
(pt, yt) = IRequest(request).mintAuthorization(msg.sender); asset = IRequest(request).asset();
asset.safeApprove(request, pt); IRequest(request).mint(maxPt, minYt); asset.safeApprove(request, 0);
_setMinOutput(request, minOutput); emit Lended(request, pt, yt); }
function setMinOutput(address request, uint256 minOutput) external onlyOwner { _ensureWhitelisted(request); _setMinOutput(request, minOutput); }
function pull(address to, address asset, uint256 amount) external onlyOwner { asset.safeTransfer(to, amount); }
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 < minOutputs[request]) { revert InsufficientOutput(); } }
function _ensureWhitelisted(address request) internal view { if ( IWhitelist(REQUEST_WHITELIST).isWhitelisted(request) != IWhitelist.WhitelistStatus.Whitelisted ) { revert NotWhitelisted(); } }
function _setMinOutput(address request, uint256 minOutput) internal { minOutputs[request] = minOutput; emit SetMinOutput(request, minOutput); }}