From b6e57379732776a7b9530c3908521f124d05ca6d Mon Sep 17 00:00:00 2001 From: Patrick - ghzhost Date: Fri, 11 Sep 2026 12:35:38 +0000 Subject: [PATCH] feat(airdrops): enforce status lifecycle state machine and transitions (#87) --- README.md | 15 ++++++ src/errors/AppError.js | 1 + src/routes/airdrops.js | 10 +++- src/services/airdrops.js | 67 +++++++++++++++++++++++++++ test/airdrops-service.test.js | 86 +++++++++++++++++++++++++++++++++++ test/airdrops.test.js | 21 +++++++++ test/errorCodes.test.js | 1 + 7 files changed, 199 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6273737..c915cc4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/errors/AppError.js b/src/errors/AppError.js index c581063..ce15471 100644 --- a/src/errors/AppError.js +++ b/src/errors/AppError.js @@ -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 }, diff --git a/src/routes/airdrops.js b/src/routes/airdrops.js index 1028ba4..3b76f57 100644 --- a/src/routes/airdrops.js +++ b/src/routes/airdrops.js @@ -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); diff --git a/src/services/airdrops.js b/src/services/airdrops.js index bb512b1..59f5cb2 100644 --- a/src/services/airdrops.js +++ b/src/services/airdrops.js @@ -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}`; } @@ -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, @@ -201,6 +260,8 @@ async function cancel(id) { return airdrop; } + assertTransition(airdrop.status, 'cancelled'); + const updated = { ...airdrop, status: 'cancelled', @@ -255,10 +316,16 @@ module.exports = { update, remove, cancel, + transitionTo, + markExecuting, + markCompleted, + markFailed, addRecipients, listRecipients, getCurrentLedger, scanIds, markExpired, TERMINAL_STATUSES, + ALLOWED_TRANSITIONS, + assertTransition, }; diff --git a/test/airdrops-service.test.js b/test/airdrops-service.test.js index d710c84..349d5d2 100644 --- a/test/airdrops-service.test.js +++ b/test/airdrops-service.test.js @@ -143,6 +143,7 @@ jest.mock('@stellar/stellar-sdk', () => ({ })); const airdropsService = require('../src/services/airdrops'); +const AppError = require('../src/errors/AppError'); beforeEach(() => { mockStore.clear(); @@ -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); diff --git a/test/airdrops.test.js b/test/airdrops.test.js index 34f1248..811d2f3 100644 --- a/test/airdrops.test.js +++ b/test/airdrops.test.js @@ -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') diff --git a/test/errorCodes.test.js b/test/errorCodes.test.js index 9854472..7a3ded1 100644 --- a/test/errorCodes.test.js +++ b/test/errorCodes.test.js @@ -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); }