FPSF-SS-001 — Guides
Layer: Guides · Audience: developers, integration teams, processor operators For normative requirements, see the Formal Specification.
Guide 1: Submit a Payment
Prerequisites: read Core Concepts — specifically the dual-signature pattern and the fee model.
Step 1 — Retrieve Session Parameters
Exchange the Ephemeral Token for payment parameters:
const session = await fetch(`${widgetUrl}/api/session`, {
headers: { Authorization: `Bearer ${ephemeralToken}` },
}).then(r => r.json());
// session contains:
// {
// token: "0x...", ERC-2612 stablecoin address
// beneficiary: "0x...", merchant receiving address
// principal: "100000000", amount in smallest token units
// orderReference: "0x...", bytes16 Order Reference (34-char hex)
// acquirerId: "0x...", bytes16 Acquirer ID (34-char hex), or Zero-UUID
// deadline: 1750000000 Unix timestamp (seconds)
// }
The Ephemeral Token is now invalid. Do not retry this call.
Step 2 — Fetch Nonce and Fees
// Via wallet-gateway
const { nonce } = await walletGateway.getNonce(ownerAddress, session.token);
const { operatorFee, acquiringFee, totalWithFees } =
await settlementContract.calculateFees(
session.token,
session.principal,
session.acquirerId
);
const permitValue = totalWithFees;
Always call calculateFees immediately before constructing the permit. Do not use cached values.
Step 3 — Build PermitParams
const permitParams = {
owner: ownerAddress,
spender: settlementContractAddress,
value: permitValue.toString(),
nonce: nonce.toString(),
deadline: session.deadline,
};
Step 4 — Sign the Permit Signature
Sign against the token contract's EIP-712 domain separator:
const tokenDomain = {
name: await tokenContract.name(),
version: "1",
chainId: chainId,
verifyingContract: session.token,
};
const permitTypes = {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
const permitSig = await signer.signTypedData(tokenDomain, permitTypes, permitParams);
const { v: v1, r: r1, s: s1 } = ethers.Signature.from(permitSig);
const permitHash = ethers.TypedDataEncoder.hash(tokenDomain, permitTypes, permitParams);
const permitSigObj = {
hash: permitHash,
v: v1 < 27 ? v1 + 27 : v1,
r: r1,
s: s1,
};
Step 5 — Build PayWithPermitParams
Note: orderReference and acquirerId are separate fields — do not concatenate them.
const payWithPermitParams = {
token: session.token,
beneficiary: session.beneficiary,
orderReference: session.orderReference, // bytes16 — 34-char hex
acquirerId: session.acquirerId, // bytes16 — 34-char hex (Zero-UUID if none)
permitParams: permitParams,
};
Step 6 — Sign the Binding Signature
Sign against the Settlement Contract's EIP-712 domain separator:
const settlementDomain = {
name: "SettlementContract",
version: "1",
chainId: chainId,
verifyingContract: settlementContractAddress,
};
const payWithPermitTypes = {
PayWithPermitParams: [
{ name: "token", type: "address" },
{ name: "beneficiary", type: "address" },
{ name: "orderReference", type: "bytes16" },
{ name: "acquirerId", type: "bytes16" },
{ name: "permitParams", type: "PermitParams" },
],
PermitParams: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
const payWithPermitSig = await signer.signTypedData(
settlementDomain,
payWithPermitTypes,
payWithPermitParams
);
const { v: v2, r: r2, s: s2 } = ethers.Signature.from(payWithPermitSig);
const bindingHash = ethers.TypedDataEncoder.hash(
settlementDomain,
payWithPermitTypes,
payWithPermitParams
);
const payWithPermitSigObj = {
hash: bindingHash,
v: v2 < 27 ? v2 + 27 : v2,
r: r2,
s: s2,
};
Step 7 — Assemble and Submit
const payloadId = crypto.randomUUID();
const transferRequest = {
payWithPermitParams,
payWithPermitSig: payWithPermitSigObj,
permitSig: permitSigObj,
payloadId,
};
const ws = new WebSocket(`${walletGatewayWsUrl}?session=${payloadId}`);
ws.onmessage = (event) => handleStatusUpdate(JSON.parse(event.data));
const response = await fetch(`${walletGatewayUrl}/api/submit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(transferRequest),
});
Step 8 — Handle Status Updates
function handleStatusUpdate(update) {
switch (update.status) {
case "BROADCASTING":
// Transaction submitted — NOT final settlement
showToUser("Processing — awaiting confirmation...");
break;
case "PAYMENT_CONFIRMED":
// Confirmed by transfer-history — FINAL
showToUser("Payment confirmed.");
navigateToReceipt(update.txHash);
break;
case "FAILED":
showToUser(`Payment failed: ${update.reason}`);
break;
}
}
BROADCASTING is not final settlement. Wait for PAYMENT_CONFIRMED before treating a payment as complete.
Guide 2: Register as an Acquirer
Step 1 — Fetch Registration Parameters
const token = allowedTokens[0];
const price = await settlementContract.acquiringPrice(token);
const maxFeeBps = await settlementContract.maxAcquiringFeeBps(token);
const nonce = await tokenContract.nonces(payerAddress);
const desiredFeeBps = 100; // 1.00% — must be <= maxFeeBps
Step 2 — Build and Sign the Permit
PermitParams.value MUST equal price exactly.
const permitParams = {
owner: payerAddress,
spender: settlementContractAddress,
value: price.toString(),
nonce: nonce.toString(),
deadline: Math.floor(Date.now() / 1000) + 900,
};
// Sign against token domain (same as Guide 1 Step 4)
Step 3 — Build BuyAcquiringPackPermitParams
const buyParams = {
token: token,
feeValue: price.toString(),
acquiring: acquiringWalletAddress,
acquiringFeeBps_: desiredFeeBps,
permitParams: permitParams,
};
Step 4 — Sign the Binding Signature
const buyAcquiringPackTypes = {
BuyAcquiringPackPermitParams: [
{ name: "token", type: "address" },
{ name: "feeValue", type: "uint256" },
{ name: "acquiring", type: "address" },
{ name: "acquiringFeeBps_", type: "uint256" },
{ name: "permitParams", type: "PermitParams" },
],
PermitParams: [ /* same as above */ ],
};
// Sign against Settlement Contract domain (same pattern as Guide 1 Step 6)
Step 5 — Submit and Retrieve Acquirer ID
const response = await fetch(`${walletGatewayUrl}/api/submit-acquiring`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ buyAcquiringPackParams: buyParams, payWithPermitSig, permitSig }),
});
// Listen for AcquirerCreated event to retrieve your acquirerId
settlementContract.on("AcquirerCreated", (acquirerId, wallet, token, feeBps) => {
if (wallet.toLowerCase() === acquiringWalletAddress.toLowerCase()) {
console.log("Registered! Acquirer ID:", acquirerId);
}
});
Guide 3: Deploy and Configure the Settlement Contract
Deploy
const SettlementContract = await ethers.getContractFactory("SettlementContract");
const contract = await SettlementContract.deploy(
feeRecipientAddress,
[usdcAddress, eurcAddress], // acquiringAllowedTokens
);
await contract.waitForDeployment();
Configure Per-Token Fee Parameters
// USDC: $2.50 base fee (6 decimals) + 0.30% operator fee
await contract.setTokenFeeConfig(
usdcAddress,
ethers.parseUnits("2.50", 6), // baseFeeAmount
30, // operatorFeeBps (30 bps = 0.30%)
500, // maxAcquiringFeeBps (500 bps = 5.00%)
ethers.parseUnits("100", 6) // acquiringPrice
);
// EURC: different economics
await contract.setTokenFeeConfig(
eurcAddress,
ethers.parseUnits("2.00", 6),
25,
400,
ethers.parseUnits("80", 6)
);
Verify
Always verify the contract source code on the block explorer. Publish the contract address to the basic-data-server. This is not optional — conformant deployments MUST have their contract verified.
Common Mistakes
| Mistake | Result | Fix |
|---|---|---|
| Signing permit for principal only (without fees) | On-chain revert | Always use calculateFees and sign for totalWithFees |
Concatenating orderReference and acquirerId | Invalid Binding Signature; reconciliation failures | They are separate bytes16 parameters — never concatenate |
| Mixing EIP-712 domain separators | Invalid signature | Token domain for Permit Signature; Settlement Contract domain for Binding Signature |
Wrong v value (0 or 1) | Processor rejection | Normalize: if v < 27, add 27 |
Treating BROADCASTING as final settlement | Premature confirmation shown to user | Wait for PAYMENT_CONFIRMED |
| Caching nonce between transactions | Nonce mismatch at submission | Fetch nonce fresh immediately before each signature |
FPSF-SS-001 v1.0.0 · Draft · Fabric Payment Standards Foundation · Apache-2.0