Programmable ACLs
What are programmable ACLs?
Section titled “What are 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.
The interface
Section titled “The interface”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 withUnauthorized; - 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).
Step 1 — write and deploy an authorizer
Section titled “Step 1 — write and deploy an authorizer”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: MITpragma 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.
Step 2 — attach it to a data set
Section titled “Step 2 — attach it to a data set”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 signingawait 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.
Session keys vs programmable ACLs
Section titled “Session keys vs programmable ACLs”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 keys | Programmable ACLs | |
|---|---|---|
| What it is | An SDK-native ephemeral secp256k1 key with on-chain permission grants | Your own contract deciding each write |
| Where policy lives | The shared SessionKeyRegistry (per-key, per-operation, time-boxed grants) | Arbitrary logic in your authorizer |
| Curves / auth | secp256k1 (EVM signatures) | Anything — P256 passkeys/WebAuthn, multisig, thresholds, oracles |
| SDK support | First-class (@filoz/synapse-core/session-key, Synapse({ sessionKey })) | extraData passthrough + viem (no dedicated helper yet) |
| Scope | Per session key, across all your data sets | Per data set |
| Reach for it when | You want silent signing / better dApp UX with the standard model | You 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.
Security Considerations & Caveats
Section titled “Security Considerations & Caveats”- Full responsibility. When attached to a dataset the authorizer contract takes over *all
responsibility for access controls, including verifying whatever
signatureit expects overdigest. 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. extraDatasize. Space for the auth envelope inextraDatais 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.
Next Steps
Section titled “Next Steps”- Session Keys — the SDK-native delegation model.
- Storage Operations — add-pieces, removals, and
termination that accept the
extraDataoverride. - Filecoin Warm Storage Service — how FWSS authorizes writes.