Skip to main content

MWA Transaction Flow

This page documents how MAGMA's mobile app commits a backing transaction through the Mobile Wallet Adapter (MWA). The pattern is a clean three-party handshake: the backend builds an unsigned transaction, the mobile wallet signs and sends it, and the backend verifies it landed before writing any application state.

Devnet beta

The flow targets the magma_backing_vault program on Solana devnet (program ID ExNf7ktskoFCKwneF4239WKt3JsrYYTPDszgJSitc2Vb). The same pattern is used by the Shield vault. Treat all parameters as pre-production.

Design principles

  • The backend never signs. It builds the transaction unsigned and returns it as base64; only the user's wallet on the device signs.
  • The backend derives all PDAs and builds the instruction from the Anchor IDL — never by guessing account names or seeds.
  • State is written only after on-chain confirmation. The publish step verifies the signature via getTransaction() before any database insert.
  • The narrative ID is stable across the two requests. The same UUID is used in prepare and publish, and duplicates are rejected.

Sequence

Step 1 — Backend builds the unsigned transaction

The prepare-mint route loads the Anchor IDL, derives the PDAs, builds the backNarrative instruction, attaches a recent blockhash, sets the fee payer to the backer's wallet, and serializes the unsigned transaction to base64.

PDA derivation

The narrative ID is a UUID. Strip the dashes and decode the 16 hex bytes, then derive the three PDAs from their seeds:

import { PublicKey } from '@solana/web3.js';

// narrativeId is a UUID, e.g. "f52b150c-3c15-..." → 16 bytes
const narrativeIdBytes = Buffer.from(narrativeId.replace(/-/g, ''), 'hex'); // 16 bytes

const [narrativeState] = PublicKey.findProgramAddressSync(
[Buffer.from('state'), narrativeIdBytes],
programId,
);

const [vault] = PublicKey.findProgramAddressSync(
[Buffer.from('vault'), narrativeIdBytes],
programId,
);

const [backingRecord] = PublicKey.findProgramAddressSync(
[Buffer.from('backing'), narrativeIdBytes, backerPubkey.toBytes()],
programId,
);
PDASeeds
narrativeState["state", narrativeIdBytes]
vault["vault", narrativeIdBytes]
backingRecord["backing", narrativeIdBytes, backerPubkey]

Building the instruction

// Load the IDL from the contracts build output — do not hand-write account names.
// Build via Anchor methods so the IDL supplies instruction + account layout.
const ix = await program.methods
.backNarrative(narrativeIdBytes, amountLamports, deadlineTimestamp)
.accounts({
narrativeState,
vault,
backingRecord,
backer: backerPubkey,
// remaining accounts per IDL
})
.instruction();

const { blockhash } = await connection.getLatestBlockhash();
const tx = new Transaction({ feePayer: backerPubkey, recentBlockhash: blockhash }).add(ix);

const base64 = tx.serialize({ requireAllSignatures: false }).toString('base64');
return { transaction: base64, narrativeId };
Do not sign on the backend

The transaction is serialized unsigned (requireAllSignatures: false) and returned to the device. Signing happens only in the user's wallet.

Step 2 — Mobile signs via MWA

The mobile app reconstructs the transaction, opens an MWA session with transact, reauthorizes the wallet, and calls signAndSendTransactions. The wallet signs and submits in one step; the returned signature is forwarded to the publish endpoint.

import { transact } from '@solana-mobile/mobile-wallet-adapter-protocol-web3js';
import { Transaction } from '@solana/web3.js';

const txBytes = Buffer.from(txBase64, 'base64');
const transaction = Transaction.from(txBytes);

const result = await transact(async (wallet) => {
await wallet.reauthorize({
auth_token: account.authToken,
identity: APP_IDENTITY, // { uri, icon, name } from WalletContext
});
return wallet.signAndSendTransactions({
transactions: [transaction],
});
});

const signature = result[0];

await axios.post(`${API_BASE_URL}/v1/narratives/publish`, {
narrativeId,
signature,
thesis,
walletAddress: account.address,
deadline_days: selectedDeadline.days,
});
  • account.authToken comes from the wallet hook/context.
  • APP_IDENTITY is the app identity object { uri, icon, name } used by MWA for the authorization prompt.

Step 3 — Backend verifies the signature

The publish route does not trust the client. It calls getTransaction() against the devnet RPC to confirm the transaction landed, and only then writes the narrative.

const tx = await connection.getTransaction(signature, {
maxSupportedTransactionVersion: 0,
});

if (!tx) {
return res.status(400).json({ error: 'Transaction not confirmed' });
}

// Reject duplicate narrativeId before inserting
const exists = await getSupabase()
.from('narratives')
.select('id')
.eq('id', narrativeId)
.maybeSingle();

if (exists.data) {
return res.status(409).json({ error: 'Narrative already exists' });
}

// Proceed with narrative creation...
  1. Receive the signature from the body.
  2. Call getTransaction(signature) to verify it landed on devnet.
  3. If confirmed, proceed with narrative creation.
  4. If not found, return 400.
  5. Check for a duplicate narrativeId before inserting.

After confirmation, backed capital is routed to the correct yield protocol by token type — see Yield Routing.

Endpoints

POST /v1/narratives/prepare-mint  — backend builds the unsigned transaction (returns base64 + narrativeId)
POST /v1/narratives/publish — backend verifies the signature, then creates the narrative

The REST base is https://api.magmaprotocol.xyz.

Testing checklist

Run this sequence on devnet before relying on the flow:

  • Read the IDL first. Confirm instruction names, account names, and PDA seeds from the magma_backing_vault IDL — do not assume any of them.
  • Print the built transaction to the console and verify all three PDAs (narrativeState, vault, backingRecord) are derived correctly.
  • Submit a test transaction with 0.01 SOL on devnet.
  • Confirm the transaction appears in Solana Explorer (devnet cluster).
  • Verify the NarrativeVaultState account was created on-chain.
  • Verify the BackingRecord account was created on-chain.
  • Verify the fee payer is the backer wallet (not the backend).
  • Verify the transaction was returned unsigned from prepare-mint.
  • Confirm narrativeId is identical between the prepare-mint and publish requests.
  • Confirm publish rejects an unconfirmed signature with 400.
  • Confirm publish rejects a duplicate narrativeId.
  • Verify the yield-protocol receipt is stored in the position record after confirmation (see Yield Routing).
  • Confirm no durable-nonce transactions are used anywhere in the flow.