Skip to main content

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

MistakeResultFix
Signing permit for principal only (without fees)On-chain revertAlways use calculateFees and sign for totalWithFees
Concatenating orderReference and acquirerIdInvalid Binding Signature; reconciliation failuresThey are separate bytes16 parameters — never concatenate
Mixing EIP-712 domain separatorsInvalid signatureToken domain for Permit Signature; Settlement Contract domain for Binding Signature
Wrong v value (0 or 1)Processor rejectionNormalize: if v < 27, add 27
Treating BROADCASTING as final settlementPremature confirmation shown to userWait for PAYMENT_CONFIRMED
Caching nonce between transactionsNonce mismatch at submissionFetch nonce fresh immediately before each signature

FPSF-SS-001 v1.0.0 · Draft · Fabric Payment Standards Foundation · Apache-2.0