Skip to content
3F Docs

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.

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
  1. Owner authenticates against the BF API and submits an offer naming SimplePush as the funding address.
  2. 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 to AWAITING_MINT.
  3. Owner calls SimplePush.lend(request, ...).
  4. lend sources the principal (if needed), approves the asset, calls Request.mint, then revokes the residual allowance.
  5. PT and YT land on SimplePush and stay there until repayment.

Off-chain (step 1) — submit the offer:

  1. Generate an API key against the BF identity (see Onboarding § Generate Your API Key).
  2. POST /v1/offer with fundingAddress = address(simplePush), the requested amount and expectedReturn, and the target auctionId.
  3. Poll GET /v1/offer until the offer is AWAITING_MINT. That status is the signal that authorizeMinting has landed on-chain.

On-chain (step 2) — push the funds:

  1. Call simplePush.lend(request, maxPt, minYt, minOutput) from the owner. maxPt should equal the offer’s amount and minYt the offer’s expectedReturn; these are slippage bounds in case the pending authorization was mutated between matching and minting.
  2. The contract reads mintAuthorization(this) from the Request, sources the principal if needed, approves the asset, calls Request.mint, and revokes any residual allowance. PT and YT now sit on SimplePush.

Redemption — When 3F marks the Request repaid (setRepaid()):

  1. Anyone calls simplePush.unlock(request). The contract burns all PT/YT it holds via VaultController.burnAll and receives the underlying.
  2. Owner sweeps with pull(to, asset, amount) — or, if unlock is extended (see below), the proceeds route to a destination automatically.

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 pt units of the asset. The BF keeps its collateral productive elsewhere and only carries the debt for the duration of the bridge loan; unlock later closes it out.
  • Removing from another LP position. Withdraw pt units 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:

  • pt comes from mintAuthorization, 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.
  • maxPt and minYt are belt-and-braces. They guard against the pending authorization being mutated between the time the owner reads the BF API and the time lend lands on-chain. Set maxPt to the offer’s amount and minYt to its expectedReturn; any deviation reverts inside Request.mint.
  • Allowance is revoked after the mint. safeApprove(request, 0) immediately after mint makes 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_MINT so 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 amount back 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.

// SPDX-License-Identifier: MIT
pragma 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);
}
}