# API Introduction
Source: https://dev-docs.multihopper.com/api-reference/introduction
Integrate with MultiHopper programmatically via the REST API
## Base URL
The API runs in two independent environments. Append `/api/v1` to the host for your environment:
| Environment | Base URL |
| -------------------- | --------------------------------------- |
| Devnet (testing) | `https://devnet.multihopper.com/api/v1` |
| Production (mainnet) | `https://multihopper.com/api/v1` |
Use the **server dropdown** at the top of any endpoint page to switch the interactive examples
between them. See [Environments](/concepts/environments) for keys, clusters, and dashboards.
## Authentication
All API endpoints require an API key. Pass it via the `x-api-key` header or as a Bearer token.
```bash theme={null}
# Header authentication
x-api-key: mh_live_abc123...
# Or Bearer token
Authorization: Bearer mh_live_abc123...
```
API keys are prefixed to indicate mode:
| Prefix | Mode |
| ---------- | -------------------------------- |
| `mh_test_` | Test mode — uses test accounting |
| `mh_live_` | Live mode — uses live accounting |
The Solana cluster (mainnet/devnet) depends on the environment the API is deployed against.
## Request format
Use JSON request bodies for `POST` requests and standard path/query parameters for `GET` and `DELETE` requests.
```
Content-Type: application/json
```
## Idempotency
All `POST` mutations require an `Idempotency-Key` header (8–64 characters, `[a-zA-Z0-9._-]`). Use a random UUID per request. Reusing a key with a different body returns `MH_071`.
```bash theme={null}
Idempotency-Key: a3f8c2e1-74b9-4d06-9832-15f0e7b3a129
```
| Code | Description | Status |
| -------- | -------------------------------------------------- | ------ |
| `MH_070` | Idempotency-Key header missing or invalid | 400 |
| `MH_071` | Idempotency-Key reused with different request body | 409 |
| `MH_072` | Idempotency-Key request still in progress | 409 |
## Error codes
All errors include a structured `MH_XXX` code for programmatic handling.
### Authentication
| Code | Description | Status |
| -------- | -------------------------- | ------ |
| `MH_001` | Invalid or missing API key | 401 |
| `MH_002` | API key revoked | 401 |
| `MH_003` | Integration suspended | 403 |
| `MH_004` | Rate limit exceeded | 429 |
### Validation
| Code | Description | Status |
| -------- | ------------------------------------------------- | ------ |
| `MH_010` | Unsupported token mint or missing pricing context | 400 |
| `MH_011` | Invalid wallet address | 400 |
| `MH_012` | Amount below minimum | 400 |
| `MH_013` | Hops out of range (3–10) | 400 |
| `MH_014` | Arrival time below minimum for hop count | 400 |
| `MH_015` | Invalid external ID format | 400 |
### Transfer lifecycle
| Code | Description | Status |
| -------- | ------------------------------------------------------------ | ------ |
| `MH_030` | Transfer not found | 404 |
| `MH_031` | Route creation failed | 500 |
| `MH_032` | Funding not completed within timeout | 408 |
| `MH_033` | Transfer already exists (duplicate `externalId`) | 409 |
| `MH_034` | Transfer expired | 410 |
| `MH_035` | Transfer not in a state that allows broadcast confirmation | 409 |
| `MH_036` | Broadcast signature not found on chain | 404 |
| `MH_037` | Broadcast transaction failed on chain | 422 |
| `MH_038` | Broadcast transaction did not invoke the MultiHopper program | 422 |
| `MH_039` | Keeper funding signature required but not provided | 400 |
### Webhooks
| Code | Description | Status |
| -------- | ----------------------------- | ------ |
| `MH_050` | Webhook URL unreachable | 400 |
| `MH_051` | Max webhook endpoints reached | 400 |
### Recovery
| Code | Description | Status |
| -------- | ----------------------------------------------------------- | ------ |
| `MH_080` | Recovery action not allowed in current transfer phase | 409 |
| `MH_081` | Nothing to rescue — no rescuable accounts found | 409 |
| `MH_082` | Nothing to reclaim — no reclaimable rent found | 409 |
| `MH_083` | Provided rescue signatures do not match the prepared bundle | 400 |
### Reward claims
| Code | Description | Status |
| -------- | ----------------------------- | ------ |
| `MH_060` | Below minimum claim threshold | 400 |
| `MH_061` | Rewards wallet not set | 400 |
### Internal
| Code | Description | Status |
| -------- | ------------------------------- | ------ |
| `MH_090` | Internal server error | 500 |
| `MH_091` | Service temporarily unavailable | 503 |
## Rate limits
Rate limits are enforced per API key, per endpoint. When exceeded, the API returns `MH_004` with a `Retry-After` header.
| Endpoint | Limit | Window |
| ------------------------------------- | ------ | ------ |
| `POST /transfers` | 10 req | 60s |
| `POST /transfers/:id/funding/refresh` | 10 req | 60s |
| `POST /transfers/:id/funding/confirm` | 10 req | 60s |
| `POST /transfers/estimate` | 30 req | 60s |
| `GET /transfers/:id` | 60 req | 60s |
| `GET /transfers` | 60 req | 60s |
| `POST /webhooks` | 30 req | 60s |
| `GET /webhooks` | 30 req | 60s |
| `DELETE /webhooks/:id` | 30 req | 60s |
| `GET /usage` | 30 req | 60s |
## Pricing
Pricing tiers are determined by the USD equivalent of the transfer at quote time. For native SOL, the backend resolves USD value from CoinGecko spot pricing. Fees are split between you (the integrator) and the platform.
Check costs before creating a transfer.
Create a transfer and receive funding transactions.
Receive real-time transfer lifecycle events.
View usage and fee summaries for your integration.
# Confirm Broadcast
Source: https://dev-docs.multihopper.com/api-reference/transfers/confirm-broadcast
POST /api/v1/transfers/{transferId}/confirm-broadcast
Report Solana transaction signatures after broadcasting prepared transactions
After signing and broadcasting the prepared transactions to Solana, call this endpoint to record the signatures. Once the full bundle is recorded the transfer advances to `processing` and the keeper network takes over.
This endpoint is **resumable** and **called twice** in the normal flow:
1. **Immediately after `keeperFundingTx`** — call with just `keeperFundingSignature` and an empty `routeInitSignatures: []`. This prevents the keeper from being double-funded if deployment is later resumed.
2. **After all remaining transactions** — call with `routeInitSignatures`, `orchestratorInitSignature`, and `sessionInitSignatures`.
**Only make the final call once every transaction has actually confirmed on-chain.** This
endpoint advances the transfer only if it can verify that the route, the orchestrator config,
and **all** step PDAs already exist on-chain. If you report signatures for transactions that
haven't landed yet (e.g. you broadcast lazily or out of order, or a tx silently dropped), the
transfer stays at its pre-broadcast state, the keeper never picks it up, and the route stalls
with no error. Broadcast in order — `keeperFundingTx` → `routeInitTxs` → `orchestratorInitTx`
→ `sessionInitTxs` — wait for confirmations, then call this endpoint. If a transfer appears
stuck, re-call [`/prepare`](/api-reference/transfers/prepare) to see which groups are still
missing (non-`null` fields), rebroadcast them, then confirm again.
## Path parameters
The internal transfer ID.
## Request body
Base58 signatures for each `routeInitTxs` entry. Pass an empty array `[]` on the intermediate call (after keeper funding only).
Base58 signature for `keeperFundingTx`. **Required** whenever `/prepare` emitted a `keeperFundingTx` — omitting it returns `MH_039`.
Base58 signature for `orchestratorInitTx`. Omit when that field was `null`.
Base58 signatures for each `sessionInitTxs` entry. Omit when empty.
## Response
Updated transfer object. `status` becomes `processing` once the full bundle is recorded.
```bash cURL — intermediate call (after keeperFundingTx only) theme={null}
curl -X POST /api/v1/transfers/42/confirm-broadcast \
-H "x-api-key: mh_live_abc123..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"routeInitSignatures": [],
"keeperFundingSignature": "7hGpRt4bJm2Kx9Nv5aZqYe3uLk8dQ1P..."
}'
```
```bash cURL — final call (after all remaining txs) theme={null}
curl -X POST /api/v1/transfers/42/confirm-broadcast \
-H "x-api-key: mh_live_abc123..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"routeInitSignatures": ["4mGxFn7mN1KqQe6a..."],
"orchestratorInitSignature": "2qYzLf1xJx4oRz9M...",
"sessionInitSignatures": ["9rTpWx2aKm3Nv8Qz..."],
"keeperFundingSignature": "7hGpRt4bJm2Kx9Nv..."
}'
```
```json 200 theme={null}
{
"transfer": {
"id": 42,
"status": "processing"
}
}
```
```json 400 theme={null}
{
"error": {
"code": "MH_039",
"message": "keeperFundingSignature is required"
}
}
```
```json 409 theme={null}
{
"error": {
"code": "MH_035",
"message": "Transfer is not in a state that allows broadcast confirmation"
}
}
```
# Create Transfer
Source: https://dev-docs.multihopper.com/api-reference/transfers/create
POST /api/v1/transfers
Create a new transfer quote
Creates a new transfer in `quote` status with locked-in pricing. Call `/prepare` next to get the unsigned transactions.
Idempotent via `externalId` — the same `externalId` returns the existing transfer rather than creating a duplicate.
On compliance-enabled networks the route is screened automatically after deployment and a flat
**0.002 SOL screening fee** is charged at deploy. You don't take any action to pass screening —
see [Compliance & screening](/concepts/compliance) for the lifecycle, funding impact, and what
happens if a wallet is flagged.
## Request body
The Solana mint address of the token to transfer.
Raw token amount as a string (e.g. `"1000000000"` for 1 SOL).
Human-readable token amount (e.g. `"1.0"`).
Solana public key of the wallet that will sign and pay for the deployment transactions.
Solana public key of the final recipient.
Decimal precision of the token (0–18).
Token symbol (e.g. `"SOL"`). Optional, used for display purposes.
Number of intermediate abstraction hops (3–10). Defaults to the integration setting.
Target transfer arrival time in seconds. Minimum: 60.
Your own identifier for idempotency (e.g. `"order_12345"`). Alphanumeric, `.`, `_`, `-` only.
Optional token price in USD. If not provided, resolved from Raydium spot pricing.
## Response
Internal transfer ID.
Your external ID, if provided.
Human-readable support reference (e.g. `"MH-acme-42"`).
Mint address of the token being transferred.
Transfer amount in base units.
Human-readable token amount.
Solana public key of the signing wallet.
Solana public key of the final recipient.
Internal funding strategy (e.g. `"direct_orchestration"`).
Number of abstraction hops.
Target arrival time in seconds.
Pricing tier applied.
Percentage fee in basis points.
Total flat fee in lamports.
Transfer status. Initially `quote`.
Whether created with a test-mode API key.
ISO 8601 timestamp when pricing was quoted.
ISO 8601 timestamp after which the transfer expires if not funded.
ISO 8601 creation timestamp.
```bash cURL theme={null}
curl -X POST /api/v1/transfers \
-H "x-api-key: mh_live_abc123..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"tokenMint": "So11111111111111111111111111111111111111112",
"amountRaw": "1000000000",
"amountTokens": "1.0",
"tokenDecimals": 9,
"tokenSymbol": "SOL",
"sourceOwner": "Abc123...",
"recipientWallet": "Def456...",
"hops": 7,
"arrivalSeconds": 300,
"externalId": "order_12345"
}'
```
```json 200 theme={null}
{
"id": 42,
"externalId": "order_12345",
"supportBundleId": "MH-acme-42",
"tokenMint": "So111...112",
"amountRaw": "1000000000",
"amountTokens": "1.0",
"sourceOwner": "Abc123...",
"recipientWallet": "Def456...",
"fundingStrategy": "direct_orchestration",
"hops": 7,
"arrivalSeconds": 300,
"pricingTier": "standard",
"percentFeeBps": 50,
"totalFlatFeeLamports": 42000,
"status": "quote",
"isTest": false,
"quotedAt": "2025-01-15T10:30:00.000Z",
"expiresAt": "2025-01-15T11:00:00.000Z",
"createdAt": "2025-01-15T10:30:00.000Z"
}
```
```json 409 theme={null}
{
"error": {
"code": "MH_033",
"message": "Transfer already exists for externalId 'order_12345'"
}
}
```
# Estimate Fees
Source: https://dev-docs.multihopper.com/api-reference/transfers/estimate
POST /api/v1/transfers/estimate
Estimate fees for a potential transfer without creating one
Use this endpoint to show users expected costs before they confirm a transfer.
**This estimate does not include the compliance screening fee.** On networks where the
compliance gate is enabled (mainnet today), each deploy also collects a flat refundable
**screening deposit (currently 0.002 SOL on mainnet)** from the signing wallet, charged by the
`/prepare` bundle. Add it on top of the
fees returned here when budgeting how much SOL the `sourceOwner` wallet must hold, otherwise the
deploy can fail for insufficient lamports. See [Compliance & screening](/concepts/compliance).
## Request body
The Solana mint address of the token to transfer.
Raw token amount as a string (e.g. `"1000000000"` for 1 SOL).
Decimal precision of the token. Defaults to 6.
Number of intermediate abstraction hops. Range: 3–10. Defaults to 7.
## Response
Pricing tier determined by the USD equivalent of the transfer.
Percentage fee in basis points.
Percentage fee in raw token units.
Flat fee in lamports charged per hop.
Total flat fee in lamports across all hops.
Integrator's share of the percentage fee in raw token units.
Integrator's share of the flat fee in lamports.
USD equivalent of the transfer amount at quote time (used for tier selection).
Whether this estimate was made with a test-mode API key.
```bash cURL theme={null}
curl -X POST /api/v1/transfers/estimate \
-H "x-api-key: mh_live_abc123..." \
-H "Content-Type: application/json" \
-d '{
"tokenMint": "So11111111111111111111111111111111111111112",
"amountRaw": "1000000000",
"tokenDecimals": 9,
"hops": 7
}'
```
```json 200 theme={null}
{
"tier": "standard",
"percentFeeBps": 50,
"percentFeeRaw": "500000",
"flatFeeLamportsPerHop": 6000,
"totalFlatFeeLamports": 42000,
"integratorPercentShare": "250000",
"integratorFlatShare": 21000,
"usdEquivalent": 1502.50,
"isTestMode": false
}
```
# Get Transfer
Source: https://dev-docs.multihopper.com/api-reference/transfers/get
GET /api/v1/transfers/{transferId}
Retrieve a transfer by its internal ID or external ID
Retrieve a transfer by its internal ID. Returns the extended transfer object including `phase`, `progress`, `recovery`, and `signatures`.
A companion endpoint at `GET /api/v1/transfers/by-external/{externalId}` returns the same response shape using your external ID.
## Path parameters
The internal transfer ID.
## Response
Transfer ID.
Transfer status: `quote` | `awaiting_signature` | `processing` | `completed` | `failed` | `expired` | `refunded`
Finer-grained execution phase derived from on-chain state: `quoted` | `deploying` | `executing` | `settled` | `failed` | `recoverable` | `rescued` | `reclaimed` | `expired`
Number of hops that have executed.
Total number of hops in the route.
Last error recorded for this transfer, if any.
Structured error code (e.g. `MH_037`).
Human-readable error message.
ISO 8601 timestamp of when the error occurred.
Recovery options. Only populated when `phase` is `recoverable`, `settled`, `rescued`, or `reclaimed`.
Whether locked funds can be rescued via `/rescue/prepare`.
Total lamports recoverable.
Accounts with locked funds: `[{ kind, lamports }]`
Whether closed step account rent can be reclaimed.
Total reclaimable rent in lamports.
On-chain transaction signatures recorded for this transfer.
Route initialization transaction hashes.
Session initialization transaction hashes.
Hop execution transaction hashes.
```bash cURL — by internal ID theme={null}
curl /api/v1/transfers/42 \
-H "x-api-key: mh_live_abc123..."
```
```bash cURL — by external ID theme={null}
curl /api/v1/transfers/by-external/order_12345 \
-H "x-api-key: mh_live_abc123..."
```
```json 200 theme={null}
{
"id": 42,
"externalId": "order_12345",
"status": "processing",
"phase": "executing",
"progress": { "hopsCompleted": 3, "hopsTotal": 7 },
"lastError": null,
"recovery": null,
"signatures": {
"routeInit": ["4mGxFn7m..."],
"sessionInit": [],
"hops": ["9rTpWx2a...", "7kLmQv4b..."]
}
}
```
```json 404 theme={null}
{
"error": {
"code": "MH_030",
"message": "Transfer not found"
}
}
```
# List Transfers
Source: https://dev-docs.multihopper.com/api-reference/transfers/list
GET /api/v1/transfers
List transfers with pagination and optional filters
## Query parameters
Filter by transfer status (e.g. `completed`, `processing`, `failed`).
ISO 8601 start date filter (e.g. `2025-01-01T00:00:00.000Z`).
ISO 8601 end date filter.
Filter by your external ID.
Filter by support bundle ID.
Number of results to return.
Pagination offset.
## Response
Array of transfer objects.
Total number of matching transfers.
Page size used.
Current offset.
```bash cURL theme={null}
curl "/api/v1/transfers?status=completed&fromDate=2025-01-01T00:00:00.000Z&limit=50" \
-H "x-api-key: mh_live_abc123..."
```
```json 200 theme={null}
{
"items": [
{
"id": 42,
"externalId": "order_12345",
"status": "completed",
"phase": "settled",
"progress": { "hopsCompleted": 7, "hopsTotal": 7 }
}
],
"pagination": {
"total": 142,
"limit": 50,
"offset": 0
}
}
```
# Prepare Transactions
Source: https://dev-docs.multihopper.com/api-reference/transfers/prepare
POST /api/v1/transfers/{transferId}/prepare
Build the unsigned transaction bundle for a transfer
Probes on-chain state and returns the full bundle of unsigned Solana transactions that `sourceOwner` must sign and broadcast to deploy the transfer.
This endpoint is **resumable** — re-call it after a partial broadcast to get a fresh blockhash and drop any groups already confirmed on-chain (`null` fields are already on chain, skip them).
On compliance-enabled networks (mainnet) the bundle includes a flat **0.002 SOL screening fee**
transfer that runs at deploy. Ensure the `sourceOwner` wallet holds enough SOL to cover
**route amount + protocol fees + account rent + keeper funding + the screening fee** before
signing — the fee is **not** part of `/estimate`. It is a refundable anti-abuse deposit: a clean
(verified) route gets it back, a flagged route forfeits it. Either way the wallet must hold it at
deploy time. See [Compliance & screening](/concepts/compliance).
## Path parameters
The internal transfer ID.
## Response
Current transfer state.
Bundle of unsigned transactions to sign and broadcast.
**Broadcast FIRST.** Base64 VersionedTransaction that transfers SOL to the assigned keeper. `null` if keeper is already funded.
Array of `{ base64 }` VersionedTransactions to initialize the on-chain route. Empty if route is already deployed.
Base64 legacy Transaction to initialize the orchestrator config PDA. `null` if already initialized.
Array of base64 VersionedTransactions to initialize step state PDAs. Entries for already-initialized steps are omitted.
Blockhash used to build these transactions. Sign and broadcast before this expires (\~60s).
Last block height at which these transactions are valid.
Snapshot of on-chain state used to build the bundle.
Indices of step PDAs already on-chain.
`true` if all groups are null/empty — no signing needed.
```bash cURL theme={null}
curl -X POST /api/v1/transfers/42/prepare \
-H "x-api-key: mh_live_abc123..." \
-H "Idempotency-Key: $(uuidgen)"
```
```json 200 theme={null}
{
"transfer": { "id": 42, "status": "awaiting_signature" },
"preparedTxs": {
"keeperFundingTx": "AQAAAAAAAA...",
"routeInitTxs": [{ "base64": "AQAAAAAAAA..." }],
"orchestratorInitTx": null,
"sessionInitTxs": ["AQAAAAAAAA...", "AQAAAAAAAA..."],
"recentBlockhash": "BpFi8bTNRuLbUnrq7q1N1M...",
"lastValidBlockHeight": 289043200,
"resume": {
"routeAlreadyDeployed": false,
"existingHopCount": 0,
"totalHops": 7,
"orchestratorAlreadyInitialized": true,
"completedStepIndices": [],
"totalSteps": 7,
"keeperAlreadyFunded": false,
"nothingToDo": false
}
}
}
```
# Usage
Source: https://dev-docs.multihopper.com/api-reference/usage
GET /api/v1/usage
Get usage and fee summary for your integration
## Query parameters
ISO 8601 start date (e.g. `2025-01-01T00:00:00.000Z`).
ISO 8601 end date.
## Response
Total number of transfers in the period.
Number of successfully completed transfers.
Number of failed transfers.
Total transfer volume in base token units (returned as a string to preserve bigint precision).
Total flat fee earned by your integration in lamports.
Number of integrator reward payouts settled on-chain in the period.
Whether this summary reflects test-mode transfers.
```bash cURL theme={null}
curl "/api/v1/usage?fromDate=2025-01-01T00:00:00.000Z&toDate=2025-01-31T23:59:59.000Z" \
-H "x-api-key: mh_live_abc123..."
```
```json 200 theme={null}
{
"totalTransfers": 142,
"completedTransfers": 130,
"failedTransfers": 5,
"totalVolumeRaw": "142000000000",
"totalFlatFeeEarned": 5460000,
"totalPayouts": 0,
"isTestMode": false
}
```
# Register Webhook
Source: https://dev-docs.multihopper.com/api-reference/webhooks/create
POST /api/v1/webhooks
Register an endpoint to receive transfer lifecycle events
Registers a webhook endpoint. Returns a signing secret — store it securely, it is only shown once.
## Request body
The HTTPS URL to receive webhook events.
List of event types to subscribe to. Defaults to all events if omitted.
Available events:
* `transfer.quote_created`
* `transfer.deposit_confirmed`
* `transfer.processing`
* `transfer.hop_complete`
* `transfer.completed`
* `transfer.failed`
* `transfer.expired`
* `transfer.refunded`
* `payout.completed`
## Response
Webhook endpoint ID.
The registered URL.
HMAC-SHA256 signing secret prefixed with `whsec_`. **Shown once — store this securely.**
Subscribed event types.
```bash cURL theme={null}
curl -X POST /api/v1/webhooks \
-H "x-api-key: mh_live_abc123..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/multihopper",
"events": ["transfer.completed", "transfer.failed"]
}'
```
```json 200 theme={null}
{
"id": 1,
"url": "https://your-app.com/webhooks/multihopper",
"secret": "whsec_abc123...",
"events": ["transfer.completed", "transfer.failed"]
}
```
# Delete Webhook
Source: https://dev-docs.multihopper.com/api-reference/webhooks/delete
DELETE /api/v1/webhooks/{endpointId}
Delete a registered webhook endpoint
## Path parameters
The webhook endpoint ID to delete.
## Response
`true` if the endpoint was successfully deleted.
```bash cURL theme={null}
curl -X DELETE /api/v1/webhooks/1 \
-H "x-api-key: mh_live_abc123..."
```
```json 200 theme={null}
{
"deleted": true
}
```
# Webhook Events
Source: https://dev-docs.multihopper.com/api-reference/webhooks/events
Verify signatures and handle transfer lifecycle events
## Signature verification
Webhook payloads are signed with HMAC-SHA256 using your endpoint secret. Always verify the signature before processing a payload.
```javascript theme={null}
const crypto = require('crypto');
function verifyWebhook(payload, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
// In your handler:
const signature = req.headers['x-multihopper-signature'];
const isValid = verifyWebhook(req.body, signature, whsec_secret);
```
## Event types
| Event | Description |
| ---------------------------- | -------------------------------------------------------------------------- |
| `transfer.quote_created` | A transfer has been created and quoted |
| `transfer.deposit_confirmed` | Funding transactions have been confirmed |
| `transfer.processing` | Transfer is actively being processed |
| `transfer.phase_changed` | Transfer moved to a new execution phase |
| `transfer.hop_complete` | An intermediate hop has completed |
| `transfer.completed` | Transfer has arrived at the recipient |
| `transfer.failed` | Transfer failed |
| `transfer.expired` | Transfer expired before funding was completed |
| `transfer.refunded` | Transfer was refunded |
| `transfer.recoverable` | Transfer failed with funds locked in on-chain accounts that can be rescued |
| `transfer.rescued` | Locked funds were successfully recovered to the sender |
| `transfer.rent_reclaimed` | Closed on-chain step accounts; rent returned to sender |
| `payout.completed` | An integrator reward claim has settled |
## Retry schedule
If your endpoint fails to return a `2xx` response, the event is retried on the following schedule:
| Attempt | Delay | Cumulative |
| ------- | ---------- | ---------- |
| 1 | Immediate | 0s |
| 2 | 30 seconds | 30s |
| 3 | 5 minutes | 5m 30s |
| 4 | 30 minutes | 35m 30s |
| 5 | 2 hours | 2h 35m |
After 5 failed attempts, the delivery is marked as permanently failed.
# List Webhooks
Source: https://dev-docs.multihopper.com/api-reference/webhooks/list
GET /api/v1/webhooks
List all registered webhook endpoints
## Response
Array of webhook endpoint objects.
Webhook endpoint ID.
The registered URL.
Subscribed event types.
Whether the endpoint is active.
ISO 8601 creation timestamp.
```bash cURL theme={null}
curl /api/v1/webhooks \
-H "x-api-key: mh_live_abc123..."
```
```json 200 theme={null}
{
"items": [
{
"id": 1,
"url": "https://your-app.com/webhooks/multihopper",
"events": ["transfer.completed", "transfer.failed"],
"isActive": true,
"createdAt": "2025-01-10T00:00:00.000Z"
}
]
}
```
# Compliance & screening
Source: https://dev-docs.multihopper.com/concepts/compliance
How TRM compliance screening works, what it costs, and what builders need to do
On networks where compliance is enabled (mainnet today), every route is screened against
sanctions and risk data before its funds move. Screening is **automatic and enforced on-chain** —
as a builder you do not run, attach, or sign anything to "pass" it. This page covers the two things
you *do* need to account for: the **screening fee** and the **verification window** (plus what
happens on a denial).
**You take no action to pass screening.** The MultiHopper keeper screens the route and submits
the on-chain verification for you. The only builder-facing impacts are funding the screening fee
and tolerating a short verification delay before hops execute.
## What gets screened
When the gate is on, the route's **origin** (the `sourceOwner` / funding wallet) and **every
recipient** in the route — including the final `recipientWallet` and all intermediate hops — are
screened together. The set is cryptographically bound to the route, so funds cannot be screened
against one set of addresses and then moved to another.
## The screening fee
| Property | Value |
| ------------------- | -------------------------------------------------------------------------------------------------- |
| Amount | A flat per-deploy fee — **currently 0.002 SOL on mainnet** (a configured value, subject to change) |
| Paid by | `sourceOwner` (the signing wallet) |
| Charged when | At deploy, inside the `/prepare` transaction bundle |
| Charged if flagged? | **Yes** — the fee is collected regardless of the screening outcome |
| Refunded? | Yes for a clean (verified) route — the deposit is returned to `sourceOwner` on verification |
| In `/estimate`? | **No** — you must add it on top of the estimate yourself |
The fee is a refundable anti-abuse deposit, not a fixed protocol constant: a clean route gets it
back when screening verifies, while a flagged route forfeits it. The amount is configured per
network and may change, so treat 0.002 SOL as the current mainnet value rather than a guarantee.
The fee is **not** returned by [`/estimate`](/api-reference/transfers/estimate), but it **is**
charged by [`/prepare`](/api-reference/transfers/prepare). Ensure the `sourceOwner` wallet holds
**transfer amount + protocol fees + account rent + keeper funding + 0.002 SOL** before signing,
or the deploy can fail for insufficient lamports. This is the most common reason a route fails to
deploy on mainnet.
## Lifecycle
```
create → prepare → sign + broadcast → confirm-broadcast
│
▼
(route deployed, fee charged)
│
keeper screens + verifies on-chain
│
┌───────────────────┴───────────────────┐
▼ ▼
verified flagged
hops execute principal refunded,
status → completed fee retained,
status → refunded
```
After your final [`confirm-broadcast`](/api-reference/transfers/confirm-broadcast), the transfer
sits in `processing` while the keeper screens the route and submits the on-chain verification. This
typically takes a few seconds. Once verified, hops begin executing and the transfer proceeds to
`completed` as normal. **Keep polling [`GET /transfers/{id}`](/api-reference/transfers/get) — no
action is required during this window.**
## When a wallet is flagged
If any screened address is flagged, the route is **not** executed:
* The **principal is refunded** to the `sourceOwner`.
* The **screening fee is retained**.
* The transfer ends in `refunded` status.
* For privacy and legal reasons, the specific reason for a flag is **not disclosed**.
## Enforced on-chain — not by the API
Compliance is a property of the deployed smart contract, not the API server or keeper. The required
compliance account is mandatory on every fund-moving instruction, and the program refuses to move
funds for a route that has not been verified while the gate is on. This means a route **cannot be
constructed to bypass screening**, whether it is built through this API or by hand. See the
[Security Model](/concepts/security) for the underlying guarantees.
## Networks
* **Mainnet** — the gate is **on**. Screening and the fee apply as described above.
* **Test mode** (`mh_test_*` keys) — use test-mode keys to integrate against the lifecycle without
moving real value. Verify that your wallet budgets for the fee and that your code keeps polling
through the verification window.
# Environments
Source: https://dev-docs.multihopper.com/concepts/environments
Devnet vs production — base URLs, dashboards, and API keys for each
MultiHopper runs two independent environments. They are fully separate deployments:
a key, integration, or route from one does **not** exist in the other. Pick the
environment first, then use its base URL **and** its dashboard throughout.
| | Devnet (testing) | Production (mainnet) |
| ------------------ | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
| **Base URL** | `https://devnet.multihopper.com/api/v1` | `https://multihopper.com/api/v1` |
| **Dashboard** | [devnet.multihopper.com/developer/dashboard](https://devnet.multihopper.com/developer/dashboard) | [multihopper.com/developer/dashboard](https://multihopper.com/developer/dashboard) |
| **API keys** | `mh_test_…` only | `mh_live_…` and `mh_test_…` |
| **Solana cluster** | devnet | mainnet-beta |
| **Funds** | devnet SOL / test tokens | real assets |
Each environment **only issues its own keys**, and a key is bound to the cluster of the
deployment that minted it — it is not switched by the key prefix. To build against
Solana devnet you must create your key from the **devnet** dashboard above; a key minted
on the production dashboard will not work against devnet.
## Which one should I use?
Start on **devnet** while you build and test — it uses devnet SOL, so mistakes cost
nothing. Move to **production** once your integration works end to end.
The [Quickstart](/quickstart) and the interactive [API Reference](/api-reference/introduction)
both default to production; use the **server dropdown** at the top of any API Reference page
to switch the examples to devnet.
## Compliance differs by environment
TRM compliance screening is enabled per environment. It is currently **on for mainnet** and
may be toggled on devnet for testing. See [Compliance & screening](/concepts/compliance)
for what that means for fees and denied routes.
# Keepers
Source: https://dev-docs.multihopper.com/concepts/keepers
Permissionless executors that trigger hop and orchestrator step execution on-chain
## What is a Keeper?
A **keeper** is any off-chain process that monitors pending MultiHopper hops and orchestrator steps, then submits the corresponding on-chain transaction when the scheduled time arrives.
Keepers are the "automation layer" of the protocol — they bridge the gap between on-chain timelock enforcement and real-world execution timing.
## What Keepers Execute
Keepers interact with two programs:
### MultiHopper Core — Hop Execution
```
1. Keeper monitors route state (via API or on-chain)
2. A hop's execute_at time is reached
3. Keeper submits trigger_hop transaction to the Core program
4. Program validates: timing, sequencing, prepaid_hops limit
5. On success: wrapper tokens transfer to next recipient, hop marked complete
6. On failure: transaction reverts, no state change
```
### Orchestrator — Step Execution
```
1. Keeper monitors OrchestratorStep accounts
2. A step's execute_at time is reached
3. Keeper submits the appropriate step instruction:
- execute_step_transfer → move lamports between accounts
- execute_step_wrap_sol → wrap SOL into the route vault
- execute_step_token_transfer / execute_step_wrap_token → SPL token operations
4. On success: step marked complete, keeper earns keeper_share_bps of deposited funds
5. remaining_steps on OrchestratorConfig decrements
```
The keeper has no authority over funds. Both programs enforce all constraints — keepers only provide the trigger.
## Permissionless Execution
Execution is **permissionless**: any wallet can submit a valid step or hop execution. The programs validate correctness independently of who submits.
If a designated keeper goes offline, any other executor can pick up pending steps and hops. Routes and orchestrators do not expire due to keeper failures.
## Keeper Incentives
The `OrchestratorConfig` account includes a `keeper_share_bps` field (basis points). When a keeper successfully executes an orchestrator step, they earn a proportional share of the lamports deposited for that step.
This creates an open, competitive market for route deployment execution — anyone can run a keeper and earn fees for timely execution.
## Current Keeper Infrastructure
MultiHopper operates a **scheduler service** (a Node.js cron process) that automatically monitors all active routes and orchestrators and submits executions at the scheduled time.
This scheduler is:
* Non-custodial — it can trigger steps and hops but cannot redirect funds
* Redundant — execution remains possible without it since any keeper can act
* Observable — execution history is indexed and queryable via the API
## Failure Recovery
If an orchestrator step fails on-chain:
* **`refund_failed_step`** — returns deposited funds to the route creator
* **`rescue_step`** — admin escape hatch for permanently stuck steps (requires privileged authority)
* **`close_step`** — closes a completed or refunded step account to reclaim rent
After all steps are resolved, `close_orchestrator` reclaims the `OrchestratorConfig` account rent.
Learn how the Orchestrator program coordinates multi-step route deployment.
# Orchestrator
Source: https://dev-docs.multihopper.com/concepts/orchestrator
Permissionless step-based execution engine for route deployment
## What is the Orchestrator?
The **Orchestrator** is a separate Solana program (`4TPCmR2uN3hDM1FbTsf7ZtFXwXGSz4u1Kxv5YHj6eruX`) that coordinates the multi-transaction sequence required to deploy a MultiHopper route.
Deploying a route involves multiple on-chain operations: funding intermediate accounts, wrapping tokens into vaults, and initializing route state. The Orchestrator breaks this sequence into discrete **steps** that any keeper can execute permissionlessly — removing the requirement for the route creator to sign and submit every setup transaction themselves.
## OrchestratorConfig
When a route deployment begins, an `OrchestratorConfig` account is created on-chain:
| Field | Type | Description |
| ------------------ | ---------- | ---------------------------------------------------------------------------------- |
| `orchestrator_id` | `[u8; 32]` | Unique 32-byte orchestrator identifier |
| `creator` | `Pubkey` | Wallet that initiated the orchestrator |
| `keeper_share_bps` | `u16` | Keeper incentive in basis points — share of step deposits earned per executed step |
| `steps_count` | `u8` | Total number of steps in this orchestrator |
| `remaining_steps` | `u16` | Steps not yet completed — decrements on each successful execution |
| `bump` | `u8` | PDA bump seed |
The account address is derived as: `PDA: [b"orchestrator", orchestrator_id]`
## Step Types
Each `OrchestratorStep` account has an `execute_at` timestamp and one of three types:
| Step Type | Instruction | Description |
| ------------ | ------------------------- | -------------------------------------------------------------------------- |
| `transfer` | `execute_step_transfer` | Transfers SOL lamports between accounts (e.g. fund an intermediate wallet) |
| `wrap_sol` | `execute_step_wrap_sol` | Wraps SOL into the route vault via the MultiHopper Core program |
| `wrap_token` | `execute_step_wrap_token` | Wraps an SPL token into the route vault |
Step accounts are addressed as: `PDA: [b"orchestrator_step", orchestrator_id, step_index]`
There is also a token-specific step initialization path (`initialize_step_token`) for SPL token steps, which records the destination wallet, mint, and amount.
## Step Lifecycle
```
INITIALIZE_ORCHESTRATOR Create OrchestratorConfig with step sequence
|
▼
INITIALIZE_STEP(S) Create each OrchestratorStep account with type + execute_at
|
▼
EXECUTE_STEP(S) Keepers execute each step permissionlessly at the scheduled time
|
▼
CLOSE_STEP(S) Completed/refunded steps closed to reclaim rent
|
▼
CLOSE_ORCHESTRATOR Final cleanup — closes OrchestratorConfig, reclaims remaining rent
```
## Failure Recovery
If a step cannot be executed on-chain:
* **`refund_failed_step`** — returns the deposited lamports to the creator. The step must be in a failed state for this to succeed.
* **`rescue_step`** — privileged instruction that bypasses normal constraints to recover funds from a permanently stuck step.
After all steps are either completed or refunded, `close_orchestrator` closes the config account.
## Keeper Incentives
The `keeper_share_bps` field determines how much of each step's deposited funds the executing keeper earns. This is expressed in basis points (1 bps = 0.01%).
For example, with `keeper_share_bps = 50` (0.5%), a step that has 1,000,000 lamports deposited would yield 5,000 lamports to the keeper on successful execution.
This creates open competition for step execution — any operator can run a keeper and earn fees for timely, correct execution.
Learn how keepers monitor and execute pending steps and hops.
# Routes
Source: https://dev-docs.multihopper.com/concepts/routes
The core primitive — a programmable, sequenced token transfer pipeline
## What is a Route?
A **route** is the fundamental unit of MultiHopper. It defines a complete token transfer pipeline: a sequence of recipients and execution times that play out automatically on-chain after the route is funded.
Think of a route as a smart payment program you deploy once and let run.
## RouteConfig Fields
The `RouteConfig` on-chain account stores the full route definition:
| Field | Type | Description |
| ------------------ | ------------------ | ---------------------------------------------------------------------------------------------- |
| `route_id` | `[u8; 32]` | Unique 32-byte route identifier |
| `creator` | `Pubkey` | Wallet that created the route |
| `original_mint` | `Pubkey` | Mint address of the deposited token (or native SOL mint) |
| `route_token_mint` | `Pubkey` | Token2022 wrapper mint address |
| `mint_authority` | `Pubkey` | PDA with authority to mint/burn wrapper tokens |
| `source_owner` | `Option` | Optional source wallet — used by the abstraction path to link the route to an origin wallet |
| `hops` | `Vec` | Ordered list of transfer steps (up to 64 hops) |
| `hop_amount` | `u64` | Single token amount shared across all hops (in base units) |
| `is_finalized` | `bool` | Whether the route has been fully initialized and locked |
| `created_at` | `i64` | Unix timestamp of route creation |
| `prepaid_hops` | `u8` | Number of hops that protocol fees have been paid for — execution is rejected beyond this limit |
| `provider_id` | `Option<[u8; 16]>` | Optional integrator identifier from a registered Provider account |
| `reference_id` | `Option<[u8; 16]>` | Optional external reference ID set by the integrator |
### Hops
Each hop within a route specifies:
| Field | Description |
| ------------ | --------------------------------------------------- |
| `recipient` | Solana wallet address receiving tokens at this step |
| `execute_at` | Unix timestamp — the earliest this hop can execute |
## Route Variants
Two route creation instructions exist depending on the asset type:
| Instruction | Use Case |
| ------------------------------- | -------------------------------------------------------------- |
| `initialize_route` | SPL token routes |
| `initialize_route_sol` | Native SOL routes (uses a dedicated `sol_vault` PDA) |
| `initialize_route_provider` | SPL route with a registered provider and optional reference ID |
| `initialize_route_provider_sol` | SOL route with a registered provider and optional reference ID |
## Route Constraints
* Routes are **immutable** after finalization. The hop sequence, recipients, and timing cannot be changed.
* Hops execute in **strict order**. Hop `N` cannot run until hop `N-1` is complete.
* Timing is enforced by the **Solana clock** on-chain. No hop can run before its `execute_at` time.
* Execution is gated by **`prepaid_hops`** — only hops within the prepaid count can execute.
## Route Limits
| Constraint | Value |
| ---------------------- | ------------------- |
| Maximum hops per route | 64 |
| Supported tokens | SOL, SPL, Token2022 |
## Closing a Route
Once a route is fully settled (all hops complete, vault emptied), the `close_route` instruction reclaims the `RouteConfig` account rent and returns lamports to the creator.
## Identifying Routes
Each route is identified by its `route_id` — a 32-byte UUID stored as a 64-character hex string (e.g. `a1b2c3d4e5f6...`). The on-chain account address is derived as:
```
PDA: [b"route", route_id_le_bytes]
```
where `route_id_le_bytes` is the little-endian byte representation of the route ID. Use this ID to query route status via the API or track on-chain state.
See how routes move from creation through settlement.
# Security Model
Source: https://dev-docs.multihopper.com/concepts/security
Trust assumptions, threat model, and on-chain enforcement guarantees
## Design Philosophy
MultiHopper is designed to minimize trust. Every constraint that matters for fund safety is enforced by the smart contract — not by an API server, a keeper, or a UI.
The guiding principle: **off-chain components provide convenience; on-chain programs provide guarantees.**
## What the Protocol Guarantees
| Guarantee | Mechanism |
| ---------------------------------------------------- | ---------------------------------------------------------------- |
| Tokens cannot be redirected to unintended recipients | Route recipients are immutable after creation; enforced on-chain |
| Hops cannot execute before their scheduled time | On-chain clock validation in `execute_hop` instruction |
| Hops cannot skip or execute out of order | Sequential state machine enforced in program logic |
| Original tokens cannot be withdrawn mid-route | Vault can only be emptied via `unwrap` after all hops complete |
| 1:1 wrapper/original backing at all times | Mint/burn logic enforced by the program |
| Only authorized executors can trigger hops | Access control checks in instruction handlers |
## Trust Assumptions
### What you must trust
* **The MultiHopper Core program** — all protocol logic lives here. The program is the root of trust.
* **The Solana runtime** — transaction execution and clock accuracy.
* **The Token2022 runtime** — permanent delegate and transfer hook invocation.
### What you do not need to trust
* **The API server** — it builds transactions but cannot execute them without your signature.
* **The keeper/scheduler** — it can trigger hops but cannot redirect funds. If it misbehaves, it simply fails to execute on time; it cannot steal or misroute.
* **The indexer** — read-only; it has no authority over any on-chain state.
* **The MultiHopper team** — funds are secured by program logic, not by operator keys.
## Threat Model
MultiHopper's multi-hop routing increases the abstraction between sender and final recipient, which can make naive on-chain analysis more complex. However, all transfers are publicly visible on Solana and auditable.
If the MultiHopper scheduler is censored or goes offline, anyone can execute pending hops directly. The protocol cannot be censored at the smart contract level.
A keeper failure delays execution but does not result in fund loss. Hops stay pending indefinitely and can be triggered by any executor.
As with any on-chain protocol, bugs in the program logic are a risk. The program should be audited before significant value is routed through it. Audit reports will be linked here as they are completed.
The permanent delegate is scoped to wrapper mints created by the MultiHopper program. It cannot be used to access original tokens in the vault or any other user tokens.
## Audit Status
MultiHopper is in active development. Smart contract audits are planned prior to mainnet launch with significant TVL. Do not route funds exceeding your risk tolerance until audits are published.
Audit reports will be published here when available.
## Responsible Disclosure
If you discover a security vulnerability in the MultiHopper protocol or infrastructure, please report it to **[security@multihopper.com](mailto:security@multihopper.com)**.
Do not disclose vulnerabilities publicly before coordinating with the team.
# Wrapper Tokens
Source: https://dev-docs.multihopper.com/concepts/wrapper-tokens
How MultiHopper routes assets without ever moving original tokens mid-flight
## The Problem with Moving Originals
Routing tokens through multiple recipients directly requires either:
* Trusting each intermediate recipient to forward funds (custodial risk), or
* Requiring every intermediate party to sign each step (coordination overhead)
MultiHopper solves this with a **vault-backed wrapper token** architecture.
## How Wrapper Tokens Work
When a route is funded:
1. **Original tokens are deposited** into a program-controlled vault. They stay there until final redemption.
2. **Wrapper tokens are minted** 1:1 against the vault balance using a Token2022 mint.
3. **The protocol uses the permanent delegate** to move wrapper tokens through the hop sequence without requiring intermediate recipient signatures.
4. **The final recipient redeems** wrapper tokens by burning them, releasing the original tokens from the vault.
Original tokens never move mid-route. Only wrapper tokens flow.
## Token2022 Permanent Delegate
The permanent delegate is a Token2022 extension that grants a designated program the ability to transfer tokens from *any* holder of that mint — without the holder needing to sign.
In MultiHopper, the **MultiHopper Core program** is the permanent delegate for all wrapper mints it creates. This is what enables trustless, programmatic hop execution.
The permanent delegate is scoped to wrapper tokens only. It has no authority over original tokens in the vault or any other token in a user's wallet.
## Metadata Pointer
Each wrapper mint uses the Token2022 **metadata pointer** extension to store route metadata directly on-chain:
* Hop sequence
* Original token mint
* Vault address
* Route creation timestamp
This makes wrapper tokens self-describing — anyone can inspect a wrapper mint and understand the full route it represents.
## Transfer Hook
A Token2022 **transfer hook** is attached to every wrapper mint. The hook is implemented by the **Transfer Hook Guard** program and runs on every wrapper token transfer, enforcing:
* Only the protocol program can move wrapper tokens (not arbitrary transfers)
* Transfer is within an active, valid route context
## 1:1 Backing Guarantee
At every point in the route lifecycle:
```
wrapper tokens in circulation == original tokens in vault
```
This invariant is maintained by the program logic:
* Minting only occurs when an equal amount is deposited
* Burning only occurs when releasing an equal amount from the vault
* No partial mints or burns are possible
## Why Not Just Use the Original Token?
Using wrapper tokens instead of the original token:
* Keeps original assets in a single, auditable vault
* Avoids requiring recipient signatures at each hop
* Allows the protocol to enforce routing rules at the token level (via transfer hook)
* Normalizes SOL, SPL, and Token2022 tokens into a single interface
See the full wrapper token flow from deposit through redemption.
# Agentic Integration
Source: https://dev-docs.multihopper.com/guides/agentic-integration
Run autonomous multi-hop transfers from an agent or automated backend
## Overview
When integrating Multihopper into an automated or agentic workflow, the transfer lifecycle splits into two distinct halves:
* **API-managed** — create, prepare, confirm-broadcast, and monitor via REST
* **Client-managed** — sign and broadcast transactions to Solana (requires wallet access)
The sign and broadcast steps are intentionally handled client-side: your private key never leaves your environment.
## Transfer lifecycle for agents
```
1. POST /transfers → create transfer, receive transferId
2. POST /transfers/:id/prepare → receive preparedTxs (unsigned base64 txs)
3. [CLIENT] Sign and broadcast keeperFundingTx FIRST
4. POST /transfers/:id/confirm-broadcast → record keeperFundingSignature immediately
5. [CLIENT] Sign and broadcast routeInitTxs → orchestratorInitTx → sessionInitTxs in order
6. POST /transfers/:id/confirm-broadcast → record remaining signatures, activate transfer
7. GET /transfers/:id → poll until completed | failed
```
Steps 3 and 5 happen entirely outside the API. No private key material is sent to the server.
`confirm-broadcast` is called **twice**: once immediately after `keeperFundingTx` (step 4), and once after the rest (step 6). This prevents double-funding if deployment is interrupted and `/prepare` is called again.
***
## Transaction signing
`preparedTxs` contains four groups of transactions that must be signed in order. Each group uses either a VersionedTransaction (v0) or a legacy Transaction — the signing approach differs.
| Field | Tx type | Broadcast order | Notes |
| -------------------- | ------------------------- | --------------- | --------------------------------------------------------------------------------- |
| `keeperFundingTx` | VersionedTransaction (v0) | **1st** | Broadcast and confirm first; record signature immediately via `confirm-broadcast` |
| `routeInitTxs[]` | VersionedTransaction (v0) | 2nd | Server pre-signs ephemeral keys; preserve existing sigs |
| `orchestratorInitTx` | Legacy Transaction | 3rd | Plain partial sign |
| `sessionInitTxs[]` | VersionedTransaction (v0) | 4th | Server pre-signs; preserve existing sigs |
The server partially pre-signs VersionedTransactions with ephemeral keypairs. Your signing step must **add your signature** to the existing slot without overwriting the server's partial signatures.
Any field that is `null` in `preparedTxs` is already confirmed on-chain from a previous broadcast attempt — skip it.
### Python
**Dependencies:** `pip install solders base58`
```python theme={null}
import base64
from solders.keypair import Keypair
from solders.transaction import VersionedTransaction, Transaction
import base58
def load_keypair(private_key_base58: str) -> Keypair:
secret = base58.b58decode(private_key_base58)
return Keypair.from_bytes(secret)
def sign_versioned(base64_tx: str, keypair: Keypair) -> str:
"""Sign a VersionedTransaction (v0), preserving existing partial signatures."""
tx = VersionedTransaction.from_bytes(base64.b64decode(base64_tx))
# v0 signing payload: version prefix byte (0x80) + serialised message bytes
msg_bytes = bytes([0x80]) + bytes(tx.message)
our_sig = keypair.sign_message(msg_bytes)
# Find our pubkey's slot in the signature array and replace only that entry
account_keys = list(tx.message.account_keys)
idx = next(i for i, k in enumerate(account_keys) if k == keypair.pubkey())
sigs = list(tx.signatures)
sigs[idx] = our_sig
return base64.b64encode(bytes(VersionedTransaction.populate(tx.message, sigs))).decode()
def sign_legacy(base64_tx: str, keypair: Keypair) -> str:
"""Sign a legacy Transaction."""
tx = Transaction.from_bytes(base64.b64decode(base64_tx))
tx.partial_sign([keypair], tx.message.recent_blockhash)
return base64.b64encode(bytes(tx)).decode()
def sign_prepared_txs(prepared_txs: dict, keypair: Keypair) -> dict:
signed = {}
if prepared_txs.get("routeInitTxs"):
signed["routeInitTxs"] = [
{"base64": sign_versioned(e["base64"], keypair)}
for e in prepared_txs["routeInitTxs"]
]
if prepared_txs.get("orchestratorInitTx"):
signed["orchestratorInitTx"] = sign_legacy(
prepared_txs["orchestratorInitTx"], keypair
)
if prepared_txs.get("sessionInitTxs"):
signed["sessionInitTxs"] = [
sign_versioned(b, keypair) for b in prepared_txs["sessionInitTxs"]
]
if prepared_txs.get("keeperFundingTx"):
signed["keeperFundingTx"] = sign_versioned(
prepared_txs["keeperFundingTx"], keypair
)
return signed
```
### TypeScript
**Dependencies:** `npm install @solana/web3.js bs58`
```typescript theme={null}
import { Keypair, VersionedTransaction, Transaction } from "@solana/web3.js";
function signVersioned(base64Tx: string, keypair: Keypair): string {
const tx = VersionedTransaction.deserialize(Buffer.from(base64Tx, "base64"));
tx.sign([keypair]);
return Buffer.from(tx.serialize()).toString("base64");
}
function signLegacy(base64Tx: string, keypair: Keypair): string {
const tx = Transaction.from(Buffer.from(base64Tx, "base64"));
tx.partialSign(keypair);
return Buffer.from(tx.serialize({ requireAllSignatures: false })).toString("base64");
}
interface PreparedTxs {
routeInitTxs?: { base64: string }[] | null;
orchestratorInitTx?: string | null;
sessionInitTxs?: string[] | null;
keeperFundingTx?: string | null;
}
function signPreparedTxs(preparedTxs: PreparedTxs, keypair: Keypair): PreparedTxs {
const signed: PreparedTxs = {};
if (preparedTxs.routeInitTxs?.length) {
signed.routeInitTxs = preparedTxs.routeInitTxs.map(e => ({
base64: signVersioned(e.base64, keypair),
}));
}
if (preparedTxs.orchestratorInitTx) {
signed.orchestratorInitTx = signLegacy(preparedTxs.orchestratorInitTx, keypair);
}
if (preparedTxs.sessionInitTxs?.length) {
signed.sessionInitTxs = preparedTxs.sessionInitTxs.map(b =>
signVersioned(b, keypair)
);
}
if (preparedTxs.keeperFundingTx) {
signed.keeperFundingTx = signVersioned(preparedTxs.keeperFundingTx, keypair);
}
return signed;
}
```
***
## Broadcasting to Solana
Signed transactions must be broadcast in a strict order. Each group must reach `confirmed` status before the next group is sent — later transactions depend on accounts created by earlier ones.
### Broadcast order
```
keeperFundingTx → routeInitTxs[0..N] → orchestratorInitTx → sessionInitTxs[0..N]
```
**`keeperFundingTx` must be broadcast and confirmed first.** Call `confirm-broadcast` with just the `keeperFundingSignature` immediately after — before broadcasting anything else. This is required to prevent double-funding if deployment is interrupted.
Wait for each `routeInitTx` to reach `confirmed` before broadcasting the next one. Allow an additional 3 seconds after the last `routeInitTx` and after `orchestratorInitTx` for account state to propagate across RPC nodes, especially on devnet.
### Python
```python theme={null}
import time
import requests
def broadcast_and_confirm(base64_tx: str, label: str, rpc_url: str) -> str:
"""Send a signed transaction to the RPC and wait for confirmation."""
resp = requests.post(rpc_url, json={
"jsonrpc": "2.0", "id": 1,
"method": "sendTransaction",
"params": [base64_tx, {"encoding": "base64", "skipPreflight": False}],
}, timeout=30)
resp.raise_for_status()
result = resp.json()
if "error" in result:
raise RuntimeError(f"{label} failed: {result['error']}")
sig = result["result"]
print(f" {label}: {sig}")
for _ in range(12):
time.sleep(5)
status = requests.post(rpc_url, json={
"jsonrpc": "2.0", "id": 1,
"method": "getSignatureStatuses",
"params": [[sig], {"searchTransactionHistory": True}],
}, timeout=30).json()
val = (status.get("result", {}).get("value") or [None])[0]
if val and val.get("confirmationStatus") in ("confirmed", "finalized"):
return sig
return sig # proceed after 60s timeout
def broadcast_signed_txs(
signed: dict, rpc_url: str, transfer_id: int, api_base: str, api_headers: dict
) -> dict:
"""Broadcast all signed transactions in the correct order.
Calls confirm-broadcast immediately after keeperFundingTx to prevent double-funding.
Returns the final confirm-broadcast body."""
import uuid
signatures: dict = {}
# 1. keeperFundingTx FIRST — record immediately
if signed.get("keeperFundingTx"):
sig = broadcast_and_confirm(signed["keeperFundingTx"], "keeperFundingTx", rpc_url)
signatures["keeperFundingSignature"] = sig
requests.post(
f"{api_base}/api/v1/transfers/{transfer_id}/confirm-broadcast",
json={"routeInitSignatures": [], "keeperFundingSignature": sig},
headers={**api_headers, "Idempotency-Key": str(uuid.uuid4())},
timeout=30,
).raise_for_status()
# 2. routeInitTxs
route_sigs = []
for i, entry in enumerate(signed.get("routeInitTxs") or []):
sig = broadcast_and_confirm(entry["base64"], f"routeInitTxs[{i}]", rpc_url)
route_sigs.append(sig)
if i < len(signed["routeInitTxs"]) - 1:
time.sleep(3)
if route_sigs:
signatures["routeInitSignatures"] = route_sigs
time.sleep(3)
# 3. orchestratorInitTx
if signed.get("orchestratorInitTx"):
sig = broadcast_and_confirm(signed["orchestratorInitTx"], "orchestratorInitTx", rpc_url)
signatures["orchestratorInitSignature"] = sig
time.sleep(3)
# 4. sessionInitTxs
session_sigs = []
for i, b64 in enumerate(signed.get("sessionInitTxs") or []):
sig = broadcast_and_confirm(b64, f"sessionInitTxs[{i}]", rpc_url)
session_sigs.append(sig)
if session_sigs:
signatures["sessionInitSignatures"] = session_sigs
return signatures
```
### TypeScript
```typescript theme={null}
import { Connection } from "@solana/web3.js";
const connection = new Connection(process.env.SOLANA_RPC_URL!, "confirmed");
async function broadcastAndConfirm(base64Tx: string, label: string): Promise {
const sig = await connection.sendRawTransaction(
Buffer.from(base64Tx, "base64"),
{ skipPreflight: false }
);
console.log(` ${label}: ${sig}`);
await connection.confirmTransaction(sig, "confirmed");
return sig;
}
async function broadcastSignedTxs(
signed: PreparedTxs,
confirmBroadcast: (body: Record) => Promise
): Promise> {
const signatures: Record = {};
// 1. keeperFundingTx FIRST — record immediately to prevent double-funding on resume
if (signed.keeperFundingTx) {
const sig = await broadcastAndConfirm(signed.keeperFundingTx, "keeperFundingTx");
signatures.keeperFundingSignature = sig;
await confirmBroadcast({ routeInitSignatures: [], keeperFundingSignature: sig });
}
// 2. routeInitTxs
const routeSigs: string[] = [];
for (let i = 0; i < (signed.routeInitTxs?.length ?? 0); i++) {
const sig = await broadcastAndConfirm(signed.routeInitTxs![i].base64, `routeInitTxs[${i}]`);
routeSigs.push(sig);
if (i < signed.routeInitTxs!.length - 1) {
await new Promise(r => setTimeout(r, 3000));
}
}
if (routeSigs.length) {
signatures.routeInitSignatures = routeSigs;
await new Promise(r => setTimeout(r, 3000));
}
// 3. orchestratorInitTx
if (signed.orchestratorInitTx) {
const sig = await broadcastAndConfirm(signed.orchestratorInitTx, "orchestratorInitTx");
signatures.orchestratorInitSignature = sig;
await new Promise(r => setTimeout(r, 3000));
}
// 4. sessionInitTxs
const sessionSigs: string[] = [];
for (const [i, b64] of (signed.sessionInitTxs ?? []).entries()) {
sessionSigs.push(await broadcastAndConfirm(b64, `sessionInitTxs[${i}]`));
}
if (sessionSigs.length) signatures.sessionInitSignatures = sessionSigs;
return signatures;
}
```
***
## Handling expiry and resume
Solana blockhashes expire roughly 60 seconds after the `/prepare` call. If broadcast fails mid-way — due to expiry, an RPC error, or a process crash — call `/prepare` again with a **new `Idempotency-Key`**.
The server inspects the chain and returns `null` for any group already confirmed. The signing and broadcast helpers above skip `null` fields automatically.
```json theme={null}
{
"preparedTxs": {
"routeInitTxs": null, // already on-chain — skip
"orchestratorInitTx": null, // already on-chain — skip
"sessionInitTxs": ["AQAAAA..."], // still needed
"keeperFundingTx": "AQAAAA..." // still needed
}
}
```
Repeat: sign the non-null fields → broadcast → confirm-broadcast. The server merges with the existing on-chain state.
***
## Compliance & screening
On compliance-enabled networks (mainnet today) every route is screened against sanctions/risk data
**automatically, after deployment, by the keeper** — your agent does nothing to "pass" screening.
Two things to account for:
* **Budget the screening fee.** A flat screening fee (currently **0.002 SOL** on mainnet, a
configured value) is taken from `sourceOwner` at deploy by the `/prepare` bundle and is **not**
included in `/estimate`. It's a refundable anti-abuse deposit — a clean route gets it back on
verification, a flagged route forfeits it — but the wallet must hold it at deploy time on top of
the transfer amount, protocol fees, account rent, and keeper funding, or the deploy can fail for
insufficient lamports.
* **Expect a short verification window.** After your final `confirm-broadcast`, the transfer sits in
`processing` while the keeper verifies compliance, then hops begin executing. Keep polling — no
action is needed. If a wallet is flagged, the transfer ends as `refunded`: the principal is
returned and the screening fee is retained.
See [Compliance & screening](/concepts/compliance) for the full lifecycle.
***
## Full autonomous loop (TypeScript)
```typescript theme={null}
import { Keypair } from "@solana/web3.js";
import bs58 from "bs58";
const API_BASE = "https://multihopper.com";
const API_KEY = process.env.MH_API_KEY!;
const keypair = Keypair.fromSecretKey(bs58.decode(process.env.SOLANA_PRIVATE_KEY!));
const headers = { "x-api-key": API_KEY, "Content-Type": "application/json" };
async function mhTransfer(params: {
sourceOwner: string;
recipientWallet: string;
amountRaw: string;
amountTokens: string;
tokenMint: string;
tokenDecimals: number;
tokenSymbol?: string;
hops?: number;
arrivalSeconds?: number;
}) {
// 1. Create
const transfer = await fetch(`${API_BASE}/api/v1/transfers`, {
method: "POST",
headers: { ...headers, "Idempotency-Key": crypto.randomUUID() },
body: JSON.stringify(params),
}).then(r => r.json());
const id = transfer.id;
// Helper: call confirm-broadcast
const confirmBroadcast = async (body: Record) => {
await fetch(`${API_BASE}/api/v1/transfers/${id}/confirm-broadcast`, {
method: "POST",
headers: { ...headers, "Idempotency-Key": crypto.randomUUID() },
body: JSON.stringify(body),
});
};
// 2. Prepare → sign → broadcast (with retry on expiry)
// broadcastSignedTxs calls confirmBroadcast once immediately after keeperFundingTx.
let broadcastSignatures: Record = {};
let attempts = 0;
while (true) {
if (attempts++ > 3) throw new Error("Too many prepare attempts");
const { preparedTxs } = await fetch(`${API_BASE}/api/v1/transfers/${id}/prepare`, {
method: "POST",
headers: { ...headers, "Idempotency-Key": crypto.randomUUID() },
}).then(r => r.json());
if (preparedTxs.resume?.nothingToDo) break;
const signed = signPreparedTxs(preparedTxs, keypair);
const sigs = await broadcastSignedTxs(signed, confirmBroadcast);
broadcastSignatures = { ...broadcastSignatures, ...sigs };
if (!preparedTxs.resume?.routeAlreadyDeployed) break;
}
// 3. Final confirm-broadcast with all remaining signatures
await confirmBroadcast(broadcastSignatures);
// 4. Poll until settled
while (true) {
const { transfer: t } = await fetch(`${API_BASE}/api/v1/transfers/${id}`, { headers })
.then(r => r.json());
console.log(` status: ${t.status} progress: ${t.progress.hopsCompleted}/${t.progress.hopsTotal}`);
if (["completed", "failed", "expired"].includes(t.status)) return t;
await new Promise(r => setTimeout(r, 5000));
}
}
```
***
## Agent context file (CLAUDE.md)
Drop the block below into your project's `CLAUDE.md` (or any system prompt / context file your agent reads). It gives the agent everything it needs to call MultiHopper correctly without hallucinating endpoints or field names.
```markdown theme={null}
# MultiHopper
REST API: https://multihopper.com/api/v1
Auth: x-api-key header — mh_live_... (live) or mh_test_... (test)
## Transfer flow — 3 API calls
### 1. Create
POST /transfers
Required fields: tokenMint, amountRaw, amountTokens, sourceOwner (sender wallet),
recipientWallet, hops (integer, 3–10), arrivalSeconds
Optional: tokenDecimals (default 6), tokenSymbol, tokenPriceUsd, externalId
→ returns { id, status: "awaiting_signature", ... }
### 2. Prepare
POST /transfers/{id}/prepare
→ returns { transfer, preparedTxs: {
routeInitTxs[]: { base64 },
orchestratorInitTx: string,
sessionInitTxs[]: string,
keeperFundingTx: string,
recentBlockhash, lastValidBlockHeight
}}
Sign each group with the sourceOwner keypair (see signing rules).
BROADCAST ORDER (strict): keeperFundingTx FIRST, then routeInitTxs, then orchestratorInitTx,
then sessionInitTxs.
null fields are already confirmed on-chain — skip them.
### 3. Confirm broadcast — call TWICE
First call (immediately after keeperFundingTx, before anything else):
POST /transfers/{id}/confirm-broadcast
{ routeInitSignatures: [], keeperFundingSignature: "..." }
Second call (after all remaining txs are broadcast and confirmed):
POST /transfers/{id}/confirm-broadcast
{ routeInitSignatures[], orchestratorInitSignature?, sessionInitSignatures?, keeperFundingSignature? }
keeperFundingSignature is REQUIRED if /prepare emitted a keeperFundingTx. Returns MH_039 if missing.
The two-call pattern prevents double-funding the keeper on resume.
Blockhashes expire ~60 seconds after /prepare. Call /prepare again with a new Idempotency-Key on
expiry; null fields will reflect what is already on-chain.
All POST mutations require an Idempotency-Key header (MH_070 if missing or invalid).
## Signing rules
- keeperFundingTx, routeInitTxs, sessionInitTxs → VersionedTransaction (v0)
Add your signature to the correct slot in the existing signatures array.
Do NOT call sign() or replace all signatures — server has pre-signed ephemeral keys.
- orchestratorInitTx → Legacy Transaction, use partialSign.
- Wait for each group to reach "confirmed" before broadcasting the next.
- Add 3s delay after last routeInitTx and after orchestratorInitTx.
## Polling transfer status
GET /transfers/{id}
→ { status, phase, progress: { hopsCompleted, hopsTotal }, lastError, recovery }
status: quote → awaiting_signature → processing → completed | failed | expired | refunded
phase: quoted → deploying → executing → settled
(failure: failed | recoverable | rescued | reclaimed | expired)
If phase = "recoverable" (funds locked on-chain):
POST /transfers/{id}/rescue/prepare → sign txs → POST /transfers/{id}/rescue/confirm
## Compliance (mainnet)
- Routes are screened automatically by the keeper after deploy — no agent action needed.
- A flat screening fee (currently ~0.002 SOL on mainnet, configured/subject to change) is taken at
deploy by the /prepare bundle, NOT in /estimate. Budget for it or the deploy can fail for
insufficient lamports. It is a refundable deposit: clean route gets it back on verify, flagged
route forfeits it.
- After the final confirm-broadcast the transfer sits in "processing" during the screening/verify
window, then hops execute. Keep polling.
- A flagged wallet ends the transfer as "refunded": principal returned, screening deposit retained.
## Key constraints
- hops must be 3–10
- arrivalSeconds minimum varies by hop count (MH_014 if too low)
- externalId must be unique per integration key (MH_033 on duplicate)
- All POST mutations require Idempotency-Key header
## Common error codes
MH_001 / MH_002 invalid or revoked API key (401)
MH_012 amount below minimum (400)
MH_013 hops out of range — must be 3–10 (400)
MH_014 arrivalSeconds below minimum for hop count (400)
MH_032 funding not completed within timeout (408)
MH_033 duplicate externalId (409)
MH_034 transfer expired (410)
MH_039 keeperFundingSignature required but missing (400)
MH_070 Idempotency-Key header missing or invalid (400)
```
***
## MCP server integration
When building an MCP-compatible agent (Claude, LangGraph, AutoGen), expose the API steps as individual tools and keep `sign_and_broadcast` as a local tool that holds wallet access.
| Tool | Wraps |
| -------------------- | ---------------------------------------------- |
| `estimate_transfer` | `POST /api/v1/transfers/estimate` |
| `create_transfer` | `POST /api/v1/transfers` |
| `prepare_transfer` | `POST /api/v1/transfers/:id/prepare` |
| `sign_and_broadcast` | local — signs with keypair, broadcasts to RPC |
| `confirm_broadcast` | `POST /api/v1/transfers/:id/confirm-broadcast` |
| `get_transfer` | `GET /api/v1/transfers/:id` |
| `list_transfers` | `GET /api/v1/transfers` |
| `prepare_rescue` | `POST /api/v1/transfers/:id/rescue/prepare` |
| `confirm_rescue` | `POST /api/v1/transfers/:id/rescue/confirm` |
`sign_and_broadcast` is the only tool that requires wallet access. All other tools are pure HTTP wrappers and can be exposed without key material.
Full endpoint documentation, error codes, and rate limits.
Receive real-time transfer lifecycle events instead of polling.
Trust assumptions and on-chain guarantees.
How keepers execute hops after broadcast.
# MCP Server
Source: https://dev-docs.multihopper.com/guides/mcp-server
Connect AI agents to MultiHopper's documentation via the Model Context Protocol
MultiHopper's documentation is available as a hosted **Model Context Protocol (MCP) server**. Connect any MCP-compatible AI client to search docs, read API references, and explore code examples in real time — without leaving your editor or agent workflow.
## Server URL
```
https://dev-docs.multihopper.com/mcp
```
Paste this URL into any MCP-compatible client to connect.
***
## Connect to your AI client
Open `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) and add:
```json theme={null}
{
"mcpServers": {
"multihopper-docs": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://dev-docs.multihopper.com/mcp"]
}
}
}
```
Restart Claude Desktop. The `search_multi_hopper` and `query_docs_filesystem_multi_hopper` tools will be available in every conversation.
Open **Cursor Settings → MCP** and add a new server:
* **Name:** `multihopper-docs`
* **URL:** `https://dev-docs.multihopper.com/mcp`
Or run the Mintlify install command in your terminal:
```bash theme={null}
npx mcp add dev-docs.multihopper.com
```
Add to your `.vscode/mcp.json` or user settings:
```json theme={null}
{
"servers": {
"multihopper-docs": {
"type": "sse",
"url": "https://dev-docs.multihopper.com/mcp"
}
}
}
```
The server uses the standard **SSE transport**. Point your client at:
```
https://dev-docs.multihopper.com/mcp
```
No authentication is required for public documentation pages.
***
## Available tools
### `search_multi_hopper`
Searches across the MultiHopper knowledge base to find relevant information, code examples, API references, and guides.
Use this tool for broad or conceptual queries — e.g. *"how does the orchestrator work"*, *"what is keeperFundingTx"*, or *"error MH\_039"*. Returns contextual snippets with titles and links to the source pages.
**Example prompts that trigger this tool:**
* "How do I estimate fees before creating a transfer?"
* "What is the broadcast order for prepared transactions?"
* "What does phase `recoverable` mean?"
***
### `query_docs_filesystem_multi_hopper`
Runs read-only shell-style queries against a virtualized filesystem containing all MultiHopper documentation pages and the OpenAPI spec.
Use this tool when you need **exact content** from a specific page, regex matches across the docs, or structural exploration.
**Supported commands:** `rg`, `grep`, `find`, `tree`, `ls`, `cat`, `head`, `tail`, `stat`, `wc`, `sort`, `uniq`, `cut`, `sed`, `awk`, `jq`
**Useful commands:**
```bash theme={null}
# See the full docs structure
tree / -L 2
# Read a specific page
head -100 /quickstart.mdx
cat /api-reference/transfers/prepare.mdx
# Search for a keyword across all docs
rg -il "keeperFundingTx" /
# Show lines around a match with context
rg -C 3 "MH_039" /api-reference/
# List all OpenAPI endpoints
cat /api-reference/openapi.json | jq '.paths | keys'
# Read multiple pages in one call
head -80 /quickstart.mdx /guides/agentic-integration.mdx
```
Each call is **stateless** — the working directory always resets to `/`. Chain commands with `&&` or use absolute paths if you need to navigate a subdirectory.
***
## Example: agent workflow
Here's how an AI agent would use both tools to answer *"how do I sign and broadcast a prepared transfer?"*:
1. **`search_multi_hopper`** — `"sign broadcast prepared transactions"` → finds `/guides/agentic-integration` and `/api-reference/transfers/prepare`
2. **`query_docs_filesystem_multi_hopper`** — `head -150 /guides/agentic-integration.mdx` → reads the full signing code examples
The search tool narrows scope; the filesystem tool reads the full page. Use them together.
Full guide for autonomous agents — signing, broadcasting, and the complete TypeScript/Python loop.
All endpoints, error codes, and rate limits.
# MultiHopper
Source: https://dev-docs.multihopper.com/index
Programmable multi-hop token routing
## What is MultiHopper?
MultiHopper is a protocol for scheduled, multi-hop token transfers. It enables you to define programmable payment pipelines — specifying who receives tokens, when, and in what sequence — then deploy them on-chain and let the protocol handle execution trustlessly.
Create your first route and send your first multi-hop transfer.
## Why MultiHopper?
On-chain payments today are one-shot: sign a transaction, it executes immediately. There is no native way to:
* Schedule a series of transfers across time
* Route tokens through multiple recipients in a defined sequence
* Enforce timing constraints without trusting a centralized scheduler
* Maintain custody guarantees while assets move through a multi-step flow
MultiHopper solves this using on-chain timelocks, a wrapper token architecture, and a permissionless keeper network.
## Explore the protocol
Understand the core mechanism: vaults, wrapper tokens, and hop execution.
Smart contracts, application layer, and system components.
From route creation through settlement — step by step.
Trust assumptions, threat model, and on-chain enforcement guarantees.
## Use cases
Token unlocks distributed to multiple recipients on a defined timeline.
Recurring payments routed through compliance or treasury wallets.
Multi-party transactions with time-gated releases.
Programmatic fund distribution across organizational units.
## Integrate with the API
Create and manage routes programmatically via the MultiHopper REST API.
# Architecture
Source: https://dev-docs.multihopper.com/protocol/architecture
Smart contracts, application layer, and system components
## System Overview
MultiHopper is built as a layered system. The protocol layer enforces all routing guarantees on-chain; the application layer provides the tooling to create and monitor routes.
```
User / Integrator
↓
REST API / App
↓
Keeper Network
↓
Smart Contracts (Anchor / Rust)
↓
Solana + Token2022
```
## Smart Contracts
Two on-chain programs implement the protocol:
**`3jLoS2wbNgtKzieUUxwg6Xhdv6gbZkHDtPWA9ZAgspFh`**
Handles route creation, hop execution, wrap/unwrap operations, fee collection, provider registration, and fee schedule management. This is the primary program for all route interactions.
**`4TPCmR2uN3hDM1FbTsf7ZtFXwXGSz4u1Kxv5YHj6eruX`**
A permissionless execution engine that breaks route deployment into discrete keeper-executable steps. Manages the multi-transaction sequence needed to fund and initialize a route on behalf of the creator.
**`2JEv3pD6nczEvn1xDXaEzehkJofPPjoQQpnW5nGY3r52`**
Implements the Token2022 transfer hook extension for all wrapper mints. Enforces that only the protocol program can move wrapper tokens and that every transfer occurs within a valid, active route context.
### Key On-Chain Accounts
#### MultiHopper Core
| Account | Seed | Description |
| ------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `RouteConfig` | `[b"route", route_id_le_bytes]` | Stores the full route definition: hop sequence, recipients, amounts, timing, prepaid hops, provider, and execution state |
| `RouteState` | `[b"state", route_id_le_bytes]` | Mutable execution state for the route — tracks hop progress and finalization |
| `PermanentDelegate` | `[b"delegate", route_id_le_bytes]` | PDA holding permanent delegate authority over the wrapper mint |
| `Vault` (SPL) | `[b"vault_authority", original_mint]` | Program-controlled token account holding original deposited SPL tokens |
| `SolVault` | `[b"sol_vault", token_config_creator]` | Program-controlled lamport vault for SOL routes |
| `WrapperMint` | `[b"mint_authority", route_id_le_bytes]` | Token2022 mint with permanent delegate, metadata pointer, and transfer hook extensions |
| `TokenConfig` | `[b"token_config_global_v2"]` | Global token configuration account |
| `Provider` | `[b"provider", provider_id]` | Integrator registration — stores provider wallet and ID |
| `FeeSchedule` | `[b"fee_schedule", provider_id]` | Tiered fee tiers for a registered provider |
#### Orchestrator
| Account | Seed | Description |
| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------ |
| `OrchestratorConfig` | `[b"orchestrator"]` | Stores the full step sequence, keeper share in basis points, step count, and creator |
| `OrchestratorStep` | `[b"step", orchestrator_id, step_index]` | Individual step with type, execute\_at timestamp, and execution state |
## Application Layer
| Component | Technology | Purpose |
| ------------- | --------------------------------------------------- | ------------------------------------------------------------ |
| **Web App** | React 18/19, Vite, TailwindCSS | Route creation UI, wallet connection, transaction signing |
| **API** | Fastify, tRPC (internal), REST `/api/v1` (external) | Transaction building, route management |
| **Scheduler** | Node.js cron | Monitors pending steps and hops, submits keeper transactions |
| **Indexer** | Custom ETL | On-chain event indexing for real-time route status tracking |
| **Database** | PostgreSQL, Drizzle ORM | Route state, user data, execution history |
## Monorepo Structure
The codebase is organized as a Turborepo monorepo:
```
multihopper/
├── apps/
│ ├── web/ # React frontend
│ └── api/ # Fastify API server
├── libs/
│ ├── server/ # tRPC routers and server logic
│ └── shared/ # Shared types and utilities
```
The Anchor programs live in a separate `multihopper-contracts` repository.
## Full Tech Stack
| Layer | Technologies |
| ------------------- | --------------------------------------------------------- |
| **Smart Contracts** | Anchor, Rust, Token2022 (spl-token-2022) |
| **Frontend** | React 18/19, Vite, TanStack Router, Solana Wallet Adapter |
| **Backend** | Fastify, tRPC, Drizzle ORM, PostgreSQL |
| **Infrastructure** | Turborepo, TypeScript, Docker |
## Architectural Principles
**Distribution and execution are intentionally separated.** The abstraction layer handles asset routing and intermediate wallet coordination. The hop execution engine handles sequenced transfers. This separation allows each layer to evolve independently and makes the security boundary cleaner.
**Permissionless execution.** No single entity controls whether a route executes. Any keeper can trigger orchestrator steps or hop executions — the program validates correctness, not identity.
**Off-chain indexing, on-chain authority.** The indexer and API provide a convenient read layer, but they hold no authority over funds. All state that matters for settlement is on-chain.
**Provider extensibility.** Integrators can register as providers via `register_provider`, attach a `provider_id` to routes, and configure per-provider fee tiers via `initialize_fee_schedule` / `update_fee_schedule`.
# How It Works
Source: https://dev-docs.multihopper.com/protocol/how-it-works
The core mechanism behind MultiHopper's programmable token routing
## Overview
MultiHopper enables scheduled, sequenced token transfers on Solana. The key insight is separating **custody** from **routing**: original tokens stay locked in a program-controlled vault while wrapper tokens flow through the route.
Three on-chain programs implement the protocol:
* **MultiHopper Core** (`3jLoS2wbNgtKzieUUxwg6Xhdv6gbZkHDtPWA9ZAgspFh`) — handles route creation, hop execution, wrap/unwrap operations, provider registration, and fee management.
* **Orchestrator** (`4TPCmR2uN3hDM1FbTsf7ZtFXwXGSz4u1Kxv5YHj6eruX`) — a permissionless execution engine that coordinates the deployment transaction sequence for a route through discrete, keeper-executable steps.
* **Transfer Hook Guard** (`2JEv3pD6nczEvn1xDXaEzehkJofPPjoQQpnW5nGY3r52`) — implements the Token2022 transfer hook extension, enforcing custom compliance rules on every wrapper token movement.
## Core Mechanism: Wrapper Tokens
MultiHopper uses Solana's [Token2022](https://spl.solana.com/token-2022) extensions to create route-specific wrapper tokens.
A user deposits original tokens (SOL or SPL) into an on-chain vault controlled by the protocol program.
The protocol mints an equivalent amount of wrapper tokens using a Token2022 mint with the **permanent delegate** extension enabled. Wrapper tokens are backed 1:1 by the vault.
The permanent delegate allows the protocol to programmatically transfer wrapper tokens between hop recipients at scheduled times — without requiring recipient signatures at each step.
After the route completes, the final recipient burns their wrapper tokens to redeem the original tokens from the vault.
Original tokens never leave the vault until final redemption. The wrapper token mechanism ensures 1:1 backing throughout the entire route lifecycle.
## Token2022 Extensions Used
MultiHopper relies on three Token2022 extensions to implement its routing logic:
| Extension | Role |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| **Permanent Delegate** | Enables programmatic token transfers without holder signatures — the protocol can move wrapper tokens on behalf of recipients |
| **Metadata Pointer** | Attaches route metadata (hop sequence, timing, recipients) directly to the wrapper mint on-chain |
| **Transfer Hook** | Enforces custom transfer compliance rules at the protocol level on every wrapper token movement |
## Orchestrator: Step-Based Deployment
The **Orchestrator program** coordinates the multi-transaction sequence required to deploy a route. Instead of requiring the user to sign every setup transaction themselves, the orchestrator breaks deployment into discrete **steps** that permissionless keepers can execute.
Each step has a type:
| Step Type | Description |
| ------------ | ------------------------------------------------------------------ |
| `transfer` | Transfer SOL or lamports between accounts (e.g. fund a hop wallet) |
| `wrap_sol` | Wrap SOL into the route's vault via `wrap_sol` on the Core program |
| `wrap_token` | Wrap an SPL token into the route's vault |
When a route is deployed through the orchestrator:
1. An `OrchestratorConfig` account is created with the full step sequence and a `keeper_share_bps` incentive.
2. Individual `OrchestratorStep` accounts are initialized, each with an `execute_at` timestamp.
3. Any keeper can execute each step once its scheduled time arrives — earning a share of the deposited funds.
4. After all steps complete, the orchestrator is closed and rent reclaimed.
## On-Chain Enforcement
Every constraint in a route is enforced by the smart contract — there is no trusted off-chain component with authority over funds.
Each hop and orchestrator step has an `execute_at` timestamp validated against the Solana clock. A hop or step cannot execute before its scheduled time — the transaction will fail at the contract level.
Hops execute in strict order. Hop `N` must complete before hop `N+1` can be triggered. This is enforced by on-chain state tracking within the route account.
The `prepaid_hops` field on each route enforces how many hops have been fee-paid. The program rejects execution beyond the prepaid limit.
Protocol fees are collected upfront at route creation. Provider integrators can attach a `provider_id` and optional `reference_id` to routes, with per-provider fee schedules managed via the `FeeSchedule` account.
## Token Support
The wrapper mechanism normalizes all Solana token types into a consistent routing interface:
* Native **SOL** (via dedicated `initialize_route_sol` / `wrap_sol` / `unwrap_sol` instructions)
* Standard **SPL tokens**
* **Token2022** tokens
SOL routes use a dedicated `sol_vault` PDA rather than a token account, with `mint_wsol_tickets` used to issue wrapper tokens representing locked SOL.
## What Happens If a Keeper Fails?
If a keeper fails to execute a step or hop at the scheduled time, the step stays pending and any other executor can trigger it. The protocol does not rely on a single keeper — execution is permissionless.
If a step fails on-chain, the `refund_failed_step` instruction on the Orchestrator returns deposited funds to the creator. The `rescue_step` instruction provides an admin escape hatch for stuck steps.
Understand how the keeper network provides permissionless, decentralized execution.
# Route Lifecycle
Source: https://dev-docs.multihopper.com/protocol/route-lifecycle
From route creation through final settlement — step by step
Every MultiHopper route moves through four stages: **Create → Deploy → Execute → Unwrap**.
## Lifecycle Diagram
```
CREATE User defines route: recipients, amounts, timing, provider
|
▼
DEPLOY Orchestrator steps fund the route; wrapper tokens minted 1:1
|
▼
EXECUTE Hops trigger at scheduled times (on-chain timelocks enforced)
|
▼
UNWRAP Final recipient burns wrapper tokens; original tokens released
```
## Stage 1: Create
The user (or integrator via API) defines the route:
* **Asset** — the token to route (SOL or SPL)
* **Hop sequence** — an ordered list of recipients and timing
* **`hop_amount`** — a single amount shared across all hops
* **`source_owner`** — optional source wallet for abstraction (intermediate funding)
* **`prepaid_hops`** — how many hops have been fee-paid upfront
* **`provider_id`** / **`reference_id`** — optional integrator and external reference IDs
A `RouteConfig` account is created on-chain storing the full definition. The route ID is a 32-byte identifier (`[u8; 32]`).
Routes are immutable after creation. Plan your hop sequence carefully before submitting.
## Stage 2: Deploy (Orchestrator)
Route deployment uses the **Orchestrator program** to coordinate the multi-transaction funding sequence. This is handled transparently by the app or API — integrators do not need to manage individual steps.
### How the Orchestrator Works
1. An `OrchestratorConfig` account is created with the full step sequence, `keeper_share_bps` incentive, and step count.
2. Individual `OrchestratorStep` accounts are initialized — each with a type and `execute_at` timestamp:
* **`transfer`** — transfer SOL/lamports between accounts to fund intermediate wallets
* **`wrap_sol`** — wrap SOL into the route vault via the Core program
* **`wrap_token`** — wrap an SPL token into the route vault
3. Keepers execute each step permissionlessly once its scheduled time arrives. Each successful keeper earns a share of deposited funds proportional to `keeper_share_bps`.
4. After all steps complete, the orchestrator is closed and remaining rent is reclaimed.
### Abstraction
When a route is deployed via the abstraction path, intermediate wallets are funded through the orchestrator's `transfer` steps. This allows the source of funds to be separated from the final route configuration — the intermediate wallets facilitate the on-chain setup without directly linking the origin wallet to the final route state.
## Stage 3: Execute
Hops trigger according to the on-chain schedule. For each hop:
1. A keeper submits a `trigger_hop` instruction to the Core program
2. The program validates:
* Current Solana clock ≥ `execute_at` for this hop
* Previous hop is complete
* Hop is within the `prepaid_hops` limit
3. The permanent delegate transfers wrapper tokens from the current recipient to the next
4. The hop is marked complete on-chain
This repeats for every hop in the sequence.
Execution is permissionless — any keeper can submit a valid hop execution. The program enforces all constraints; the keeper cannot deviate from the route definition.
## Stage 4: Unwrap
After the final hop completes, the last recipient can redeem:
1. Submit an `unwrap` (SPL) or `unwrap_sol` (SOL) instruction with their wrapper tokens
2. The program burns the wrapper tokens
3. The equivalent original tokens are released from the vault to the recipient
The route is now **settled**. The `close_route` instruction can be called to reclaim the account rent once the route is fully settled.
## Failure Cases
The transaction fails with a clock constraint error. The hop stays pending. Any executor can retry after the scheduled time.
The hop or step stays pending. Any other executor can trigger it. Routes do not expire.
The `refund_failed_step` instruction on the Orchestrator returns deposited funds to the creator. The `rescue_step` instruction is available as an admin escape hatch for permanently stuck steps.
The executor must ensure recipient token accounts exist before submitting. Standard ATA creation applies.
# Quickstart
Source: https://dev-docs.multihopper.com/quickstart
Send your first multi-hop transfer in minutes
## Prerequisites
* A MultiHopper API key (`mh_test_...` for devnet/testing, `mh_live_...` for production)
* A Solana wallet with funds to transfer (devnet SOL is free)
Create your key from the dashboard of the environment you're building against — keys are
**not** interchangeable between environments. New to MultiHopper? Start on devnet.
Build and test against Solana devnet — issues `mh_test_` keys. Recommended to start here.
Go live on mainnet — issues `mh_live_` keys.
Base URLs differ per environment (`https://devnet.multihopper.com` vs `https://multihopper.com`).
The examples below use relative paths — prefix them with your environment's base URL. See
[Environments](/concepts/environments) for the full table.
## Transfer flow overview
Creating a transfer takes three API calls, plus an intermediate confirmation step:
```
1. POST /transfers → create transfer, receive a quote
2. POST /transfers/:id/prepare → get serialized transaction bundles
[sign + broadcast keeperFundingTx FIRST]
POST /transfers/:id/confirm-broadcast → record keeperFundingSignature immediately
[sign + broadcast remaining txs in order]
3. POST /transfers/:id/confirm-broadcast → submit remaining signatures, trigger deployment
```
Once confirmed, the transfer moves into processing automatically.
## Step 1: Estimate fees (optional)
Check expected costs before creating the transfer.
```bash theme={null}
curl -X POST /api/v1/transfers/estimate \
-H "x-api-key: mh_test_abc123..." \
-H "Content-Type: application/json" \
-d '{
"tokenMint": "So11111111111111111111111111111111111111112",
"amountRaw": "1000000000",
"tokenDecimals": 9,
"hops": 7
}'
```
```json theme={null}
{
"tier": "standard",
"percentFeeBps": 50,
"totalFlatFeeLamports": 42000,
"usdEquivalent": 1502.50
}
```
## Step 2: Create the transfer
```bash theme={null}
curl -X POST /api/v1/transfers \
-H "x-api-key: mh_test_abc123..." \
-H "Content-Type: application/json" \
-d '{
"tokenMint": "So11111111111111111111111111111111111111112",
"amountRaw": "1000000000",
"amountTokens": "1.0",
"tokenDecimals": 9,
"tokenSymbol": "SOL",
"sourceOwner": "",
"recipientWallet": "",
"hops": 7,
"arrivalSeconds": 300,
"externalId": "my-order-001"
}'
```
The response includes the quoted transfer object with a `status` of `awaiting_signature`.
## Step 3: Prepare transactions
Call `prepare` to receive the serialized transaction bundles for this transfer:
```bash theme={null}
curl -X POST /api/v1/transfers/42/prepare \
-H "x-api-key: mh_test_abc123..."
```
```json theme={null}
{
"transfer": { "...": "..." },
"preparedTxs": {
"routeInitTxs": [{ "base64": "AQAAAA..." }],
"orchestratorInitTx": "AQAAAA...",
"sessionInitTxs": ["AQAAAA..."],
"keeperFundingTx": "AQAAAA...",
"recentBlockhash": "5eykt4...",
"lastValidBlockHeight": 291182440
}
}
```
Sign each transaction in the bundle using your wallet and submit them to Solana. Record the resulting signatures.
## Step 4: Confirm broadcast
Submit the collected signatures to confirm the broadcast:
```bash theme={null}
curl -X POST /api/v1/transfers/42/confirm-broadcast \
-H "x-api-key: mh_test_abc123..." \
-H "Content-Type: application/json" \
-d '{
"routeInitSignatures": ["4mGxFn7m..."],
"orchestratorInitSignature": "2qYzLf1x...",
"sessionInitSignatures": ["7nKpQr3z..."],
"keeperFundingSignature": "9vLmXt8w..."
}'
```
The transfer status moves to `processing` and the keeper network takes over deployment.
## Step 5: Monitor status
```bash theme={null}
curl /api/v1/transfers/42 \
-H "x-api-key: mh_test_abc123..."
```
Or receive real-time updates by [registering a webhook](/api-reference/webhooks).
## Next steps
Full documentation for all endpoints, error codes, and rate limits.
Receive real-time transfer lifecycle events.
Understand the on-chain abstraction mechanism.
Trust assumptions and on-chain guarantees.