Skip to main content

Getting Started

Broadcast batched ERC2771 meta transactions for EVM Security Token (v4 and later) via a custom ERC2771CustomForwarder contract. This guide is EVM-specific. For Solana instructions, see Solana Getting Started.

Feature Overview​

Non-Sequential Nonces​

The forwarder contract doesn't require sequential nonces, enforcing only nonce uniqueness. This allows for high-scale parallelization of transaction signing.

Batching​

Incoming meta transactions are dynamically batched (based on a time window or a maximum batch size) and sent to the forwarder contract in a single transaction. This further increases the throughput of the system.

Status Callbacks​

The API accepts a callback URL which is used to deliver status updates for transaction execution.

Contract Deployment​

POST /api/v1/deploy_contracts deploys a complete v5 or v5.1 token set. The relayer deploys the contracts in order, then verifies source on the block explorer. All addresses arrive on the callback URL.

Architecture Overview​

Sequence Diagram​

Interaction Flow​

Below is a step-by-step breakdown of the process:

  1. Client Signer Initiates a Gasless Meta-Transaction

    • The Client Signer constructs and signs a gasless ERC2771 meta-transaction, and sends it to the Client Backend. This can be done fully offline without the need to keep track of the nonce.
  2. Client Backend Relays Transaction to the Relayer API

    • The Client Backend forwards the meta-transaction to the Relayer API via an HTTP POST request, including a callback URL for status updates.
  3. Relayer API Forwards to ERC2771 Forwarder Contract

    • The Relayer API validates and batches meta-transactions, and submits them to the ERC2771 Forwarder Contract. A pool of gas-payer wallets managed by the relayer covers the transaction gas fees.
  4. ERC2771 Forwarder Contract Interacts with Security Token Contract

    • Acting on behalf of the Client Signer, the ERC2771 Forwarder Contract calls the Security Token contract, executing the desired operation as specified in the meta-transaction.
  5. Security Token Returns Response to ERC2771 Forwarder Contract

    • The Security Token contract completes the requested operation and returns the result back to the ERC2771 Forwarder Contract.
  6. ERC2771 Forwarder Contract Relays Result to Relayer API

    • The ERC2771 Forwarder Contract returns the transaction result, including the transaction hash, to the Relayer API.
  7. Relayer API Sends Result to Client Backend

    • The Relayer API sends the transaction result and hash back to the Client Backend.

Usage​

See Tooling for snippets and tools to generate key pairs, signatures, and meta transactions.

Chain Support​

  • EVM chains: send metaTx, forwarderAddress, callback, timestamp.
  • Contract deployment is EVM-only. The request uses the chain assigned to your API key.

Authentication​

Requests​

Each API request must include an ed25519 public client key and a signature generated by the corresponding private key. The public key must be shared with Upside to whitelist the client, and should be rotated periodically.

Please refer to the Test Utils, API Reference, and examples below for details on how to generate and use the keys.

Callbacks​

Callbacks include X-Callback-Sig-B64 header, a base64-encoded ed25519 signature of json-canonicalized callback body.

The public key to verify the signature is available at GET /api/v1/callback_pk.

Rate Limiting​

Requests are rate limited per client public key (X-Request-Pk-B64). Three fixed windows are enforced at the same time, and a request is rejected if it would exceed any of them:

WindowLimit
Per second30 requests
Per minute600 requests
Per hour7,200 requests

Nonce Management​

warning

To avoid double-spending, it is important to store the nonce associated with each meta-transaction (or rather, the action such transaction represents). If the execution fails, the transaction must be resubmitted with the same nonce.

Due to the nature of the blockchain and the transaction processing logic in the API, there may be situations—such as mempool congestion or runtime errors—where the API returns an error even though the transaction may still be executed or might have already been processed. In such cases, when the transaction is resubmitted with the same nonce, the Forwarder Contract will ensure that it is not executed again.

Example Transaction​

Step 1​

Generate an ed25519 key pair for the API client and share the public key with Upside.

import { createKeypair } from './createKeypair'

const keypair = createKeypair()

console.log(
`Public Key: ${Buffer.from(keypair.publicKey).toString('base64')}`,
)
console.log(
`Secret Key: ${Buffer.from(keypair.secretKey).toString('base64')}`,
)

