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.
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
prepareandpublish, 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,
);
| PDA | Seeds |
|---|---|
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 };
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.authTokencomes from the wallet hook/context.APP_IDENTITYis 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...
- Receive the
signaturefrom the body. - Call
getTransaction(signature)to verify it landed on devnet. - If confirmed, proceed with narrative creation.
- If not found, return
400. - Check for a duplicate
narrativeIdbefore 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_vaultIDL — 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
NarrativeVaultStateaccount was created on-chain. - Verify the
BackingRecordaccount 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
narrativeIdis identical between theprepare-mintandpublishrequests. - Confirm
publishrejects an unconfirmed signature with400. - Confirm
publishrejects a duplicatenarrativeId. - 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.