Skip to content

Programmable ACLs

By default, every write to a data set — adding pieces, scheduling piece removals, terminating the service — is authorized by an EIP-712 signature from the data set’s payer, optionally delegated to a session key. Session key delegation offers a powerful UX upgrade over repetitive wallet signing operations but they delegate authority for all datasets that the payer owns.

A programmable ACL overrides that built-in auth check on an individual dataset basis with a smart contract supplied by the payer that implements the IDataSetAuthorizer interface. When the payer attaches an authorizer to a dataset, FWSS calls the authorizer to make the decision instead of checking the session key registry.

The authorizer can implement any policy you want:

  • verify a signature using a different algorithm — for example a P256 passkey assertion (Touch ID, secure enclave), which the built-in secp256k1 path cannot do;
  • require human presence (a biometric user-verification flag) for sensitive operations;
  • gate on the operation’s contents (metadata, piece paths);
  • authorize a machine agent’s stored key and your human passkey on the same data set, each scoped to different operations.

An authorizer is any contract implementing a single method:

interface IDataSetAuthorizer {
function isAuthorized(
uint256 dataSetId,
address payer,
bytes32 operation, // EIP-712 type hash of the op (AddPieces / SchedulePieceRemovals / TerminateService)
bytes32 digest, // the EIP-712 digest FWSS computed for this exact operation
bytes calldata signature, // opaque to FWSS — your contract interprets it
bytes calldata operationData // ABI-encoded raw op payload (empty for terminate)
) external returns (bool authorized);
}

FWSS calls this instead of its built-in signature check, for the three write operations. Semantics:

  • return true → the operation proceeds;
  • return false → FWSS reverts with Unauthorized;
  • revert / out-of-gas → treated as “not authorized”; the operation reverts.

isAuthorized is a state-mutating call (not view), so an authorizer may update its own storage while deciding — consume a nonce, tick a rate-limiter, log an event. FWSS gas-caps the sub-call and blocks re-entry into the authorization path while a decision is in flight.

The digest is FWSS’s EIP-712 digest for that exact operation and its parameters; the signature is whatever blob your authorizer expects (FWSS does not interpret it); operationData is the raw ABI-encoded operation payload, so a policy can gate on contents (it is empty for terminate).

Here is a minimal authorizer that accepts a P256 (secp256r1) signature over the operation digest from a single registered key — the kind of delegation the built-in secp256k1 path can’t express. It verifies via the FEVM secp256r1 precompile at 0x100:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SingleP256Authorizer {
uint256 public immutable pubKeyX;
uint256 public immutable pubKeyY;
constructor(uint256 x, uint256 y) { pubKeyX = x; pubKeyY = y; }
function isAuthorized(uint256, address, bytes32, bytes32 digest, bytes calldata signature, bytes calldata)
external view returns (bool)
{
(bytes32 r, bytes32 s) = abi.decode(signature, (bytes32, bytes32));
// 0x100 input: digest ‖ r ‖ s ‖ x ‖ y ; returns 32-byte 1 on success
(bool ok, bytes memory out) =
address(0x100).staticcall(abi.encodePacked(digest, r, s, bytes32(pubKeyX), bytes32(pubKeyY)));
return ok && out.length == 32 && bytes32(out) == bytes32(uint256(1));
}
}

Deploy it with your usual tooling (Foundry, Hardhat, or viem’s deployContract), passing your P256 public-key coordinates to the constructor.

Attaching is a call to setDataSetAuthorizer(dataSetId, authorizer) on the FWSS contract. Only the data set’s payer may call it.:

import { createWalletClient, http, getAddress, type Hex } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { calibration } from '@filoz/synapse-core/chains'
// FWSS address for your network — see /resources/contracts/
const WARM_STORAGE = '0x...' as Hex
const setAuthorizerAbi = [{
type: 'function', name: 'setDataSetAuthorizer', stateMutability: 'nonpayable',
inputs: [{ name: 'dataSetId', type: 'uint256' }, { name: 'authorizer', type: 'address' }],
outputs: [],
}] as const
const payer = createWalletClient({
account: privateKeyToAccount('0x<payer-private-key>' as Hex),
chain: calibration,
transport: http(),
})
await payer.writeContract({
address: WARM_STORAGE,
abi: setAuthorizerAbi,
functionName: 'setDataSetAuthorizer',
args: [42n /* dataSetId */, getAddress('0x<your-authorizer>')],
})

