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.