Step 2​

Create signed meta transaction.

import { Wallet } from 'ethers'
import { createMetaTx } from './createMetaTx'
import { createTxDataForMint } from './createTxData'

const functionName = 'mint'
const functionArgs = ['0x42D00fC2Efdace4859187DE4865Df9BaA320D5dB', '100']
const contractAddress = '0x1a35dc5e96A60941cB8cCbD869a897B1f68A7955'
const forwarderAddr = '0x826688fadd6297671f7cB7dd780e825689bed5AA'
const forwarderChainId = 43113
const signerSk = 'SIGNER_PRIVATE_EVM_KEY'
const signer = new Wallet(signerSk)

const metaTxWithSig = await createMetaTx(
{
from: signer.address,
to: contractAddress,
value: 0,
gas: 50000000,
nonce: new Date().getTime(),
deadline: Math.floor(new Date().getTime() / 1000) + 1 * 60 * 60,
data: createTxDataForMint({
to: String(functionArgs[0]),
value: parseInt(String(functionArgs[1])),
}),
signature: '',
},
forwarderAddr,
forwarderChainId,
signer,
)

console.log(metaTxWithSig)

Step 3​

Sign and send the API request.

import { createReqSignature } from './createReqSignature'
import { sign } from 'tweetnacl'

const callbackUrl = 'https://example.org/tx-updates/1/receive'
const timestamp = new Date().getTime()
const forwarderAddr = '0x826688fadd6297671f7cB7dd780e825689bed5AA'

const requestBody = {
metaTx: metaTxWithSig,
forwarderAddress: forwarderAddr,
callback: {
url: callbackUrl,
},
timestamp,
}

const keyPair = sign.keyPair.fromSecretKey(
Buffer.from('ED_25519_SECRET_IN_BASE64', 'base64'),
)

const signature = createReqSignature(requestBody, keyPair.secretKey)
const sigB64 = Buffer.from(signature).toString('base64')
const pkB64 = Buffer.from(keyPair.publicKey).toString('base64')

const resp = await fetch('https://relayer.upside.gg/api/v1/relay_meta_tx', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Request-Pk-B64': pkB64,
'X-Request-Sig-B64': sigB64,
},
body: JSON.stringify(requestBody),
})

if (resp.status !== 201) {
throw new Error(`Failed to submit meta tx: ${await resp.text()}`)
}

Step 4​

Listen for the callback on the provided URL. The callback will include status, transaction hash, and X-Request-Sig-B64 from the original request for authentication.

app.post('/tx-updates/:id/recieve', (req, res) => {
const { status, message, txHash, reqSignature } = req.body

// Verify that received signature:
// 1. Exists
// 2. Belongs to the correct meta-tx
// 3. Hasn't been received before

if (verifySignature(reqSignature, req.params.id)) {
console.log({ status, message, txHash })
res.status(200).send('OK')
} else {
res.status(401).send('Unauthorized')
}
})

Deploy a Token​

Use POST /api/v1/deploy_contracts to deploy a built-in v5 or v5.1 contract set. The same Ed25519 headers apply. A 201 response means the job is queued. Addresses arrive on the callback.

See the Relayer API Reference for the full request schema.

What the relayer deploys​

The relayer deploys contracts in this order. Later constructors receive addresses from earlier deployments.

  1. ERC2771CustomForwarder (skipped when forwarderContractAddress is set)
  2. AccessControl
  3. TransferRules
  4. SnapshotPeriods (skipped when snapshotType is none or off_chain)
  5. IdentityRegistry (skipped when identityRegistryContractAddress is set)
  6. RestrictedLockupTokenManagementExtension
  7. RestrictedLockupTokenExtension
  8. RestrictedLockupTokenStandardsExtension (v5.1 only)
  9. RestrictedLockupToken
  10. RestrictedSwap
  11. InterestPayment and PurchaseContract (only when snapshotType is on_chain_interest_payment)
  12. RecallablePayment and optional dedicated AccessControl (only when deployRecallablePayment is true)

There are no post-deploy initialize or grantRole calls. Each constructor configures the contract.

Required fields​

