Portrait Protocol

If you want to understand Portrait as a protocol instead of just as a product, the clearest public artifact is portrait-contracts-public. The repo is small enough to read in one sitting and opinionated enough to show what Portrait thought identity, naming, delegation, hosting, and paid plans should look like onchain.

The important thing is not any single contract. It is the split: one registry for IDs, one for names, one for delegates, one for plans, one for state, one for nodes, and one contract that keeps the addresses of the others in sync.

A key component of the protocol also lets us implement our own gas sponsorship logic, which I wrote about in Gas sponsorship.

portrait-contracts-public/
    • PortraitAccessRegistry.sol
    • PortraitContractRegistry.sol
    • PortraitIdRegistry.sol
    • PortraitNameRegistry.sol
    • PortraitNodeRegistry.sol
    • PortraitPlanRegistry.sol
    • PortraitStateRegistry.sol
3 folders, 17 files, one registry-based protocol split across contracts, interfaces, and libs

Signed intent is explicit

The signature stack lives in three files under lib/. IPortraitSigStruct defines a single struct, SigData, with six fields: action, target, targetType, version, params, and expirationTime. Every *For function in every registry builds one of these structs before asking PortraitSigValidator to verify it.

PortraitSigValidator.createMessage turns a SigData into a human-readable string. The string starts with a fixed introduction line, then lists the action, target, target type, version, a keccak hash of the full parameters, and the expiration time. The message is hashed with MessageHashUtils.toEthSignedMessageHash and then forwarded to the UniversalSigValidator for final verification. Because every field is visible in the message, a wallet popup shows the user exactly what they are approving before they sign.

The pattern repeats everywhere. PortraitIdRegistry uses it for RegisterFor, SetPrimaryPortraitFor, and TransferFor. PortraitAccessRegistry uses it for ToggleDelegateFor and ToggleDelegateRequestFor. PortraitNodeRegistry uses it for RegisterNodeToPortraitIdFor. PortraitNameRegistry uses it for RegisterNameFor. Every call hashes its own parameters into params, sets an action string that names the exact operation, and enforces a deadline.

struct SigData {
    string action;
    string target;
    string targetType;
    uint256 version;
    bytes32 params;
    uint256 expirationTime;
}

// Every registry builds one, e.g. in PortraitIdRegistry:
SigData memory data = SigData({
    action: "RegisterFor",
    target: CONTRACT_NAME,
    targetType: "Contract",
    version: VERSION,
    params: keccak256(abi.encodePacked(signer, owner, reservationHash, delegate, deadline)),
    expirationTime: deadline
});

portraitSigValidator.isValidSig(signer, data, sig);
solidity

EIP-6492 and smart wallet support

PortraitSigValidator does not call ecrecover directly. It delegates to UniversalSigValidator, a full implementation of EIP-6492 bundled in EIP6492.sol. That means Portrait signatures work with EOAs, deployed smart contract wallets via ERC-1271, and counterfactual wallets that have not been deployed yet.

This is the piece that makes ERC-4337 account abstraction compatible with the protocol. An ERC-4337 smart account may not exist onchain when the user first signs. EIP-6492 handles that case: the signature includes the factory address and the calldata needed to deploy the wallet, so the validator can deploy the wallet in memory, call isValidSignature on it, and then revert the deployment to avoid side effects. If the wallet is already deployed, the validator skips the factory step and goes straight to ERC-1271.

The verification order inside UniversalSigValidator.isValidSigImpl is strict. It checks for the EIP-6492 suffix first, then tries ERC-1271 if there is contract code at the signer address, and only falls back to ecrecover for plain EOA signatures. That layering means a Portrait user can rotate from an EOA to a smart wallet without any migration on the protocol side. The same isValidSig call covers all three cases.

// PortraitSigValidator.isValidSig
string memory message = createMessage(data);
bytes32 ethSignedMessageHash = MessageHashUtils.toEthSignedMessageHash(
    abi.encodePacked(message)
);
return universalSigValidator.isValidSig(signer, ethSignedMessageHash, sig);

// UniversalSigValidator verification order:
// 1. EIP-6492 suffix → deploy counterfactual wallet, then ERC-1271
// 2. Contract code exists → ERC-1271 isValidSignature
// 3. No contract code → ecrecover (plain EOA)
solidity

