Gas sponsorship
Portrait needed gas sponsorship before ERC-4337 had settled into something you could actually build a product on. Some of the protocol already had the shape of 4337, but the standard infrastructure was not there yet, so I built the sponsorship system myself.
This is the actual story from the code: what was already 4337-like in spirit, what was completely bespoke, and how I made gasless onboarding work anyway.
Before 4337 had a name
The first transaction kills most crypto UX. If a user has to buy gas before they can finish signup, claim a handle, or publish a profile, you have already lost them.
Portrait did not need a universal account abstraction framework. It needed a narrow system that could sponsor a few high-value actions reliably: register a Portrait ID, reserve a handle, publish profile state, and attach a node.
What was already 4337-like
The architecture already contained the same separation ERC-4337 later formalised. The signer, the submitter, the gas payer, and the verifier were not the same actor.
- The user signed intent.
- A Portrait-controlled delegate service wallet submitted and paid for the transaction.
- Backend validators decided whether a request was eligible for sponsorship.
- The contracts verified that the action was actually authorised.
Why build your own system
The important part is not that this looked a bit like 4337. The important part is that it was too early to rely on 4337 itself. We still needed the UX, so the only practical option was to build the sponsorship path directly into the product.
That meant no generic bundler market, no paymaster standard, and no shared EntryPoint. Just one application-owned service path that was explicit end to end.
The execution path
In the backend, sponsored actions converge on one queue. Controllers enqueue a contract name, method name, and arguments. A worker consumes the job and submits the call.
That sounds simple because it was simple. The point was to keep the trust boundary narrow and the operational surface area small.
const job = await queue.add('delegate/call', {
contractName: 'PortraitNodeRegistry',
methodName: 'registerNodeToPortraitId',
args: [nodeAddress, portraitId, deadline, sig],
});How signing actually worked
The contracts use PortraitSigValidator, which constructs a readable action message from a compact SigData payload and verifies it onchain.
So the system was not “a relayer that can do anything.” It was “a relayer that can do one named action if the contract can reconstruct and verify the user intent.” That is a much better security model.
struct SigData {
string action;
string target;
string targetType;
uint256 version;
bytes32 params;
uint256 expirationTime;
}registerFor bootstrapped sponsorship
The best part of the design lives in registration. PortraitIdRegistry.registerFor takes a signed registration intent plus a delegate address. If that delegate matches delegateServiceAddress, the registration flow installs the service wallet as an accepted delegate for the new user.
That means the first sponsored transaction creates the conditions for the next sponsored transactions. After registration, the backend can publish state as an authorised delegate instead of forcing the user to manually sign every routine write.
The real policy engine
There is no abstract rules engine in these repos. The policy layer is validator middleware, and that is the honest description. Before sponsorship happens, the backend checks exactly the things Portrait cares about.
- Does the authenticated address match the signer for
registerFor? - Does the supplied
nameandsecrethash to the reservation hash in the call? - Is the requested handle whitelisted?
- Does the user already have a Portrait?
- Is the deadline still valid and only a few minutes out?
- Is the node already attached or over the per-portrait cap?
Where it was not 4337
Everything here was bespoke. Each flow had its own contract method and its own verification semantics. Registration had registerFor. Delegate updates had toggleDelegateFor. Node registration had a dedicated signature proof path. Publishing used long-lived delegation rather than per-operation signatures.
So yes, the system had proto-4337 logic. But it was not a standard account abstraction stack. It was an application-specific sponsorship protocol designed to make one product work before the ecosystem had converged on shared abstractions.
Why the modularity mattered
That architectural separation paid off later. When we stopped continuing down the Coinbase wallet SaaS path and moved to Privy, we did not have to redesign identity and sponsorship from scratch.
Because Portrait ownership lived in our contracts, and because the sponsorship layer was already ours, we could treat wallet providers as interchangeable infrastructure. Auth could change. The protocol-level owner of a Portrait could still move cleanly from one EVM address to another.
That is the underrated advantage of building your own gas sponsorship logic instead of burying everything inside a wallet vendor. The current backend can authenticate a Privy-linked wallet and email, but the important point is that the wallet provider is no longer the source of truth for ownership.
If a user needed to move from one embedded wallet address to another, we could transfer the Portrait to the new EVM address with explicit contract support. That portability is not a side benefit. It is what makes embedded-wallet experimentation survivable.
function transferPortraitIdFor(
uint256 portraitId,
address from,
address to,
uint256 deadline,
bytes calldata sig
) external;Lessons
Gas sponsorship sounds like a payments problem. It is mostly an authorisation problem. Paying gas is easy. Proving the user meant this exact action, on this exact contract, until this exact deadline, is the hard part.
The part I still like most is that sponsorship is explicit in the contract interface. Nothing is hidden behind a magic forwarder. If a system is going to act on a user's behalf, the contract should say so in public.