Always required: tokenType, name (3–100 chars), symbol (3–10 chars), decimals (0–18), contractAdminAddress, reserveAdminAddress, transferAdminAddress, maxTotalSupplyBaseUnit, minTimelockAmountBaseUnit, maxReleaseDelaySeconds (uint256 decimal strings, > 0), snapshotType, callback, timestamp.

Conditional:

FieldRequired when
walletsAdminAddress, amlKycValidityDurationSecondsa new IdentityRegistry is deployed
maxSwapLifetimeSeconds (3600–31536000)tokenType is comakery_security_token_v5_1
payoutTokenAddress, accrualPeriodSeconds, principalAmountPerTokenBaseUnit, interestAccrualStartTimestamp, interestAccrualEndTimestamp, maxInterestRateInBipssnapshotType is on_chain_interest_payment
recallablePaymentAccessControlAdminAddressa dedicated RecallablePayment AccessControl is deployed
recallablePaymentAccessControlAddressRecallablePayment reuses an existing AccessControl

Step 1 — Sign and send the deploy request​

import { createReqSignature } from './createReqSignature'
import { sign } from 'tweetnacl'

const requestBody = {
tokenType: 'comakery_security_token_v5_1',
name: 'Example Token',
symbol: 'EXT',
decimals: 18,
contractAdminAddress: '0x1111111111111111111111111111111111111111',
reserveAdminAddress: '0x2222222222222222222222222222222222222222',
transferAdminAddress: '0x3333333333333333333333333333333333333333',
walletsAdminAddress: '0x4444444444444444444444444444444444444444',
maxTotalSupplyBaseUnit: '1000000000000000000000000',
minTimelockAmountBaseUnit: '1',
maxReleaseDelaySeconds: '2592000',
amlKycValidityDurationSeconds: 31557600,
snapshotType: 'none',
maxSwapLifetimeSeconds: 86400,
callback: {
url: 'https://example.org/deploy-updates/1/receive',
requestId: 'deploy-1',
},
timestamp: Date.now(),
}

const keyPair = sign.keyPair.fromSecretKey(
Buffer.from('ED_25519_SECRET_IN_BASE64', 'base64'),
)
const signature = createReqSignature(requestBody, keyPair.secretKey)
const sigB64 = Buffer.from(signature).toString('base64')
const pkB64 = Buffer.from(keyPair.publicKey).toString('base64')

const resp = await fetch('https://relayer.upside.gg/api/v1/deploy_contracts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Request-Pk-B64': pkB64,
'X-Request-Sig-B64': sigB64,
},
body: JSON.stringify(requestBody),
})

if (resp.status !== 201) {
throw new Error(`Failed to queue deploy: ${await resp.text()}`)
}

Step 2 — Receive the deploy callback​

The first callback has type: "contract_deployment". status is success, partial, or failure. Use addresses.token as the token contract. contracts is the full per-filename list.

{
"type": "contract_deployment",
"requestId": "deploy-1",
"status": "success",
"tokenType": "comakery_security_token_v5_1",
"chainId": "84532",
"deployerAddress": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"contracts": [
{
"filename": "RestrictedLockupToken.sol",
"contractName": "contracts/RestrictedLockupToken.sol:RestrictedLockupToken",
"address": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"txHash": "0x…",
"blockNumber": 1234
}
],
"addresses": {
"forwarder": "0x…",
"accessControl": "0x…",
"transferRules": "0x…",
"identityRegistry": "0x…",
"token": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"restrictedSwap": "0x…"
},
"message": "Contracts deployed successfully",
"reqSignature": "…"
}

A second callback has type: "contract_verification" after the explorer verifies each deployed contract.

{
"type": "contract_verification",
"requestId": "deploy-1",
"status": "success",
"verifications": [
{
"filename": "RestrictedLockupToken.sol",
"address": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"status": "success",
"guid": "…",
"message": "Pass - Verified"
}
],
"reqSignature": "…"
}

Verify both callbacks with the same X-Callback-Sig-B64 header and GET /api/v1/callback_pk. Return HTTP 2xx so the relayer does not retry delivery.

warning

Do not resubmit a deploy that returned partial. Some contracts already exist on chain. Use the contracts list, or start a new request with reused addresses (forwarderContractAddress, identityRegistryContractAddress).

Next Steps​