Cross-chain payments

The public repo can give the wrong first impression here. The identity side of Portrait Protocol was deployed on Base Sepolia, but the money path did not stay on the same chain. In portrait-api-backend, the contract helper maps the registry contracts to baseSepolia, then routes PortraitPaymentReceiverBase and PortraitPlusRegistryBase to Base mainnet and PortraitPaymentReceiverETH and PortraitPlusRegistryETH to Ethereum mainnet.

At the beginning, Portraits cost $10 per user. Both payment receiver contracts literally set paymentAmountUSD = 10, accept USDC, DAI, USDT, and ETH, and expose checkPaymentStatus(address) so the product can ask whether an address has already paid.

Later we introduced Portrait Plus. Instead of a simple paid flag, the Plus contracts store portraitIdToPortraitPlusExpiryTimestamp, so buying support time on Base or Ethereum extends an expiry window rather than flipping one boolean forever.

The frontend and backend did not merge that state in exactly the same place. In portrait-frontend, the public profile page reads PortraitPlusRegistryBase on Base mainnet directly to decide whether to show the Plus state. In portrait-api-backend, the real cross-chain merge lives in isPortraitPlus() and hasPortraitPlusTime(), which check Base first and then Ethereum before gating things like custom domains and page limits.

payments/
    • PortraitPaymentReceiverBase.sol
    • PortraitPlusRegistryBase.sol
    • PortraitPaymentReceiverETH.sol
    • PortraitPlusRegistryETH.sol
core registry writes on Base Sepolia, payment state on Base and Ethereum mainnet
const contractToChain = {
  PortraitIdRegistry: 'baseSepolia',
  PortraitStateRegistry: 'baseSepolia',
  PortraitNameRegistry: 'baseSepolia',
  PortraitAccessRegistry: 'baseSepolia',
  PortraitNodeRegistry: 'baseSepolia',
  PortraitPaymentReceiverBase: 'baseMainnet',
  PortraitPaymentReceiverETH: 'ethereumMainnet',
  PortraitPlusRegistryBase: 'baseMainnet',
  PortraitPlusRegistryETH: 'ethereumMainnet',
}

const portraitPlusTimestampBase = await PortraitPlusRegistryBase.portraitIdToPortraitPlusExpiryTimestamp(portraitId)
if (now < portraitPlusTimestampBase) return true

const portraitPlusTimestampETH = await PortraitPlusRegistryETH.portraitIdToPortraitPlusExpiryTimestamp(portraitId)
if (now < portraitPlusTimestampETH) return true
typescript

Acknowledgements

Written from the public portrait-contracts-public repo plus the local portrait-contracts, portrait-frontend, and portrait-api-backend repos. The payment section reflects the actual multi-chain implementation rather than just the stripped-down public tree.

Footnotes

  1. The public README lists deployed Base Sepolia addresses for the core registries and utilities, including PortraitIdRegistry, PortraitAccessRegistry, PortraitPlanRegistry, PortraitNodeRegistry, PortraitStateRegistry, and PortraitSigValidator.

  2. Most of the core contracts in the repo use OpenZeppelin upgradeable building blocks and the UUPS upgrade pattern, which is why PortraitContractRegistry matters so much as a coordination layer.

  3. The local portrait-contracts repo also includes PortraitPaymentReceiverETH.sol, PortraitPlusRegistryETH.sol, and multiple Base variants of the Plus contract. The backend helper maps those payment contracts to Base mainnet and Ethereum mainnet even while the identity registries stay on Base Sepolia.

  4. Both payment receiver contracts set paymentAmountUSD = 10, expose checkPaymentStatus(address), and accept USDC, DAI, USDT, and ETH. The local Base source file is named PortraitPaymentReceiveBase.sol, while the deployed artifact and backend contract name are PortraitPaymentReceiverBase.

  5. The EIP6492.sol in the repo is the reference implementation from the EIP itself. PortraitSigValidator wraps it with IPortraitSigStruct.SigData so that every registry gets human-readable, scoped, expiring signatures that work for EOAs, ERC-1271 smart wallets, and counterfactual ERC-4337 accounts alike.