Integrating Volta with Your dApp
This tutorial covers how to integrate the Volta multi-signature contract into your decentralized application.
Overview
Volta acts as a secure, multi-sig controlled account that can: - Hold and transfer assets - Call other contracts on behalf of the organization - Manage its own configuration through governance
Your dApp can interact with Volta to provide multi-sig functionality for your users.
Integration Patterns
Pattern 1: Volta as Treasury
Use Volta to manage shared funds with multi-sig approval for withdrawals.
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Owner 1 │────▶│ │ │ │
├─────────────┤ │ Volta │────▶│ Token │
│ Owner 2 │────▶│ (Treasury) │ │ Contract │
├─────────────┤ │ │ │ │
│ Owner 3 │────▶│ │ │ │
└─────────────┘ └─────────────┘ └─────────────┘
Pattern 2: Volta as Admin
Use Volta as the admin for other contracts, requiring multi-sig for admin actions.
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Owners │────▶│ Volta │────▶│ Your │
│ (multi) │ │ (Admin) │ │ Contract │
└─────────────┘ └─────────────┘ └─────────────┘
JavaScript/TypeScript Integration
Setup
Reading Contract State
import { Contract, SorobanRpc, Networks } from '@stellar/stellar-sdk';
const server = new SorobanRpc.Server('https://soroban-testnet.stellar.org');
const contractId = 'CXXXXX...'; // Your Volta contract ID
// Get current configuration
async function getConfig() {
const contract = new Contract(contractId);
const tx = contract.call('get_config');
const result = await server.simulateTransaction(tx);
// Parse result...
return result;
}
// Get proposal by ID
async function getProposal(proposalId: number) {
const contract = new Contract(contractId);
const tx = contract.call('get_proposal', proposalId);
const result = await server.simulateTransaction(tx);
// Parse result...
return result;
}
Creating a Proposal
import {
Keypair,
Contract,
TransactionBuilder,
SorobanRpc,
Networks,
xdr
} from '@stellar/stellar-sdk';
async function createConfigProposal(
ownerSecret: string,
newOwners: string[],
newThreshold: number
) {
const server = new SorobanRpc.Server('https://soroban-testnet.stellar.org');
const ownerKeypair = Keypair.fromSecret(ownerSecret);
const ownerPublic = ownerKeypair.publicKey();
const contract = new Contract(contractId);
const account = await server.getAccount(ownerPublic);
// Build the proposal
const proposal = {
Config: {
owners: newOwners,
threshold: newThreshold
}
};
const tx = new TransactionBuilder(account, {
fee: '100',
networkPassphrase: Networks.TESTNET
})
.addOperation(contract.call('propose', ownerPublic, proposal))
.setTimeout(30)
.build();
// Simulate to get the prepared transaction
const prepared = await server.prepareTransaction(tx);
prepared.sign(ownerKeypair);
// Submit
const response = await server.sendTransaction(prepared);
// Handle response...
return response;
}
Voting on a Proposal
async function vote(
ownerSecret: string,
proposalId: number,
voteType: 'Yes' | 'No' | 'Abstain'
) {
const server = new SorobanRpc.Server('https://soroban-testnet.stellar.org');
const ownerKeypair = Keypair.fromSecret(ownerSecret);
const ownerPublic = ownerKeypair.publicKey();
const contract = new Contract(contractId);
const account = await server.getAccount(ownerPublic);
const tx = new TransactionBuilder(account, {
fee: '100',
networkPassphrase: Networks.TESTNET
})
.addOperation(contract.call('vote', ownerPublic, proposalId, voteType))
.setTimeout(30)
.build();
const prepared = await server.prepareTransaction(tx);
prepared.sign(ownerKeypair);
const response = await server.sendTransaction(prepared);
return response;
}
Invoking Another Contract
async function invokeContract(
ownerSecret: string,
targetContract: string,
functionName: string,
args: xdr.ScVal[],
authEntries: xdr.SorobanAuthorizationEntry[] = []
) {
const server = new SorobanRpc.Server('https://soroban-testnet.stellar.org');
const ownerKeypair = Keypair.fromSecret(ownerSecret);
const ownerPublic = ownerKeypair.publicKey();
const contract = new Contract(contractId);
const account = await server.getAccount(ownerPublic);
const tx = new TransactionBuilder(account, {
fee: '100',
networkPassphrase: Networks.TESTNET
})
.addOperation(contract.call(
'invoke',
ownerPublic,
targetContract,
functionName,
args,
authEntries
))
.setTimeout(30)
.build();
const prepared = await server.prepareTransaction(tx);
prepared.sign(ownerKeypair);
const response = await server.sendTransaction(prepared);
return response;
}
Monitoring Events
Subscribe to Volta events for real-time updates in your dApp.
Event Types
| Event | Topic | Data |
|---|---|---|
ProposalEvent |
new_prop, pend_prop, exec_prop, rej_prop, rev_prop |
Full proposal object |
VoteEvent |
vote |
proposal_id, voter, vote, status |
ConfigSet |
cfg_set |
New config |
InvokeOk |
inv_ok |
proposal_id, result |
InvokeConversionError |
inv_err |
proposal_id |
Fetching Events
async function getRecentEvents(startLedger: number) {
const server = new SorobanRpc.Server('https://soroban-testnet.stellar.org');
const events = await server.getEvents({
startLedger,
filters: [
{
type: 'contract',
contractIds: [contractId],
}
],
limit: 100
});
return events.events;
}
UI Considerations
Proposal Status Display
function getStatusDisplay(status: string) {
const statusMap = {
'Pending': { label: 'Awaiting Votes', color: 'yellow' },
'Approved': { label: 'Approved', color: 'green' },
'Rejected': { label: 'Rejected', color: 'red' },
'Executed': { label: 'Executed', color: 'green' },
'Revoked': { label: 'Revoked', color: 'gray' }
};
return statusMap[status] || { label: status, color: 'gray' };
}
Vote Progress
function calculateVoteProgress(proposal: Proposal, config: Config) {
const yesVotes = Object.values(proposal.votes).filter(v => v === 'Yes').length;
const noVotes = Object.values(proposal.votes).filter(v => v === 'No').length;
const abstainVotes = Object.values(proposal.votes).filter(v => v === 'Abstain').length;
const totalVotes = yesVotes + noVotes + abstainVotes;
const remaining = config.owners.length - totalVotes;
return {
yes: yesVotes,
no: noVotes,
abstain: abstainVotes,
remaining,
threshold: config.threshold,
canPass: yesVotes + remaining >= config.threshold,
willPass: yesVotes >= config.threshold,
willFail: yesVotes + remaining < config.threshold
};
}
Proposal Expiration Warning
Proposals expire after ~1 week. Show a warning when expiration is near:
const WEEK_IN_LEDGERS = 120_960;
const LEDGER_TIME_SECONDS = 5;
function getExpirationWarning(proposalLedger: number, currentLedger: number) {
const ledgersRemaining = (proposalLedger + WEEK_IN_LEDGERS) - currentLedger;
const secondsRemaining = ledgersRemaining * LEDGER_TIME_SECONDS;
const hoursRemaining = secondsRemaining / 3600;
if (hoursRemaining < 24) {
return `Expires in ${Math.round(hoursRemaining)} hours`;
} else if (hoursRemaining < 72) {
return `Expires in ${Math.round(hoursRemaining / 24)} days`;
}
return null;
}
Error Handling
Map contract errors to user-friendly messages:
const ERROR_MESSAGES: Record<number, string> = {
2: 'Contract not initialized',
5: 'Invalid owner configuration',
6: 'Invalid threshold value',
8: 'You are not an owner of this contract',
9: 'Proposal not found or expired',
10: 'You have already voted on this proposal',
11: 'Proposal is no longer pending',
12: 'Contract invocation failed',
14: 'No changes in proposed configuration',
15: 'Only the proposer can perform this action',
19: 'Use invoke() for contract calls',
22: 'Invalid upgrade hash',
23: 'Target address is not a contract',
24: 'Function name cannot be empty'
};
function handleContractError(error: any) {
// Extract error code from Soroban error
const code = extractErrorCode(error);
return ERROR_MESSAGES[code] || 'Unknown error occurred';
}
Security Best Practices
-
Never store owner private keys in your dApp - Use wallet integrations (Freighter, Albedo, etc.)
-
Validate proposals client-side before submission to catch errors early
-
Show full proposal details before asking users to vote
-
Implement confirmation dialogs for irreversible actions
-
Monitor for config changes - Alert users when owner set changes