Developers6 min read

Wallets and signed metadata

Wallet authorization, transaction recovery, exact metadata signing, and image-upload limits.

The wallet signs messages and transactions. The backend verifies metadata signatures and supplies reads/quotes; it never receives a private key. The frontend discovers injected wallets through EIP-6963 with a legacy-provider fallback and uses the selected EIP-1193 provider.

Account and network lifecycle

Request the initial account connection from an explicit user action. Restore an existing connection with silent account/network reads. A refresh must not request permissions, switch networks, start setup deployments, or sign a transaction.

Treat account and chain changes as invalidating cached transaction intent. Check the current account and chain immediately before each signature and submission, including approvals. The application stores the selected provider hint rather than treating locally saved accounts as authorization.

Disconnect clears the application session and saved provider choice, then attempts wallet_revokePermissions. A wallet may not support revocation. Disconnect does not revoke on-chain allowances.

Transactions and recovery

The shared transaction helper simulates the call, estimates gas, checks the RPC network, and asks the wallet to submit. Required approvals target the contract that actually spends the token. The application skips sufficient allowances; when replacing a nonzero insufficient allowance, it first resets to zero and then approves the requested amount.

Operation Typical spender or target
In-kind creation Factory spends constituent tokens
Direct mint Vault spends constituent tokens
ETH-funded creation/buy Native factory/router receives ETH
Native sell Native trading router spends the requested shares
Add liquidity or ETH pool sell Liquidity helper spends shares
ERC-20-settled pool trade DEX router spends settlement tokens for buy, shares for sell
Direct redemption/claim Vault burns caller shares or pays an existing claim receiver

After broadcast, persist the hash with its chain, account, and operation context. A receipt timeout leaves an unresolved transaction rather than authorizing another submission. The application blocks subsequent operations until recovery resolves the pending receipt. The wallet/explorer is still a recovery source if browser storage is unavailable.

Verify the expected contract event after a successful receipt. Retain claim IDs from RedemptionRequested; claim payouts always go to their fixed receiver. A missing expected event requires investigation even if the receipt succeeded.

Metadata request schema

POST /v1/metadata accepts:

{
  metadata: { name, symbol, description, image },
  address,
  chainId,
  expires,
  signature
}

The metadata object must contain exactly four string fields. The signature binds the exact JSON serialization, signing address, chain, and expiry. expires is an integer Unix timestamp in seconds, strictly in the future and no more than 600 seconds ahead of the server’s current time.

Field Upload limit
name At most 80 JavaScript string units; nonempty after cleaning
symbol At most 16 string units; nonempty after cleaning
description At most 2,000 string units
image At most 2,800,000 string units and the image rules below
address Nonzero Ethereum address
signature 64-byte compact or 65-byte EOA message signature encoded as hex

On-chain creation has separate byte-length limits, including a 64-byte share name and 16-byte symbol. Passing metadata upload validation does not establish that the same text is valid for contract creation, especially for multibyte characters.

Exact message format

Compute the lowercase SHA-256 hex digest of the UTF-8 bytes of JSON.stringify(metadata), then sign this text with an Ethereum message signature:

RRR metadata upload
Chain: <chainId>
Address: <lowercase signing address>
Expires: <integer Unix seconds>
SHA256: <64 lowercase hex characters>

Use newline characters between lines and no trailing newline. Preserve the metadata field order and strings between hashing and JSON submission. The format is not an EIP-712 typed-data request or a sorted-key JSON canonicalization scheme.

The verifier supports EOA message signatures. ERC-1271 smart-contract-wallet signature validation is not implemented for this upload endpoint.

Browser integration example

This TypeScript helper accepts a viem wallet client that the user has already connected and an explicitly selected account. It performs one metadata signature and upload when called from the application’s upload action. Set apiOrigin to your service’s public origin and allow the browser application’s origin in backend CORS.

import type { Address, WalletClient } from 'viem';

type Metadata = {
  name: string;
  symbol: string;
  description: string;
  image: string;
};

export async function uploadSignedMetadata(
  apiOrigin: string,
  wallet: WalletClient,
  account: Address,
  chainId: 4663 | 46630,
  metadata: Metadata,
) {
  const assertWallet = async () => {
    const [activeChain, accounts] = await Promise.all([
      wallet.getChainId(), wallet.getAddresses(),
    ]);
    if (activeChain !== chainId ||
        accounts[0]?.toLowerCase() !== account.toLowerCase()) {
      throw new Error('Wallet account or network changed.');
    }
  };
  const configResponse = await fetch(`${apiOrigin}/v1/config`);
  if (!configResponse.ok) throw new Error('Configuration unavailable.');
  const config = await configResponse.json();
  if (config.chainId !== chainId) throw new Error('API network mismatch.');

  // Snapshot the exact payload so edits cannot change it during signing.
  const content: Metadata = {
    name: metadata.name,
    symbol: metadata.symbol,
    description: metadata.description,
    image: metadata.image,
  };
  const bytes = new TextEncoder().encode(JSON.stringify(content));
  const digest = await crypto.subtle.digest('SHA-256', bytes);
  const hash = [...new Uint8Array(digest)]
    .map((byte) => byte.toString(16).padStart(2, '0')).join('');
  const expires = Math.floor(Date.now() / 1000) + 300;
  const message = `RRR metadata upload\nChain: ${chainId}` +
    `\nAddress: ${account.toLowerCase()}\nExpires: ${expires}\nSHA256: ${hash}`;

  await assertWallet();
  const signature = await wallet.signMessage({ account, message });
  await assertWallet();
  const response = await fetch(`${apiOrigin}/v1/metadata`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      metadata: content, address: account, chainId, expires, signature,
    }),
  });
  const result = await response.json();
  if (!response.ok) throw new Error(result.error ?? 'Upload failed.');
  if (typeof result.uri !== 'string') throw new Error('Invalid upload response.');
  return result.uri as string;
}

An example content object is { name: "Example basket", symbol: "EXAMPLE", description: "Developer example", image: "cover:sage" }. The server returns HTTP 201 with { "uri": "<API origin>/v1/metadata/<hash>" }. Use that returned URI in the contract’s metadata field.

Image processing and immutable storage

Accepted image strings are:

Input Rules
Empty string No image
Predefined cover cover:peach, cover:sage, cover:lavender, cover:yellow, cover:blue, cover:pink
Stored media URI Exact same public API origin and /v1/media/<hash>.webp; referenced file must exist
Raster data URL Canonical base64 PNG, JPEG, or WebP; decoded file at most 2 MiB

Remote image fetching, SVG, GIF, and animated/multipage input are unsupported. Raster input is limited to 16 million pixels, oriented from its image metadata, resized within 1,024 × 1,024 without enlargement, and re-encoded to WebP at quality 85. At most two image conversions run concurrently, with a five-second encoding timeout.

Verification happens against the original metadata. The service then removes specified control and bidirectional formatting characters, trims text, processes the image, and hashes the stored JSON. The final metadata URI hash can therefore differ from the digest in the signing message.

Stored JSON and WebP are content-addressed files under DATA_DIR/content. Upload replay produces the same URI when normalized content is identical. There is no update/delete route. Content-addressing establishes identity, not guaranteed hosting duration: preserve this directory and the public origin to keep existing links reachable.

The signature authorizes this upload. It is not a curated-token approval, ownership registry, contract audit, or an endorsement of claims made in descriptive text.

Documentation reviewed September 10, 2026Back to top ↑
Start typing to search all documentation.
↑ ↓ to navigate · Enter to openesc to close
Documentation
Open app