Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,21 @@ Soroban contract that actually executes airdrops lives in a separate repository
`contract_airdrop_id` field via `PATCH /api/v1/airdrops/:id` to link the REST
record with indexer-observed on-chain state.

#### Airdrop Status State Machine

Airdrops strictly enforce a status state machine across transitions:

| Current Status | Allowed Target Statuses | Notes |
|---|---|---|
| `draft` | `executing`, `cancelled`, `expired` | Initial creation state. Mutations like updating `expiry_ledger` are allowed only in `draft`. |
| `executing` | `completed`, `failed`, `cancelled`, `expired` | In-flight distribution state. |
| `completed` | *(none)* | Terminal status. Rejects further transitions and cancellations. |
| `failed` | *(none)* | Terminal status. |
| `cancelled` | *(none)* | Terminal status. |
| `expired` | *(none)* | Terminal status set when `expiry_ledger` has passed. |

Attempting an illegal transition or attempting to cancel/modify an immutable terminal airdrop returns a `409 INVALID_STATE_TRANSITION` error. Attempting to update `expiry_ledger` once an airdrop is no longer `draft` returns a `400 VALIDATION_ERROR`.

The indexer (`src/indexer/eventStore.js`) independently tracks on-chain
airdrop events (`airdrop_created`, `recipient_added`, `token_claimed`,
`airdrop_expired`) keyed by the contract's own airdrop ID. Until the linking
Expand Down
1 change: 1 addition & 0 deletions src/errors/AppError.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const ERROR_CODES = Object.freeze({
// ── Airdrops ─────────────────────────────────────────────────────────
AIRDROP_NOT_FOUND: { statusCode: 404 },
AIRDROP_NOT_INDEXED: { statusCode: 404 },
INVALID_STATE_TRANSITION: { statusCode: 409 },
RECIPIENT_LIMIT_EXCEEDED: { statusCode: 400 },
CSV_INVALID_ENCODING: { statusCode: 400 },
CSV_MISSING_COLUMNS: { statusCode: 400 },
Expand Down
10 changes: 8 additions & 2 deletions src/routes/airdrops.js
Original file line number Diff line number Diff line change
Expand Up @@ -371,13 +371,19 @@ router.post(
validateRouteIdParams,
async (req, res, next) => {
try {
const airdrop = await airdropsService.cancel(req.params.id);
const airdrop = await airdropsService.get(req.params.id);
if (!airdrop) {
return next(
new AppError("AIRDROP_NOT_FOUND", "Airdrop not found", 404),
);
}
return res.json(airdrop);

if (airdrop.status === 'cancelled') {
return res.json(airdrop);
}

const cancelled = await airdropsService.cancel(req.params.id);
return res.json(cancelled);
} catch (err) {
logger.error("Cancel airdrop error", { error: err.message });
return next(err);
Expand Down
67 changes: 67 additions & 0 deletions src/services/airdrops.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,60 @@
const crypto = require('crypto');
const cache = require('./cache');
const logger = require('../logger');
const AppError = require('../errors/AppError');
const { Horizon } = require('@stellar/stellar-sdk');
const config = require('../config');

const IDS_KEY = 'airdrops:ids';

// State machine for airdrop lifecycle transitions (issue #87)
const ALLOWED_TRANSITIONS = Object.freeze({
draft: Object.freeze(['executing', 'cancelled', 'expired']),
executing: Object.freeze(['completed', 'failed', 'cancelled', 'expired']),
completed: Object.freeze([]),
failed: Object.freeze([]),
cancelled: Object.freeze([]),
expired: Object.freeze([]),
});


function assertTransition(currentStatus, nextStatus) {
const allowed = ALLOWED_TRANSITIONS[currentStatus];
if (!allowed || !allowed.includes(nextStatus)) {
throw new AppError(
'INVALID_STATE_TRANSITION',
`Cannot transition airdrop from status "${currentStatus}" to "${nextStatus}"`,
409,
{ current_status: currentStatus, target_status: nextStatus },
);
}
}

async function transitionTo(id, nextStatus) {
const airdrop = await get(id);
if (!airdrop) return null;
assertTransition(airdrop.status, nextStatus);
const updated = {
...airdrop,
status: nextStatus,
updated_at: new Date().toISOString(),
};
await cache.set(airdropKey(id), updated);
return updated;
}

async function markExecuting(id) {
return transitionTo(id, 'executing');
}

async function markCompleted(id) {
return transitionTo(id, 'completed');
}

async function markFailed(id) {
return transitionTo(id, 'failed');
}

function airdropKey(id) {
return `airdrop:${id}`;
}
Expand Down Expand Up @@ -168,6 +217,16 @@ async function update(id, data) {
if (!airdrop) return null;

const { name, description, expiry_ledger, contract_airdrop_id } = data;

if (airdrop.status !== 'draft' && expiry_ledger !== undefined && expiry_ledger !== airdrop.expiry_ledger) {
throw new AppError(
'VALIDATION_ERROR',
`Cannot update expiry_ledger for airdrop in "${airdrop.status}" status (only draft airdrops can be updated)`,
400,
{ current_status: airdrop.status, field: 'expiry_ledger' },
);
}

const updated = {
...airdrop,
name: name !== undefined ? name : airdrop.name,
Expand Down Expand Up @@ -201,6 +260,8 @@ async function cancel(id) {
return airdrop;
}

assertTransition(airdrop.status, 'cancelled');

const updated = {
...airdrop,
status: 'cancelled',
Expand Down Expand Up @@ -255,10 +316,16 @@ module.exports = {
update,
remove,
cancel,
transitionTo,
markExecuting,
markCompleted,
markFailed,
addRecipients,
listRecipients,
getCurrentLedger,
scanIds,
markExpired,
TERMINAL_STATUSES,
ALLOWED_TRANSITIONS,
assertTransition,
};
86 changes: 86 additions & 0 deletions test/airdrops-service.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ jest.mock('@stellar/stellar-sdk', () => ({
}));

const airdropsService = require('../src/services/airdrops');
const AppError = require('../src/errors/AppError');

beforeEach(() => {
mockStore.clear();
Expand Down Expand Up @@ -536,6 +537,91 @@ describe('airdrops service', () => {
});
});

describe('State machine & status transitions (#87)', () => {
test('assertTransition permits valid transitions', () => {
expect(() => airdropsService.assertTransition('draft', 'executing')).not.toThrow();
expect(() => airdropsService.assertTransition('draft', 'cancelled')).not.toThrow();
expect(() => airdropsService.assertTransition('draft', 'expired')).not.toThrow();
expect(() => airdropsService.assertTransition('executing', 'completed')).not.toThrow();
expect(() => airdropsService.assertTransition('executing', 'failed')).not.toThrow();
expect(() => airdropsService.assertTransition('executing', 'cancelled')).not.toThrow();
expect(() => airdropsService.assertTransition('executing', 'expired')).not.toThrow();
});

test('assertTransition throws AppError (409) for invalid transitions', () => {
// From draft
expect(() => airdropsService.assertTransition('draft', 'completed')).toThrow(AppError);
expect(() => airdropsService.assertTransition('draft', 'draft')).toThrow(AppError);

// From executing
expect(() => airdropsService.assertTransition('executing', 'draft')).toThrow(AppError);

// From completed (terminal)
expect(() => airdropsService.assertTransition('completed', 'executing')).toThrow(AppError);
expect(() => airdropsService.assertTransition('completed', 'cancelled')).toThrow(AppError);

// From failed (terminal)
expect(() => airdropsService.assertTransition('failed', 'completed')).toThrow(AppError);
expect(() => airdropsService.assertTransition('failed', 'executing')).toThrow(AppError);

// From cancelled (terminal)
expect(() => airdropsService.assertTransition('cancelled', 'executing')).toThrow(AppError);
expect(() => airdropsService.assertTransition('cancelled', 'draft')).toThrow(AppError);

// From expired (terminal)
expect(() => airdropsService.assertTransition('expired', 'executing')).toThrow(AppError);
});

test('transitionTo updates status correctly', async () => {
const airdrop = await airdropsService.create({
name: 'Drop State', asset: 'USDC', asset_issuer: 'GI', total_amount: '100', expiry_ledger: 1000,
});

const executing = await airdropsService.markExecuting(airdrop.id);
expect(executing.status).toBe('executing');

const completed = await airdropsService.markCompleted(airdrop.id);
expect(completed.status).toBe('completed');
});

test('markFailed transitions executing airdrop to failed', async () => {
const airdrop = await airdropsService.create({
name: 'Drop State 2', asset: 'USDC', asset_issuer: 'GI', total_amount: '100', expiry_ledger: 1000,
});

await airdropsService.markExecuting(airdrop.id);
const failed = await airdropsService.markFailed(airdrop.id);
expect(failed.status).toBe('failed');
});

test('cancel throws 409 when airdrop is in non-cancellable status (completed)', async () => {
const airdrop = await airdropsService.create({
name: 'Drop Completed', asset: 'USDC', asset_issuer: 'GI', total_amount: '100', expiry_ledger: 1000,
});
await airdropsService.markExecuting(airdrop.id);
await airdropsService.markCompleted(airdrop.id);

await expect(async () => {
await airdropsService.cancel(airdrop.id);
}).rejects.toThrow(AppError);
});

test('update rejects expiry_ledger edit when airdrop is not in draft status', async () => {
const airdrop = await airdropsService.create({
name: 'Drop Update Expiry', asset: 'USDC', asset_issuer: 'GI', total_amount: '100', expiry_ledger: 1000,
});
await airdropsService.markExecuting(airdrop.id);

await expect(async () => {
await airdropsService.update(airdrop.id, { expiry_ledger: 2000 });
}).rejects.toThrow(AppError);

// Other fields can still be updated
const updated = await airdropsService.update(airdrop.id, { name: 'Renamed Executing Drop' });
expect(updated.name).toBe('Renamed Executing Drop');
});
});

describe('TERMINAL_STATUSES', () => {
test('contains expected terminal statuses', async () => {
expect(airdropsService.TERMINAL_STATUSES.has('completed')).toBe(true);
Expand Down
21 changes: 21 additions & 0 deletions test/airdrops.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,27 @@ describe('POST /api/v1/airdrops/:id/cancel', () => {
expect(cancelResponse.body.status).toBe('cancelled');
});

test('returns 409 conflict when trying to cancel an airdrop in a non-cancellable terminal status', async () => {
const createResponse = await request(app)
.post('/api/v1/airdrops')
.send({
name: 'Test Airdrop 2',
asset: 'USDC',
asset_issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA',
total_amount: 100,
expiry_ledger: 123456,
});

// Manually set status to completed in mockStore
const stored = mockStore.get(`airdrop:${createResponse.body.id}`);
mockStore.set(`airdrop:${createResponse.body.id}`, { ...stored, status: 'completed' });

const cancelResponse = await request(app).post(`/api/v1/airdrops/${createResponse.body.id}/cancel`);
expect(cancelResponse.status).toBe(409);
expect(cancelResponse.body.error.code).toBe('INVALID_STATE_TRANSITION');
expect(cancelResponse.body.error.message).toContain('completed');
});

test('idempotent cancellation', async () => {
const createResponse = await request(app)
.post('/api/v1/airdrops')
Expand Down
1 change: 1 addition & 0 deletions test/errorCodes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ describe('error code registry', () => {
'ALERT_NOT_FOUND',
'API_KEY_NOT_FOUND',
'AIRDROP_NOT_INDEXED',
'INVALID_STATE_TRANSITION',
]) {
expect(AppError.isKnownCode(code)).toBe(true);
}
Expand Down