This guide covers testing smart contracts, debugging failed transactions, and resolving common issues.
# Install test dependencies
npm install --save-dev proton-tsc @proton/vert jest @types/jest ts-jestNote: proton-tsc provides types and includes proton-asc compiler. Tests use @proton/vert for blockchain simulation.
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/tests/**/*.test.ts'],
moduleFileExtensions: ['ts', 'js'],
};// tests/mycontract.test.ts
import { Blockchain, nameToBigInt, expectToThrow } from '@proton/vert';
describe('MyContract', () => {
let blockchain: Blockchain;
let contract: any;
let user1: any;
let user2: any;
beforeAll(async () => {
// Initialize blockchain
blockchain = new Blockchain();
// Create accounts
[user1, user2] = blockchain.createAccounts('user1', 'user2');
// Deploy contract (reads .wasm and .abi from the target dir)
contract = blockchain.createContract('mycontract', 'assembly/target/mycontract.contract');
});
beforeEach(async () => {
// Reset state between tests
blockchain.resetTables();
});
test('should store value', async () => {
await contract.actions.store(['user1', 'Hello World']).send('user1@active');
// Check table
const rows = contract.tables.mydata().getTableRows();
expect(rows.length).toBe(1);
expect(rows[0].value).toBe('Hello World');
});
test('should fail without auth', async () => {
await expectToThrow(
contract.actions.store(['user1', 'Hello']).send('user2@active'),
'missing required authority user1' // vert's wording; a real node says "missing authority of user1"
);
});
test('should validate input', async () => {
await expectToThrow(
contract.actions.store(['user1', '']).send('user1@active'),
'Value cannot be empty'
);
});
});test('should handle incoming transfer', async () => {
// Setup token contract
// proton-tsc ships a prebuilt eosio.token (abi + wasm) for tests
const eosioToken = blockchain.createContract('eosio.token', 'node_modules/proton-tsc/external/eosio.token/eosio.token');
// Create and issue tokens
await eosioToken.actions.create(['eosio.token', '1000000.0000 XPR']).send();
await eosioToken.actions.issue(['user1', '1000.0000 XPR', 'initial']).send();
// Transfer to contract
await eosioToken.actions.transfer([
'user1', 'mycontract', '100.0000 XPR', 'deposit'
]).send('user1@active');
// Verify contract received and processed it
const deposits = contract.tables.deposits().getTableRows();
expect(deposits[0].amount).toBe('100.0000 XPR');
});test('should expire after duration', async () => {
// Create challenge
await contract.actions.create(['user1', '100.0000 XPR', 1, 300]).send('user1@active');
// Advance blockchain time by 5 minutes
blockchain.addTime(TimePointSec.from(300)); // import { TimePointSec } from '@greymass/eosio'
// Now it should be resolvable
await contract.actions.resolve([1]).send('resolver@active');
const challenge = contract.tables.challenges().getTableRow(1n);
expect(challenge.status).toBe(2); // RESOLVED
});# Switch to testnet
proton chain:set proton-test
# Create testnet account (if needed; prompts for email verification)
proton account:create mytestaccount
# Or create extra accounts from one you already control: no email, and no private
# key is printed when you pass -k (best for agent-driven rehearsals)
proton account:create-funded mytestuser2 -c mytestaccount -k PUB_K1_xxxxx -o backupowner
# Get test tokens (1,000 test XPR per account per 24 h)
proton faucet:claim XPR mytestaccount# Build
npm run build
# Deploy (contract:set asks "Continue? (y/N)" and has no --yes flag)
echo y | proton contract:set mytestaccount ./assembly/target
# Confirm the WASM landed: an all-zero code_hash means only the ABI was set
curl -s -X POST https://api-xprnetwork-test.saltant.io/v1/chain/get_code_hash \
-d '{"account_name":"mytestaccount"}'
# Initialize
proton action mytestaccount init '{"owner":"mytestaccount"}' mytestaccountDeploy to testnet early, not only after the unit tests pass. The local test VM does not check that the contract's imported intrinsics match the node's signatures (
@proton/vert0.3.24 implementsget_code_hashwith the same 3 parameters asproton-tsc, so that bug is invisible locally). On a testnet rehearsal (2026-09-24), a contract passed 15/15 local tests and was then rejected atsetcodewithwrong type for imported function get_code_hash(seesmart-contracts.md→ Known SDK bug). A deploy on the first day of development catches this.
- Contract deploys without errors, and
get_code_hashis non-zero - Init action succeeds
- All actions work as expected
- Table data persists correctly
- Inline actions execute
- Notifications fire to other contracts
- Error messages are clear
- Oracle callbacks (
rng::receiverand) arrive, and a late or cancelled callback is ignored - Tested with an account that has contract code as well as a plain WebAuth account
Rehearse at full size. Some failures only show up at the real volume. In a 3,333-asset mint rehearsal on testnet (September 2026), none of these appeared in small runs: the minter's free NET ran out after ~80 transactions, a monitor that read one
get_table_rowspage missed 2,300 rows, and one CLI push had an ambiguous result. Run the whole job on testnet with the real data, the real batch sizes and the same scripts, including one deliberate interruption, before you run it on mainnet.
When a transaction fails, the error message contains clues:
Error: assertion failure with message: Insufficient balance
The message after "with message:" is from your contract's check() calls.
| Error | Cause | Solution |
|---|---|---|
missing authority of X (chain) / missing required authority X (vert) |
Wrong authorization | Use correct account in authorization. Match both wordings in shared error parsing |
assertion failure with message: ... |
Contract validation failed | Check the condition in your contract |
account does not exist |
Invalid account name | Verify account exists on chain |
table not found |
Querying non-existent table | Check contract is deployed, table name correct |
your requireGet() message (C++ contracts: unable to find key) |
Row doesn't exist | Use get() with a null check instead of requireGet() |
transaction has expired, expiration is ... (expired_tx_exception) |
Transaction took too long | Increase expireSeconds |
duplicate transaction <id> |
Same tx submitted twice | Add unique data or wait |
... has insufficient objective cpu resources / ... was executing for too long (tx_cpu_usage_exceeded) |
Not enough CPU | Stake more CPU or optimize contract |
account X has insufficient ram; needs N bytes has M bytes (ram_usage_exceeded) |
Not enough RAM | Buy more RAM |
# Check transaction details
proton transaction:get TRANSACTION_ID
# Check account resources
proton account myaccount
# Check table data
proton table mycontract mytable
# Execute an action (no flags; output includes the transaction id)
proton action mycontract myaction '{}' myaccount# Get recent actions for account
curl "https://proton.eosusa.io/v2/history/get_actions?account=mycontract&limit=10"
# Get specific transaction
curl "https://proton.eosusa.io/v2/history/get_transaction?id=TX_ID"
# Filter by action name
curl "https://proton.eosusa.io/v2/history/get_actions?account=mycontract&filter=mycontract:myaction"In contracts, use print() for debugging (visible in local tests, not on mainnet):
import { print, printi, printui } from 'proton-tsc';
@action("debug")
debugAction(value: u64): void {
print("Starting debug action\n");
printui(value);
print("\n");
// Your logic here
const result = value * 2;
print("Result: ");
printui(result);
print("\n");
}try {
const result = await session.transact({
actions: [/* ... */]
}, { broadcast: true });
} catch (error: any) {
console.error('Transaction failed:', error);
// Parse the error
if (error.message.includes('assertion failure')) {
const match = error.message.match(/assertion failure with message: (.+)/);
const contractError = match ? match[1] : 'Unknown contract error';
showUserError(contractError);
} else if (error.message.includes('User cancelled')) {
// User rejected in wallet
showUserError('Transaction cancelled');
} else if (error.message.includes('expired')) {
showUserError('Transaction expired, please try again');
} else {
showUserError('Transaction failed: ' + error.message);
}
}async function debugTableQuery(code: string, table: string, scope: string) {
console.log(`Querying: ${code}::${table} (scope: ${scope})`);
try {
const result = await rpc.get_table_rows({
code,
scope,
table,
json: true,
limit: 10
});
console.log('Result:', JSON.stringify(result, null, 2));
console.log(`Found ${result.rows.length} rows, more: ${result.more}`);
return result.rows;
} catch (error) {
console.error('Query failed:', error);
// Check if table exists
const abi = await rpc.get_abi(code);
const tableExists = abi.abi?.tables?.some(t => t.name === table);
console.log(`Table "${table}" exists in ABI: ${tableExists}`);
throw error;
}
}// Debug session state
function debugSession(session: any) {
console.log('Session debug:');
console.log(' Actor:', session?.auth?.actor);
console.log(' Permission:', session?.auth?.permission);
console.log(' Chain ID:', session?.chainId);
console.log(' Link type:', session?.link?.walletType);
// Check if session is valid
if (!session) {
console.error('Session is null/undefined');
} else if (!session.auth) {
console.error('Session has no auth');
} else if (!session.transact) {
console.error('Session missing transact method');
}
}For any transaction, check the block explorer:
https://explorer.xprnetwork.org/tx/TRANSACTION_ID
This shows:
- All actions in the transaction
- Inline actions triggered
- Table deltas (data changes)
- CPU/NET/RAM usage
- Error messages (if failed)
# Account overview
https://explorer.xprnetwork.org/account/ACCOUNT_NAME
# Contract ABI
https://explorer.xprnetwork.org/account/ACCOUNT_NAME?tab=contract
# Recent transactions
https://explorer.xprnetwork.org/account/ACCOUNT_NAME?tab=transactions
# Table data
https://explorer.xprnetwork.org/account/ACCOUNT_NAME?tab=tables#!/bin/bash
# test-contract.sh
set -e
CONTRACT="mycontract"
TESTNET_ACCOUNT="mytest"
echo "=== Building contract ==="
npm run build
echo "=== Deploying to testnet ==="
proton chain:set proton-test
echo y | proton contract:set $TESTNET_ACCOUNT ./assembly/target
# contract:set can set the ABI, fail the WASM, and still exit 0: check the code hash
if curl -s -X POST https://api-xprnetwork-test.saltant.io/v1/chain/get_code_hash \
-d '{"account_name":"'$TESTNET_ACCOUNT'"}' | grep -q '"code_hash":"0\{64\}"'; then
echo "✗ No WASM on $TESTNET_ACCOUNT (ABI only)"
exit 1
fi
echo "=== Running tests ==="
# Test 1: Initialize
echo "Test 1: Init"
proton action $CONTRACT init '{"owner":"'$TESTNET_ACCOUNT'"}' $TESTNET_ACCOUNT
# Test 2: Store value
echo "Test 2: Store"
proton action $CONTRACT store '{"owner":"'$TESTNET_ACCOUNT'","value":"test123"}' $TESTNET_ACCOUNT
# Test 3: Verify table
echo "Test 3: Verify"
RESULT=$(proton table $CONTRACT mydata)
if echo "$RESULT" | grep -q "test123"; then
echo "✓ Value stored correctly"
else
echo "✗ Value not found in table"
exit 1
fi
# Test 4: Error case (should fail)
echo "Test 4: Error handling"
if proton action $CONTRACT store '{"owner":"'$TESTNET_ACCOUNT'","value":""}' $TESTNET_ACCOUNT 2>&1 | grep -q "assertion failure"; then
echo "✓ Empty value correctly rejected"
else
echo "✗ Should have rejected empty value"
exit 1
fi
echo "=== All tests passed ==="// In contract, time-intensive operations
const startTime = currentTimeMs();
// ... expensive operation ...
const elapsed = currentTimeMs() - startTime;
print(`Operation took ${elapsed}ms\n`);// BAD: Iterating entire table
let total: u64 = 0;
let cursor = this.dataTable.first();
while (cursor) {
total += cursor.value;
cursor = this.dataTable.next(cursor);
}
// GOOD: Use a secondary index for point lookups
// getBySecondaryU64(value, indexPosition) returns ONE row (or null), not an array;
// for ranges, use lowerBound/upperBound on the index and walk with next()
const row = this.dataTable.getBySecondaryU64(owner.N, 0); // 0 = first @secondary index
const total: u64 = row ? row.value : 0;There is no in-contract RAM query in proton-tsc. Measure RAM per action from the outside: the explorer shows the RAM delta per action, and proton table output shows the payer of each row. Keep rows small (fixed-width fields, no unbounded strings) and let the caller pay (store(row, payer)).
# .github/workflows/test.yml
name: Test Contract
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm install
- name: Build contract
run: npm run build
- name: Run tests
run: npm test
- name: Upload artifacts
uses: actions/upload-artifact@v3
with:
name: contract-build
path: assembly/target/When something doesn't work:
- Check the error message - Read it carefully
- Verify authorization - Is the right account signing?
- Check table state - Is the data what you expect?
- Test on testnet first - Never debug on mainnet
- Check ABI - Is the contract deployed with current ABI?
- Check resources - Does account have RAM/CPU/NET?
- Check block explorer - What does the transaction show?
- Add logging - Use
print()in tests - Isolate the issue - Test one thing at a time
- Check timestamps - Is time-dependent logic correct?