setDataSetAuthorizer requires the target to be a deployed contract (authorizer.code.length > 0), or address(0) to detach (see Step 4). You can read the current authorizer back with the matching getDataSetAuthorizer(uint256) → address view (or getDataSetAuthorizer on the FWSS State View contract).

Step 3 — authorize operations with extraData

Section titled “Step 3 — authorize operations with extraData”

FWSS operations that can be gated by the authorizer are:

  • AddPieces
  • SchedulePieceRemovals
  • TerminateService

Because all access control decisions are passed to the registered authorizer, it must handle all of these operations, else they will always revert.

Build the blob your isAuthorized implementation expects and pass it as the pre-built extraData on the operation:

import { encodeAbiParameters, type Hex } from 'viem'
// For our SingleP256Authorizer, `signature` = abi.encode(r, s) over the FWSS digest.
// Compute the digest FWSS will use for this add-pieces operation, sign it with your P256 key,
// then wrap the (r, s) exactly as your authorizer's abi.decode expects.
const signature = encodeAbiParameters(
[{ type: 'bytes32' }, { type: 'bytes32' }],
[rHex, sHex],
)
// FWSS forwards `extraData` to the SP, which passes it to your authorizer as `signature`.
const extraData = signature as Hex
// High-level: pass extraData to skip the SDK's own signing
await synapse.storage.addPieces({ dataSetId: 42n, pieces, extraData })

Step 4 — rotate or remove the authorizer

Section titled “Step 4 — rotate or remove the authorizer”

Attach a different contract to rotate, or address(0) to detach and return the data set to the default payer/session-key behavior:

await payer.writeContract({
address: WARM_STORAGE,
abi: setAuthorizerAbi,
functionName: 'setDataSetAuthorizer',
args: [42n, '0x0000000000000000000000000000000000000000'],
})

Detaching is immediate and always available to the payer, so a malfunctioning authorizer can never permanently lock a payer out of their own data set.

Both approaches delegate signing away from the payer wallet; they solve different problems and can be used together (a session key for routine UX, an authorizer for a specific policies on specific data sets).

Session keysProgrammable ACLs
What it isAn SDK-native ephemeral secp256k1 key with on-chain permission grantsYour own contract deciding each write
Where policy livesThe shared SessionKeyRegistry (per-key, per-operation, time-boxed grants)Arbitrary logic in your authorizer
Curves / authsecp256k1 (EVM signatures)Anything — P256 passkeys/WebAuthn, multisig, thresholds, oracles
SDK supportFirst-class (@filoz/synapse-core/session-key, Synapse({ sessionKey }))extraData passthrough + viem (no dedicated helper yet)
ScopePer session key, across all your data setsPer data set
Reach for it whenYou want silent signing / better dApp UX with the standard modelYou need a curve or policy the built-in check can’t express

Session keys remain the recommended default for ordinary “sign once, operate silently” UX. Reach for a programmable ACL when you need something the standard model can’t do — most commonly passkey/WebAuthn authorization or custom on-chain policy.

  • Full responsibility. When attached to a dataset the authorizer contract takes over *all responsibility for access controls, including verifying whatever signature it expects over digest. Exercise extreme diligence in security review of any authorizer contract you deploy. Ideally you should use an FWSS-provided contract and only write your own if you need functionality that is not already covered.
  • Gas. A P256/passkey authorizer verifies via the FEVM secp256r1 precompile, which is expensive on Filecoin (~100M-120M gas for a full passkey isAuthorized). The storage provider that relays your operation must supply enough gas; FWSS caps the authorizer sub-call at 150M gas.
  • extraData size. Space for the auth envelope in extraData is limited to 1024 bytes. Compact signatures (a raw P256 (r, s)) fit comfortably; a full WebAuthn passkey envelope is larger (~600–750 bytes), so ensure that the auth data is as compact as possible.
  • Replay. FWSS enforces replay protection for add-pieces (a per-payer nonce baked into the digest). The schedule-removals and terminate digests are not nonce-bound, so if your policy needs replay protection for those, your authorizer must provide it (e.g. consume its own nonce).
  • Direct termination is not gated. An attached authorizer gates signed (immediate) termination only. It does not restrict a payer or the storage provider from terminating a data set via the plain, unsigned path. Do not rely on an authorizer to gate or prevent termination.