diff --git a/cashtab/src/airdrop/__tests__/index.test.js b/cashtab/src/airdrop/__tests__/index.test.js
deleted file mode 100644
--- a/cashtab/src/airdrop/__tests__/index.test.js
+++ /dev/null
@@ -1,95 +0,0 @@
-// Copyright (c) 2024 The Bitcoin developers
-// Distributed under the MIT software license, see the accompanying
-// file COPYING or http://www.opensource.org/licenses/mit-license.php.
-
-import { getAirdropTx, getEqualAirdropTx } from 'airdrop';
-import vectors from 'airdrop/fixtures/vectors';
-
-describe('Cashtab airdrop methods', () => {
-    describe('Gets csv list of airdrop recipients address and amounts for a standard (prorata) airdrop', () => {
-        const { expectedReturns, expectedErrors } = vectors.getAirdropTx;
-        expectedReturns.forEach(expectedReturn => {
-            const {
-                description,
-                tokenUtxos,
-                excludedAddresses,
-                airdropAmountXec,
-                minTokenQtyUndecimalized,
-                returned,
-            } = expectedReturn;
-            it(`getAirdropTx: ${description}`, () => {
-                expect(
-                    getAirdropTx(
-                        tokenUtxos,
-                        excludedAddresses,
-                        airdropAmountXec,
-                        minTokenQtyUndecimalized,
-                    ),
-                ).toEqual(returned);
-            });
-        });
-        expectedErrors.forEach(expectedError => {
-            const {
-                description,
-                tokenUtxos,
-                excludedAddresses,
-                airdropAmountXec,
-                minTokenQtyUndecimalized,
-                err,
-            } = expectedError;
-            it(`getAirdropTx throws error for: ${description}`, () => {
-                expect(() =>
-                    getAirdropTx(
-                        tokenUtxos,
-                        excludedAddresses,
-                        airdropAmountXec,
-                        minTokenQtyUndecimalized,
-                    ),
-                ).toThrow(err);
-            });
-        });
-    });
-    describe('Gets csv list of airdrop recipients address and amounts for an equal airdrop', () => {
-        const { expectedReturns, expectedErrors } = vectors.getEqualAirdropTx;
-        expectedReturns.forEach(expectedReturn => {
-            const {
-                description,
-                tokenUtxos,
-                excludedAddresses,
-                airdropAmountXec,
-                minTokenQtyUndecimalized,
-                returned,
-            } = expectedReturn;
-            it(`getEqualAirdropTx: ${description}`, () => {
-                expect(
-                    getEqualAirdropTx(
-                        tokenUtxos,
-                        excludedAddresses,
-                        airdropAmountXec,
-                        minTokenQtyUndecimalized,
-                    ),
-                ).toEqual(returned);
-            });
-        });
-        expectedErrors.forEach(expectedError => {
-            const {
-                description,
-                tokenUtxos,
-                excludedAddresses,
-                airdropAmountXec,
-                minTokenQtyUndecimalized,
-                err,
-            } = expectedError;
-            it(`getEqualAirdropTx throws error for: ${description}`, () => {
-                expect(() =>
-                    getEqualAirdropTx(
-                        tokenUtxos,
-                        excludedAddresses,
-                        airdropAmountXec,
-                        minTokenQtyUndecimalized,
-                    ),
-                ).toThrow(err);
-            });
-        });
-    });
-});
diff --git a/cashtab/src/airdrop/__tests__/index.test.ts b/cashtab/src/airdrop/__tests__/index.test.ts
new file mode 100644
--- /dev/null
+++ b/cashtab/src/airdrop/__tests__/index.test.ts
@@ -0,0 +1,283 @@
+// Copyright (c) 2024 The Bitcoin developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+import {
+    getAirdropTx,
+    getEqualAirdropTx,
+    getAgoraHolders,
+    getP2pkhHolders,
+} from 'airdrop';
+import { tokenUtxos, p2pkhHoldersTokenUtxos } from 'airdrop/fixtures/mocks';
+import vectors from 'airdrop/fixtures/vectors';
+import { initWasm } from 'ecash-lib';
+import {
+    MockAgora,
+    MockChronikClient,
+} from '../../../../modules/mock-chronik-client/dist';
+import {
+    agoraOfferCachetAlphaOne,
+    agoraOfferCachetBetaOne,
+} from 'components/Agora/fixtures/mocks';
+import {
+    ChronikClient,
+    OutPoint,
+    TokenType,
+    Token,
+    Utxo,
+    TokenIdUtxos,
+} from 'chronik-client';
+import { Agora } from 'ecash-agora';
+
+const CACHET_TOKEN_ID =
+    'aed861a31b96934b88c0252ede135cb9700d7649f69191235087a3030e553cb1';
+
+const mockTokenId = '00'.repeat(32);
+const mockOutpoint: OutPoint = { txid: '11'.repeat(32), outIdx: 0 };
+const mockTokenType: TokenType = {
+    protocol: 'SLP',
+    type: 'SLP_TOKEN_TYPE_FUNGIBLE',
+    number: 1,
+};
+const mockToken: Token = {
+    tokenId: mockTokenId,
+    tokenType: mockTokenType,
+    amount: '100',
+    isMintBaton: false,
+};
+// Mock p2pkh holder
+const mockHolderP2pkh: Utxo = {
+    outpoint: mockOutpoint,
+    blockHeight: 800000,
+    isCoinbase: false,
+    script: '76a91400cd590bfb90b6dc1725530d6c36c78b88ddb60888ac',
+    value: 546,
+    isFinal: true,
+    token: mockToken,
+};
+// Mock p2sh holder (agora offer)
+const mockHolderP2sh: Utxo = {
+    outpoint: mockOutpoint,
+    blockHeight: 800000,
+    isCoinbase: false,
+    script: 'a914cfbe04a8a5fa04a032977138d8099862d5b40f7687',
+    value: 546,
+    isFinal: true,
+    token: mockToken,
+};
+// mock p2pk holder (non-standard)
+const mockHolderP2pk = {
+    ...mockHolderP2pkh,
+    script: '047fa64f6874fb7213776b24c40bc915451b57ef7f17ad7b982561f99f7cdc7010d141b856a092ee169c5405323895e1962c6b0d7c101120d360164c9e4b3997bd',
+};
+// Mock a TokenUtxos return
+const mockedTokenUtxos: TokenIdUtxos = {
+    tokenId: mockTokenId,
+    utxos: [
+        mockHolderP2pkh,
+        mockHolderP2sh,
+        { ...mockHolderP2pkh, token: { ...mockToken, amount: '500' } },
+    ],
+};
+describe('Cashtab airdrop methods', () => {
+    beforeAll(async () => {
+        await initWasm();
+    });
+    describe('getAgoraHolders()', () => {
+        it('We can get a p2pkh hash and the qty of token this hash listed from a single agora offer', async () => {
+            const mockAgora = new MockAgora();
+            const mockAgoraOffers = [agoraOfferCachetAlphaOne];
+            mockAgora.setActiveOffersByTokenId(
+                CACHET_TOKEN_ID,
+                mockAgoraOffers,
+            );
+            expect(
+                await getAgoraHolders(
+                    mockAgora as unknown as Agora,
+                    CACHET_TOKEN_ID,
+                ),
+            ).toStrictEqual(
+                new Map([
+                    [
+                        '76a91403b830e4b9dce347f3495431e1f9d1005f4b420488ac',
+                        10000n,
+                    ],
+                ]),
+            );
+        });
+        it('We can get multiple p2pkh hashes and the qty of tokens they each listed from a multiple agora offers created by different public keys', async () => {
+            const mockAgora = new MockAgora();
+            const mockAgoraOffers = [
+                agoraOfferCachetAlphaOne,
+                agoraOfferCachetBetaOne,
+            ];
+            mockAgora.setActiveOffersByTokenId(
+                CACHET_TOKEN_ID,
+                mockAgoraOffers,
+            );
+            expect(
+                await getAgoraHolders(
+                    mockAgora as unknown as Agora,
+                    CACHET_TOKEN_ID,
+                ),
+            ).toStrictEqual(
+                new Map([
+                    [
+                        '76a91403b830e4b9dce347f3495431e1f9d1005f4b420488ac',
+                        10000n,
+                    ],
+                    [
+                        '76a914f208ef75eb0dd778ea4540cbd966a830c7b94bb088ac',
+                        30000n,
+                    ],
+                ]),
+            );
+        });
+    });
+    describe('getP2pkhHolders', () => {
+        it('We can get a map of all p2pkh holders and their balance (excluding p2sh and other non-p2pkh scripts)', async () => {
+            const mockedChronik = new MockChronikClient();
+            mockedChronik.setUtxosByTokenId(mockTokenId, [
+                ...mockedTokenUtxos.utxos,
+                mockHolderP2pk,
+            ]);
+            expect(
+                await getP2pkhHolders(
+                    mockedChronik as unknown as ChronikClient,
+                    mockTokenId,
+                ),
+            ).toStrictEqual(
+                new Map([
+                    [
+                        '76a91400cd590bfb90b6dc1725530d6c36c78b88ddb60888ac',
+                        600n,
+                    ],
+                    // Note we DO NOT get the p2sh offer or p2pk
+                ]),
+            );
+        });
+        it('We can get a map of all p2pkh holders and their balance for the tokenUtxos mock (i.e. we exclude a holder of only the mint baton)', async () => {
+            const mockedChronik = new MockChronikClient();
+            mockedChronik.setUtxosByTokenId(
+                tokenUtxos.tokenId,
+                tokenUtxos.utxos,
+            );
+            expect(
+                await getP2pkhHolders(
+                    mockedChronik as unknown as ChronikClient,
+                    tokenUtxos.tokenId,
+                ),
+            ).toStrictEqual(p2pkhHoldersTokenUtxos);
+        });
+        it('We get an empty map if the token has no utxos', async () => {
+            const mockedChronik = new MockChronikClient();
+            mockedChronik.setUtxosByTokenId(mockTokenId, []);
+            expect(
+                await getP2pkhHolders(
+                    mockedChronik as unknown as ChronikClient,
+                    mockTokenId,
+                ),
+            ).toStrictEqual(new Map());
+        });
+        it('Throws chronik error if there is an error making the chronik query', async () => {
+            const mockedChronik = new MockChronikClient();
+            mockedChronik.setUtxosByTokenId(
+                mockTokenId,
+                new Error('some chronik error'),
+            );
+            await expect(
+                getP2pkhHolders(
+                    mockedChronik as unknown as ChronikClient,
+                    mockTokenId,
+                ),
+            ).rejects.toEqual(new Error('some chronik error'));
+        });
+    });
+    describe('Gets csv list of airdrop recipients address and amounts for a standard (prorata) airdrop', () => {
+        const { expectedReturns, expectedErrors } = vectors.getAirdropTx;
+        expectedReturns.forEach(expectedReturn => {
+            const {
+                description,
+                p2pkhHoldersTokenUtxos,
+                excludedAddresses,
+                airdropAmountXec,
+                minTokenQtyUndecimalized,
+                returned,
+            } = expectedReturn;
+
+            it(`getAirdropTx: ${description}`, () => {
+                expect(
+                    getAirdropTx(
+                        p2pkhHoldersTokenUtxos,
+                        excludedAddresses,
+                        airdropAmountXec,
+                        minTokenQtyUndecimalized,
+                    ),
+                ).toEqual(returned);
+            });
+        });
+        expectedErrors.forEach(expectedError => {
+            const {
+                description,
+                p2pkhHoldersTokenUtxos,
+                excludedAddresses,
+                airdropAmountXec,
+                minTokenQtyUndecimalized,
+                err,
+            } = expectedError;
+            it(`getAirdropTx throws error for: ${description}`, () => {
+                expect(() =>
+                    getAirdropTx(
+                        p2pkhHoldersTokenUtxos,
+                        excludedAddresses,
+                        airdropAmountXec,
+                        minTokenQtyUndecimalized,
+                    ),
+                ).toThrow(err);
+            });
+        });
+    });
+    describe('Gets csv list of airdrop recipients address and amounts for an equal airdrop', () => {
+        const { expectedReturns, expectedErrors } = vectors.getEqualAirdropTx;
+        expectedReturns.forEach(expectedReturn => {
+            const {
+                description,
+                p2pkhHoldersTokenUtxos,
+                excludedAddresses,
+                airdropAmountXec,
+                minTokenQtyUndecimalized,
+                returned,
+            } = expectedReturn;
+            it(`getEqualAirdropTx: ${description}`, () => {
+                expect(
+                    getEqualAirdropTx(
+                        p2pkhHoldersTokenUtxos,
+                        excludedAddresses,
+                        airdropAmountXec,
+                        minTokenQtyUndecimalized,
+                    ),
+                ).toEqual(returned);
+            });
+        });
+        expectedErrors.forEach(expectedError => {
+            const {
+                description,
+                p2pkhHoldersTokenUtxos,
+                excludedAddresses,
+                airdropAmountXec,
+                minTokenQtyUndecimalized,
+                err,
+            } = expectedError;
+            it(`getEqualAirdropTx throws error for: ${description}`, () => {
+                expect(() =>
+                    getEqualAirdropTx(
+                        p2pkhHoldersTokenUtxos,
+                        excludedAddresses,
+                        airdropAmountXec,
+                        minTokenQtyUndecimalized,
+                    ),
+                ).toThrow(err);
+            });
+        });
+    });
+});
diff --git a/cashtab/src/airdrop/fixtures/mocks.js b/cashtab/src/airdrop/fixtures/mocks.ts
rename from cashtab/src/airdrop/fixtures/mocks.js
rename to cashtab/src/airdrop/fixtures/mocks.ts
--- a/cashtab/src/airdrop/fixtures/mocks.js
+++ b/cashtab/src/airdrop/fixtures/mocks.ts
@@ -2,8 +2,9 @@
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
+import { TokenIdUtxos } from 'chronik-client';
 // In-node chronik return data for chronik.tokenId(50d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e).utxos()
-export const tokenUtxos = {
+export const tokenUtxos: TokenIdUtxos = {
     tokenId: '50d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e',
     utxos: [
         {
@@ -206,6 +207,14 @@
         },
     ],
 };
+export const p2pkhHoldersTokenUtxos = new Map([
+    ['76a91476458db0ed96fe9863fc1ccec9fa2cfab884b0f688ac', 88n],
+    ['76a91495e79f51d4260bc0dc3ba7fb77c7be92d0fbdd1d88ac', 1n],
+    ['76a914a5417349420ec53b27522fed1a63b1672c0f28ff88ac', 3n],
+    ['76a914a714013e6336a0378a1f71ade875b2138813a3ec88ac', 4n],
+    ['76a914c1aadc99f96fcfcfe5642ca29a53e701f0b801c388ac', 1n],
+    ['76a914d4fa9121bcd065dd93e58831569cf51ef5a74f6188ac', 3n],
+]);
 
 // Build tokenUtxos with no p2pkh or p2sh scripts
 const badUtxos = [];
diff --git a/cashtab/src/airdrop/fixtures/vectors.js b/cashtab/src/airdrop/fixtures/vectors.js
--- a/cashtab/src/airdrop/fixtures/vectors.js
+++ b/cashtab/src/airdrop/fixtures/vectors.js
@@ -2,51 +2,66 @@
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
-import { tokenUtxos, badScriptTokenUtxos } from 'airdrop/fixtures/mocks';
+import { p2pkhHoldersTokenUtxos } from 'airdrop/fixtures/mocks';
 export default {
     getAirdropTx: {
         expectedReturns: [
             {
                 description:
                     'We can calculate an airdrop for holders of a given tokenId and no ignored addresses',
-                tokenUtxos,
+                p2pkhHoldersTokenUtxos,
                 excludedAddresses: [],
-                airdropAmountXec: 5000,
+                airdropAmountXec: '5000',
                 minTokenQtyUndecimalized: '0',
-                returned:
-                    'ecash:qzj5zu6fgg8v2we82gh76xnrk9njcreglum9ffspnr, 150\necash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035, 50\necash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 150\necash:qrq64hyel9hulnl9vsk29xjnuuqlpwqpcv6mk9pqly, 50\necash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m, 200\necash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj, 4400',
+                returned: [
+                    'ecash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj, 4400',
+                    'ecash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m, 200',
+                    'ecash:qzj5zu6fgg8v2we82gh76xnrk9njcreglum9ffspnr, 150',
+                    'ecash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 150',
+                    'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035, 50',
+                    'ecash:qrq64hyel9hulnl9vsk29xjnuuqlpwqpcv6mk9pqly, 50',
+                ].join('\n'),
             },
             {
                 description:
                     'We can calculate an airdrop for holders of a given tokenId and one ignored address',
-                tokenUtxos,
+                p2pkhHoldersTokenUtxos,
                 excludedAddresses: [
                     'ecash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj',
                 ],
-                airdropAmountXec: 5000,
+                airdropAmountXec: '5000',
                 minTokenQtyUndecimalized: '0',
-                returned:
-                    'ecash:qzj5zu6fgg8v2we82gh76xnrk9njcreglum9ffspnr, 1250\necash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035, 416.66\necash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 1250\necash:qrq64hyel9hulnl9vsk29xjnuuqlpwqpcv6mk9pqly, 416.66\necash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m, 1666.66',
+                returned: [
+                    'ecash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m, 1666.66',
+                    'ecash:qzj5zu6fgg8v2we82gh76xnrk9njcreglum9ffspnr, 1250',
+                    'ecash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 1250',
+                    'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035, 416.66',
+                    'ecash:qrq64hyel9hulnl9vsk29xjnuuqlpwqpcv6mk9pqly, 416.66',
+                ].join('\n'),
             },
             {
                 description:
                     'We can calculate an airdrop for holders of a given tokenId and two ignored addresses',
-                tokenUtxos,
+                p2pkhHoldersTokenUtxos,
                 excludedAddresses: [
                     'ecash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj',
                     'ecash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m',
                 ],
-                airdropAmountXec: 5000,
+                airdropAmountXec: '5000',
                 minTokenQtyUndecimalized: '0',
-                returned:
-                    'ecash:qzj5zu6fgg8v2we82gh76xnrk9njcreglum9ffspnr, 1875\necash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035, 625\necash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 1875\necash:qrq64hyel9hulnl9vsk29xjnuuqlpwqpcv6mk9pqly, 625',
+                returned: [
+                    'ecash:qzj5zu6fgg8v2we82gh76xnrk9njcreglum9ffspnr, 1875',
+                    'ecash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 1875',
+                    'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035, 625',
+                    'ecash:qrq64hyel9hulnl9vsk29xjnuuqlpwqpcv6mk9pqly, 625',
+                ].join('\n'),
             },
             {
                 description:
                     'We can calculate an airdrop for holders of a given tokenId with no ignored addresses and a specified minTokenQtyUndecimalized that renders only one address eligible',
-                tokenUtxos,
+                p2pkhHoldersTokenUtxos,
                 excludedAddresses: [],
-                airdropAmountXec: 5000,
+                airdropAmountXec: '5000',
                 minTokenQtyUndecimalized: '5',
                 returned:
                     'ecash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj, 5000',
@@ -54,40 +69,47 @@
             {
                 description:
                     'We can calculate an airdrop for holders of a given tokenId with no ignored addresses and a specified minTokenQtyUndecimalized that renders some addresses eligible',
-                tokenUtxos,
+                p2pkhHoldersTokenUtxos,
                 excludedAddresses: [],
-                airdropAmountXec: 5000,
+                airdropAmountXec: '5000',
                 minTokenQtyUndecimalized: '2',
-                returned:
-                    'ecash:qzj5zu6fgg8v2we82gh76xnrk9njcreglum9ffspnr, 153.06\necash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 153.06\necash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m, 204.08\necash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj, 4489.79',
+                returned: [
+                    'ecash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj, 4489.79',
+                    'ecash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m, 204.08',
+                    'ecash:qzj5zu6fgg8v2we82gh76xnrk9njcreglum9ffspnr, 153.06',
+                    'ecash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 153.06',
+                ].join('\n'),
             },
             {
                 description:
                     'We can calculate an airdrop for holders of a given tokenId with an ignored addresses and a specified minTokenQtyUndecimalized that renders some addresses eligible',
-                tokenUtxos,
+                p2pkhHoldersTokenUtxos,
                 excludedAddresses: [
                     'ecash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj',
                 ],
-                airdropAmountXec: 5000,
+                airdropAmountXec: '5000',
                 minTokenQtyUndecimalized: '2',
-                returned:
-                    'ecash:qzj5zu6fgg8v2we82gh76xnrk9njcreglum9ffspnr, 1500\necash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 1500\necash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m, 2000',
+                returned: [
+                    'ecash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m, 2000',
+                    'ecash:qzj5zu6fgg8v2we82gh76xnrk9njcreglum9ffspnr, 1500',
+                    'ecash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 1500',
+                ].join('\n'),
             },
         ],
         expectedErrors: [
             {
                 description:
                     'We throw expected error if no tokens are held at p2pkh or p2sh addresses',
-                tokenUtxos: badScriptTokenUtxos,
+                p2pkhHoldersTokenUtxos: new Map(),
                 excludedAddresses: [],
-                airdropAmountXec: 5000,
+                airdropAmountXec: '5000',
                 minTokenQtyUndecimalized: '0',
-                err: 'No token balance of token "50d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e" held by p2pkh or p2sh addresses',
+                err: 'No eligible recipients with these airdrop settings. Try raising the airdrop amount.',
             },
             {
                 description:
                     'We throw expected error if all eligible addresses are excluded',
-                tokenUtxos,
+                p2pkhHoldersTokenUtxos,
                 excludedAddresses: [
                     'ecash:qzudj5fd9t0cknnsc3wzdd4sp46u9r42jc2d89j2kc',
                     'ecash:qzj5zu6fgg8v2we82gh76xnrk9njcreglum9ffspnr',
@@ -97,16 +119,16 @@
                     'ecash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj',
                     'ecash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m',
                 ],
-                airdropAmountXec: 5000,
+                airdropAmountXec: '5000',
                 minTokenQtyUndecimalized: '0',
-                err: 'No token balance of token "50d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e" held by p2pkh or p2sh addresses',
+                err: 'No eligible recipients with these airdrop settings. Try raising the airdrop amount.',
             },
             {
                 description:
                     'We throw expected error if all eligible recipients would receive dust',
-                tokenUtxos,
+                p2pkhHoldersTokenUtxos,
                 excludedAddresses: [],
-                airdropAmountXec: 5,
+                airdropAmountXec: '5',
                 minTokenQtyUndecimalized: '0',
                 err: 'No eligible recipients with these airdrop settings. Try raising the airdrop amount.',
             },
@@ -117,44 +139,59 @@
             {
                 description:
                     'We can calculate an airdrop for holders of a given tokenId and no ignored addresses',
-                tokenUtxos,
+                p2pkhHoldersTokenUtxos,
                 excludedAddresses: [],
-                airdropAmountXec: 5000,
+                airdropAmountXec: '5000',
                 minTokenQtyUndecimalized: '0',
-                returned:
-                    'ecash:qzj5zu6fgg8v2we82gh76xnrk9njcreglum9ffspnr, 833.33\necash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035, 833.33\necash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 833.33\necash:qrq64hyel9hulnl9vsk29xjnuuqlpwqpcv6mk9pqly, 833.33\necash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m, 833.33\necash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj, 833.33',
+                returned: [
+                    'ecash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj, 833.33',
+                    'ecash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m, 833.33',
+                    'ecash:qzj5zu6fgg8v2we82gh76xnrk9njcreglum9ffspnr, 833.33',
+                    'ecash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 833.33',
+                    'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035, 833.33',
+                    'ecash:qrq64hyel9hulnl9vsk29xjnuuqlpwqpcv6mk9pqly, 833.33',
+                ].join('\n'),
             },
             {
                 description:
                     'We can calculate an airdrop for holders of a given tokenId and one ignored address',
-                tokenUtxos,
+                p2pkhHoldersTokenUtxos,
                 excludedAddresses: [
                     'ecash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj',
                 ],
-                airdropAmountXec: 5000,
+                airdropAmountXec: '5000',
                 minTokenQtyUndecimalized: '0',
-                returned:
-                    'ecash:qzj5zu6fgg8v2we82gh76xnrk9njcreglum9ffspnr, 1000\necash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035, 1000\necash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 1000\necash:qrq64hyel9hulnl9vsk29xjnuuqlpwqpcv6mk9pqly, 1000\necash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m, 1000',
+                returned: [
+                    'ecash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m, 1000',
+                    'ecash:qzj5zu6fgg8v2we82gh76xnrk9njcreglum9ffspnr, 1000',
+                    'ecash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 1000',
+                    'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035, 1000',
+                    'ecash:qrq64hyel9hulnl9vsk29xjnuuqlpwqpcv6mk9pqly, 1000',
+                ].join('\n'),
             },
             {
                 description:
                     'We can calculate an airdrop for holders of a given tokenId and two ignored addresses',
-                tokenUtxos,
+                p2pkhHoldersTokenUtxos,
                 excludedAddresses: [
                     'ecash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj',
                     'ecash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m',
                 ],
-                airdropAmountXec: 5000,
+                airdropAmountXec: '5000',
                 minTokenQtyUndecimalized: '0',
-                returned:
-                    'ecash:qzj5zu6fgg8v2we82gh76xnrk9njcreglum9ffspnr, 1250\necash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035, 1250\necash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 1250\necash:qrq64hyel9hulnl9vsk29xjnuuqlpwqpcv6mk9pqly, 1250',
+                returned: [
+                    'ecash:qzj5zu6fgg8v2we82gh76xnrk9njcreglum9ffspnr, 1250',
+                    'ecash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 1250',
+                    'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035, 1250',
+                    'ecash:qrq64hyel9hulnl9vsk29xjnuuqlpwqpcv6mk9pqly, 1250',
+                ].join('\n'),
             },
             {
                 description:
                     'We can calculate an airdrop for holders of a given tokenId with no ignored addresses and a specified minTokenQtyUndecimalized that renders only one address eligible',
-                tokenUtxos,
+                p2pkhHoldersTokenUtxos,
                 excludedAddresses: [],
-                airdropAmountXec: 5000,
+                airdropAmountXec: '5000',
                 minTokenQtyUndecimalized: '5',
                 returned:
                     'ecash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj, 5000',
@@ -162,40 +199,47 @@
             {
                 description:
                     'We can calculate an airdrop for holders of a given tokenId with no ignored addresses and a specified minTokenQtyUndecimalized that renders some addresses eligible',
-                tokenUtxos,
+                p2pkhHoldersTokenUtxos,
                 excludedAddresses: [],
-                airdropAmountXec: 5000,
+                airdropAmountXec: '5000',
                 minTokenQtyUndecimalized: '2',
-                returned:
-                    'ecash:qzj5zu6fgg8v2we82gh76xnrk9njcreglum9ffspnr, 1250\necash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 1250\necash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m, 1250\necash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj, 1250',
+                returned: [
+                    'ecash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj, 1250',
+                    'ecash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m, 1250',
+                    'ecash:qzj5zu6fgg8v2we82gh76xnrk9njcreglum9ffspnr, 1250',
+                    'ecash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 1250',
+                ].join('\n'),
             },
             {
                 description:
                     'We can calculate an airdrop for holders of a given tokenId with an ignored addresses and a specified minTokenQtyUndecimalized that renders some addresses eligible',
-                tokenUtxos,
+                p2pkhHoldersTokenUtxos,
                 excludedAddresses: [
                     'ecash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj',
                 ],
-                airdropAmountXec: 5000,
+                airdropAmountXec: '5000',
                 minTokenQtyUndecimalized: '2',
-                returned:
-                    'ecash:qzj5zu6fgg8v2we82gh76xnrk9njcreglum9ffspnr, 1666.66\necash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 1666.66\necash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m, 1666.66',
+                returned: [
+                    'ecash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m, 1666.66',
+                    'ecash:qzj5zu6fgg8v2we82gh76xnrk9njcreglum9ffspnr, 1666.66',
+                    'ecash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 1666.66',
+                ].join('\n'),
             },
         ],
         expectedErrors: [
             {
                 description:
                     'We throw expected error if no tokens are held at p2pkh or p2sh addresses',
-                tokenUtxos: badScriptTokenUtxos,
+                p2pkhHoldersTokenUtxos: new Map(),
                 excludedAddresses: [],
-                airdropAmountXec: 5000,
+                airdropAmountXec: '5000',
                 minTokenQtyUndecimalized: '0',
-                err: 'No token balance of token "50d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e" held by p2pkh or p2sh addresses',
+                err: 'No token holders with more than the minimum eligible balance specified. Try a higher minimum eToken holder balance.',
             },
             {
                 description:
                     'We throw expected error if all eligible addresses are excluded',
-                tokenUtxos,
+                p2pkhHoldersTokenUtxos,
                 excludedAddresses: [
                     'ecash:qzudj5fd9t0cknnsc3wzdd4sp46u9r42jc2d89j2kc',
                     'ecash:qzj5zu6fgg8v2we82gh76xnrk9njcreglum9ffspnr',
@@ -205,25 +249,25 @@
                     'ecash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj',
                     'ecash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m',
                 ],
-                airdropAmountXec: 5000,
+                airdropAmountXec: '5000',
                 minTokenQtyUndecimalized: '0',
-                err: 'No token balance of token "50d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e" held by p2pkh or p2sh addresses',
+                err: 'No token holders with more than the minimum eligible balance specified. Try a higher minimum eToken holder balance.',
             },
             {
                 description:
                     'We throw expected error if all eligible addresses are excluded',
-                tokenUtxos,
+                p2pkhHoldersTokenUtxos,
                 excludedAddresses: [],
-                airdropAmountXec: 5000,
+                airdropAmountXec: '5000',
                 minTokenQtyUndecimalized: '100',
                 err: 'No token holders with more than the minimum eligible balance specified. Try a higher minimum eToken holder balance.',
             },
             {
                 description:
                     'We throw expected error if anticipated airdrop amount is less than dust',
-                tokenUtxos,
+                p2pkhHoldersTokenUtxos,
                 excludedAddresses: [],
-                airdropAmountXec: 5,
+                airdropAmountXec: '5',
                 minTokenQtyUndecimalized: '0',
                 err: `6 eligible recipients. Recipients would receive less than 546 sats with a total airdrop amount of 5 XEC. Please increase your airdrop amount or ignore more addresses.`,
             },
diff --git a/cashtab/src/airdrop/index.ts b/cashtab/src/airdrop/index.ts
--- a/cashtab/src/airdrop/index.ts
+++ b/cashtab/src/airdrop/index.ts
@@ -4,15 +4,111 @@
 
 import { BN } from 'slp-mdm';
 import { toSatoshis, toXec } from 'wallet';
-import { TokenIdUtxos } from 'chronik-client';
+import { ChronikClient } from 'chronik-client';
+import { Agora, AgoraPartial } from 'ecash-agora';
+import { shaRmd160 } from 'ecash-lib';
 import cashaddr from 'ecashaddrjs';
 import appConfig from 'config/app';
 
 /**
  * airdrops/index.js
- * Methods for calculating token airdrops in Cashtab
+ * Functions that support calculating token airdrops in Cashtab
  */
 
+/**
+ * Map of outputScript => tokenSatoshis
+ */
+type TokenHolderMap = Map<string, bigint>;
+
+/**
+ * token UTXOS may be held by active agora offers
+ * In this case, we do not want to airdrop XEC to the agora p2sh
+ * Instead we get the p2pkh address that created this offer
+ * And we assign that address a balance of the qty being offered
+ */
+export const getAgoraHolders = async (
+    agora: Agora,
+    tokenId: string,
+): Promise<TokenHolderMap> => {
+    const activeOffers = await agora.activeOffersByTokenId(tokenId);
+    const agoraHolders = new Map();
+    for (const offer of activeOffers) {
+        const offerInfo = offer.variant.params;
+        const offeredTokens = BigInt(offer.token.amount);
+        const pk =
+            offerInfo instanceof AgoraPartial
+                ? offerInfo.makerPk
+                : offerInfo.cancelPk;
+        // Note the use of shaRmd160 requires initWasm()
+        const hash = shaRmd160(pk);
+        const addr = cashaddr.encode('ecash', 'p2pkh', hash);
+        const outputScript = cashaddr.getOutputScriptFromAddress(addr);
+
+        // Have we already added this holder to the map?
+        const thisAgoraHolderBalance = agoraHolders.get(outputScript);
+        if (typeof thisAgoraHolderBalance === 'undefined') {
+            // Initialize an agora holder
+            agoraHolders.set(outputScript, offeredTokens);
+        } else {
+            // Increment an agora holder
+            agoraHolders.set(
+                outputScript,
+                thisAgoraHolderBalance + offeredTokens,
+            );
+        }
+    }
+    return agoraHolders;
+};
+
+export const getP2pkhHolders = async (
+    chronik: ChronikClient,
+    tokenId: string,
+): Promise<TokenHolderMap> => {
+    // Get all utxos for this tokenId
+    const utxos = (await chronik.tokenId(tokenId).utxos()).utxos;
+    const p2pkhHolders: TokenHolderMap = new Map();
+    for (const utxo of utxos) {
+        if (typeof utxo.token === 'undefined') {
+            // Ignore non-token utxos
+
+            // Should never happen, as we can only get token utxos
+            // from chronik.tokenId(tokenId).utxos()
+            continue;
+        }
+        const { script } = utxo;
+
+        // Validate script for p2pkh
+        try {
+            const { type } = cashaddr.getTypeAndHashFromOutputScript(script);
+            if (type !== 'p2pkh') {
+                continue;
+            }
+        } catch {
+            // If we have an error in getTypeAndHashFromOutputScript, then it is not p2pkh
+            continue;
+        }
+
+        const tokenSatoshis = utxo.token.amount;
+
+        if (tokenSatoshis === '0') {
+            // We do not add a 0-qty holder
+            // this happens for a holder who e.g. only holds a mint baton
+            continue;
+        }
+
+        // Have we already added this holder to the map?
+        const thisHolderBalance = p2pkhHolders.get(script);
+        if (typeof thisHolderBalance === 'undefined') {
+            // Initialize a p2pkh holder
+            p2pkhHolders.set(script, BigInt(tokenSatoshis));
+        } else {
+            // Increment an agora holder
+            p2pkhHolders.set(script, thisHolderBalance + BigInt(tokenSatoshis));
+        }
+    }
+    return p2pkhHolders;
+};
+
 /**
  * Get a list of addresses and XEC amounts for an airdrop tx according to given settings
  * @param tokenUtxos output from chronik.tokenId(airdroppedTokenId).utxos()
@@ -29,224 +125,77 @@
  * 4) We only send airdrops to P2PKH or P2SH recipients.
  */
 export const getAirdropTx = (
-    tokenUtxos: TokenIdUtxos,
+    tokenHolders: TokenHolderMap,
     excludedAddresses: string[],
     airdropAmountXec: string,
     minTokenQtyUndecimalized = '0',
 ): string => {
-    const { tokenId, utxos } = tokenUtxos;
-    // Iterate over tokenUtxos to get total token supply
+    // The total supply (in tokenSatoshis) held by all p2pkh holders
+    const totalQtyHeldByTokenHolders = new BN(
+        Array.from(tokenHolders.entries())
+            .reduce((acc, [, value]) => acc + value, 0n)
+            .toString(),
+    );
 
-    // Initialize circulatingSupply
-    // For the purposes of airdrop calculations, this is the total supply of a token held
-    // at p2pkh or p2sh addresses
+    // The total ELIGIBLE supply (in tokenSatoshis) is the sum of the supply held by
+    // all tokenHolders; i.e. after excluding holders via settings or balance reqs
     let circulatingSupply = new BN(0);
 
+    // User-configured param to only reward users holding a certain qty of token
     const minTokenAmount = new BN(minTokenQtyUndecimalized);
 
-    // Map of outputScript: amount
-    // Some holders are expected to have multiple utxos of a token
-    // But we only want to know the total amount held by each holding outputScript
-    const tokenHolders = new Map();
-    for (const utxo of utxos) {
-        if (typeof utxo.token === 'undefined') {
-            // Note: all these tokens will have utxo key, since they came from the tokenId call
-            // We do this check for typescript
-            continue;
-        }
-        // Get this holder's address
-        let address;
-        try {
-            address = cashaddr.encodeOutputScript(utxo.script);
-        } catch (err) {
-            // If the output script is not p2pkh or p2sh, we cannot get its address
-            // We do not include non-p2pkh and non-p2sh scripts in airdrops
-            // In calculating airdrop txs,
-            // token amounts held at such scripts are not considered part of circulatingSupply
-            continue;
-        }
+    // Required balance to earn at least 546 satoshis in this airdrop, the minimum amount
+    // an output can hold on eCash network
+    const minTokenAmountDust = totalQtyHeldByTokenHolders
+        .times(appConfig.dustSats)
+        .div(toSatoshis(parseFloat(airdropAmountXec)))
+        .integerValue(BN.ROUND_UP);
 
-        if (excludedAddresses.includes(address)) {
-            // If this is an ignored address, go to the next utxo
-            continue;
-        }
-
-        // Get amount of token in this utxo
-        const tokenAmountThisUtxo = new BN(utxo.token.amount);
+    // Update tokenHolders to be address => tokenSatoshisBigNumber
+    // We determine eligible supply here by only adding holings of eligible holders
+    const airdropRecipients = new Map();
+    tokenHolders.forEach((tokenSatoshis, outputScript) => {
+        // We only expect p2pkh outputScripts, so no errors are expected encoding the address
+        const address = cashaddr.encodeOutputScript(outputScript);
+        const tokenSatoshisBigNumber = new BN(tokenSatoshis.toString());
 
-        if (tokenAmountThisUtxo.eq(0)) {
-            // Ignore 0 amounts
-            // These could be from malformed txs or minting batons
-            continue;
+        if (
+            !excludedAddresses.includes(address) &&
+            tokenSatoshisBigNumber.gte(minTokenAmount) &&
+            tokenSatoshisBigNumber.gte(minTokenAmountDust)
+        ) {
+            // We only add this token holder to the map if it is not excluded by settings
+            airdropRecipients.set(address, tokenSatoshisBigNumber);
+            circulatingSupply = circulatingSupply.plus(tokenSatoshisBigNumber);
         }
-
-        // Increment the token qty held at this address in the tokenHolders map
-        const addrInMap = tokenHolders.get(address);
-        tokenHolders.set(
-            address,
-            typeof addrInMap === 'undefined'
-                ? tokenAmountThisUtxo
-                : addrInMap.plus(tokenAmountThisUtxo),
-        );
-
-        // Increment circulatingSupply for this airdrop calculation
-        circulatingSupply = circulatingSupply.plus(new BN(utxo.token.amount));
-    }
+    });
 
     // If no holders are p2pkh or p2sh, throw an error
     if (circulatingSupply.eq(0)) {
         throw new Error(
-            `No token balance of token "${tokenId}" held by p2pkh or p2sh addresses`,
+            'No eligible recipients with these airdrop settings. Try raising the airdrop amount.',
         );
     }
 
     const airdropAmountSatoshis = toSatoshis(parseFloat(airdropAmountXec));
 
-    // Remove tokenHolders with ineligible balance
-    const ineligibleRecipientsLowBalance = new Set();
-    tokenHolders.forEach((tokenQty, address) => {
-        if (tokenQty.lt(minTokenAmount)) {
-            ineligibleRecipientsLowBalance.add(address);
-        }
-    });
-
-    // We need to iterate over all the utxos again, as now the relevant circulatingSupply will be different
-    const eligibleTokenHolders = new Map();
-    let eligibleCirculatingSupply = new BN(0);
-    for (const utxo of utxos) {
-        if (typeof utxo.token === 'undefined') {
-            // Note: all these tokens will have utxo key, since they came from the tokenId call
-            // We do this check for typescript
-            continue;
-        }
-        // Get this holder's address
-        let address;
-        try {
-            address = cashaddr.encodeOutputScript(utxo.script);
-        } catch (err) {
-            continue;
-        }
-
-        if (
-            excludedAddresses.includes(address) ||
-            ineligibleRecipientsLowBalance.has(address)
-        ) {
-            // If this is an ignored address, go to the next utxo
-            // OR if this is an ineligible address due to low token balance, go to the next utxo
-            continue;
-        }
-
-        // Get amount of token in this utxo
-        const tokenAmountThisUtxo = new BN(utxo.token.amount);
-
-        if (tokenAmountThisUtxo.eq(0)) {
-            // Ignore 0 amounts
-            // These could be from malformed txs or minting batons
-            continue;
-        }
-
-        // Increment the token qty held at this address in the tokenHolders map
-        const addrInMap = eligibleTokenHolders.get(address);
-        eligibleTokenHolders.set(
-            address,
-            typeof addrInMap === 'undefined'
-                ? tokenAmountThisUtxo
-                : addrInMap.plus(tokenAmountThisUtxo),
-        );
-
-        // Increment circulatingSupply for this airdrop calculation
-        eligibleCirculatingSupply = eligibleCirculatingSupply.plus(
-            new BN(utxo.token.amount),
-        );
-    }
-
-    // Recipients who would receive less than dust sats are ineligible
-    const ineligibleRecipientsDust = new Set();
-    eligibleTokenHolders.forEach((tokenQty, address) => {
-        const satsToReceive = Math.floor(
-            tokenQty
-                .div(eligibleCirculatingSupply)
-                .times(airdropAmountSatoshis)
-                .toNumber(),
-        );
-        if (satsToReceive < appConfig.dustSats) {
-            ineligibleRecipientsDust.add(address);
-        }
-    });
-
-    // Now that we know ALL ineligible recipients, we must again iterate over token utxos to get the
-    // correct circulating supply
-    let finalEligibleCirculatingSupply = new BN(0);
-    const airdropRecipients = new Map();
-    for (const utxo of utxos) {
-        if (typeof utxo.token === 'undefined') {
-            // Note: all these tokens will have utxo key, since they came from the tokenId call
-            // We do this check for typescript
-            continue;
-        }
-        // Get this holder's address
-        let address;
-        try {
-            address = cashaddr.encodeOutputScript(utxo.script);
-        } catch (err) {
-            // If the output script is not p2pkh or p2sh, we cannot get its address
-            // We do not include non-p2pkh and non-p2sh scripts in airdrops
-            // In calculating airdrop txs,
-            // token amounts held at such scripts are not considered part of circulatingSupply
-            continue;
-        }
-
-        if (
-            excludedAddresses.includes(address) ||
-            ineligibleRecipientsLowBalance.has(address) ||
-            ineligibleRecipientsDust.has(address)
-        ) {
-            // If this is an ignored address
-            // OR if this address is ineligible for low token balance
-            // OR if this address is ineligible because it would receive dust
-            // go to the next utxo
-            continue;
-        }
-
-        // Get amount of token in this utxo
-        const tokenAmountThisUtxo = new BN(utxo.token.amount);
-
-        if (tokenAmountThisUtxo.eq(0)) {
-            // Ignore 0 amounts
-            // These could be from malformed txs or minting batons
-            continue;
-        }
-
-        // Increment the token qty held at this address in the tokenHolders map
-        const addrInMap = airdropRecipients.get(address);
-        airdropRecipients.set(
-            address,
-            typeof addrInMap === 'undefined'
-                ? tokenAmountThisUtxo
-                : addrInMap.plus(tokenAmountThisUtxo),
-        );
-
-        // Increment circulatingSupply for this airdrop calculation
-        finalEligibleCirculatingSupply = finalEligibleCirculatingSupply.plus(
-            new BN(utxo.token.amount),
-        );
-    }
+    // Sort airdropRecipients by most to least for easy reading of results
+    // Convert Map to sorted array of entries (descending by BigNumber value)
+    const sortedAirdropRecipientsArr = Array.from(
+        airdropRecipients.entries(),
+    ).sort(
+        (a, b) => b[1].comparedTo(a[1]), // b[1] is the BigNumber value of b, a[1] for a
+    );
 
-    // It is possible that we have no airdropRecipients because, after all exclusions are made,
-    // The only "eligible" recipients left would receive dust
-    // This is easy to do by setting an aidrop amount that is too low
-    if (airdropRecipients.size === 0) {
-        throw new Error(
-            'No eligible recipients with these airdrop settings. Try raising the airdrop amount.',
-        );
-    }
+    // If you want to convert back to a Map:
+    const sortedAirdropRecipients = new Map(sortedAirdropRecipientsArr);
 
     // Now we can build our csv
     const airdropArray: string[] = [];
-    airdropRecipients.forEach((tokenQty, address) => {
+    sortedAirdropRecipients.forEach((tokenSatoshisBigNumber, address) => {
         const satsToReceive = Math.floor(
-            tokenQty
-                .div(finalEligibleCirculatingSupply)
+            tokenSatoshisBigNumber
+                .div(circulatingSupply)
                 .times(airdropAmountSatoshis)
                 .toNumber(),
         );
@@ -257,74 +206,32 @@
 };
 
 export const getEqualAirdropTx = (
-    tokenUtxos: TokenIdUtxos,
+    tokenHolders: TokenHolderMap,
     excludedAddresses: string[],
     airdropAmountXec: string,
     minTokenQtyUndecimalized = '0',
 ): string => {
-    const { tokenId, utxos } = tokenUtxos;
     const minTokenAmount = new BN(minTokenQtyUndecimalized);
-    const tokenHolders = new Map();
-    // Iterate over tokenUtxos to get total token supply
-    for (const utxo of utxos) {
-        if (typeof utxo.token === 'undefined') {
-            // Note: all these tokens will have utxo key, since they came from the tokenId call
-            // We do this check for typescript
-            continue;
-        }
-        // Get this holder's address
-        let address;
-        try {
-            address = cashaddr.encodeOutputScript(utxo.script);
-        } catch (err) {
-            // If the output script is not p2pkh or p2sh, we cannot get its address
-            // We do not include non-p2pkh and non-p2sh scripts in airdrops
-            // In calculating airdrop txs,
-            // token amounts held at such scripts are not considered part of circulatingSupply
-            continue;
-        }
-
-        if (excludedAddresses.includes(address)) {
-            // If this is an ignored address, go to the next utxo
-            continue;
-        }
-
-        // Get amount of token in this utxo
-        const tokenAmountThisUtxo = new BN(utxo.token.amount);
-
-        if (tokenAmountThisUtxo.eq(0)) {
-            // Ignore 0 amounts
-            // These could be from malformed txs or minting batons
-            continue;
-        }
-
-        // Increment the token qty held at this address in the tokenHolders map
-        const addrInMap = tokenHolders.get(address);
-        tokenHolders.set(
-            address,
-            typeof addrInMap === 'undefined'
-                ? tokenAmountThisUtxo
-                : addrInMap.plus(tokenAmountThisUtxo),
-        );
-    }
-
-    // If no holders are p2pkh or p2sh, throw an error
-    if (tokenHolders.size === 0) {
-        throw new Error(
-            `No token balance of token "${tokenId}" held by p2pkh or p2sh addresses`,
-        );
-    }
+    // Update tokenHolders to be address => tokenSatoshisBigNumber
+    // We determine eligible supply here by only adding holings of eligible holders
+    const airdropRecipients = new Map();
+    tokenHolders.forEach((tokenSatoshis, outputScript) => {
+        // We only expect p2pkh outputScripts, so no errors are expected encoding the address
+        const address = cashaddr.encodeOutputScript(outputScript);
+        const tokenSatoshisBigNumber = new BN(tokenSatoshis.toString());
 
-    // Remove tokenHolders with ineligible balance
-    tokenHolders.forEach((tokenQty, address, thisMap) => {
-        if (tokenQty.lt(minTokenAmount)) {
-            thisMap.delete(address);
+        if (
+            !excludedAddresses.includes(address) &&
+            tokenSatoshisBigNumber.gte(minTokenAmount)
+        ) {
+            // We only add this token holder to the map if it is not excluded by settings
+            airdropRecipients.set(address, tokenSatoshisBigNumber);
         }
     });
 
-    const totalRecipients = tokenHolders.size;
-
-    if (tokenHolders.size === 0) {
+    const totalRecipients = airdropRecipients.size;
+    // If no holders are p2pkh or p2sh, throw an error
+    if (totalRecipients === 0) {
         throw new Error(
             `No token holders with more than the minimum eligible balance specified. Try a higher minimum eToken holder balance.`,
         );
@@ -340,9 +247,22 @@
         );
     }
 
+    // Sort airdropRecipients by most to least for easy reading of results
+    // Note that in this case, they all get paid the same
+    // But we are still putting the richest addresses first
+    // never go full communism
+    const sortedAirdropRecipientsArr = Array.from(
+        airdropRecipients.entries(),
+    ).sort(
+        (a, b) => b[1].comparedTo(a[1]), // b[1] is the BigNumber value of b, a[1] for a
+    );
+
+    // If you want to convert back to a Map:
+    const sortedAirdropRecipients = new Map(sortedAirdropRecipientsArr);
+
     // Now we can build our csv
     const airdropArray: string[] = [];
-    tokenHolders.forEach((tokenQty, address) => {
+    sortedAirdropRecipients.forEach((tokenQty, address) => {
         airdropArray.push(`${address}, ${toXec(equalAirdropAmount)}`);
     });
 
diff --git a/cashtab/src/components/Airdrop/__tests__/Airdrop.test.js b/cashtab/src/components/Airdrop/__tests__/Airdrop.test.js
--- a/cashtab/src/components/Airdrop/__tests__/Airdrop.test.js
+++ b/cashtab/src/components/Airdrop/__tests__/Airdrop.test.js
@@ -26,9 +26,18 @@
     clearLocalForage,
 } from 'components/App/fixtures/helpers';
 import CashtabTestWrapper from 'components/App/fixtures/CashtabTestWrapper';
+import { MockAgora } from '../../../../../modules/mock-chronik-client/dist';
+import {
+    agoraOfferCachetAlphaOne,
+    cachetCacheMocks,
+} from 'components/Agora/fixtures/mocks';
+import { initWasm } from 'ecash-lib';
 
 describe('<Airdrop />', () => {
     let user;
+    beforeAll(async () => {
+        await initWasm();
+    });
     beforeEach(() => {
         // Set up userEvent
         user = userEvent.setup();
@@ -61,9 +70,13 @@
             walletWithXecAndTokens,
             localforage,
         );
+        const mockedAgora = new MockAgora();
 
         const airdropTokenId =
             '50d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e';
+
+        // No agora offers
+        mockedAgora.setActiveOffersByTokenId(airdropTokenId, []);
         // Make sure the app can get this token's genesis info by calling a mock
         mockedChronik.setToken(
             airdropTokenId,
@@ -76,7 +89,13 @@
         // Mock the chronik.tokenId(formData.tokenId).utxos(); call
         mockedChronik.setUtxosByTokenId(airdropTokenId, tokenUtxos.utxos);
 
-        render(<CashtabTestWrapper chronik={mockedChronik} route="/airdrop" />);
+        render(
+            <CashtabTestWrapper
+                chronik={mockedChronik}
+                agora={mockedAgora}
+                route="/airdrop"
+            />,
+        );
 
         // Wait for the app to load
         await waitFor(() =>
@@ -106,7 +125,14 @@
         expect(
             screen.getByPlaceholderText('Please input parameters above.'),
         ).toHaveValue(
-            `ecash:qzj5zu6fgg8v2we82gh76xnrk9njcreglum9ffspnr, 150\necash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035, 50\necash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 150\necash:qrq64hyel9hulnl9vsk29xjnuuqlpwqpcv6mk9pqly, 50\necash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m, 200\necash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj, 4400`,
+            [
+                'ecash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj, 4400',
+                'ecash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m, 200',
+                'ecash:qzj5zu6fgg8v2we82gh76xnrk9njcreglum9ffspnr, 150',
+                'ecash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 150',
+                'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035, 50',
+                'ecash:qrq64hyel9hulnl9vsk29xjnuuqlpwqpcv6mk9pqly, 50',
+            ].join('\n'),
         );
 
         // We can ignore the mint address
@@ -119,7 +145,14 @@
         expect(
             screen.getByPlaceholderText('Please input parameters above.'),
         ).toHaveValue(
-            `ecash:qzj5zu6fgg8v2we82gh76xnrk9njcreglum9ffspnr, 150\necash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035, 50\necash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 150\necash:qrq64hyel9hulnl9vsk29xjnuuqlpwqpcv6mk9pqly, 50\necash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m, 200\necash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj, 4400`,
+            [
+                'ecash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj, 4400',
+                'ecash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m, 200',
+                'ecash:qzj5zu6fgg8v2we82gh76xnrk9njcreglum9ffspnr, 150',
+                'ecash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 150',
+                'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035, 50',
+                'ecash:qrq64hyel9hulnl9vsk29xjnuuqlpwqpcv6mk9pqly, 50',
+            ].join('\n'),
         );
 
         // We can ignore other addresses
@@ -137,7 +170,12 @@
         expect(
             screen.getByPlaceholderText('Please input parameters above.'),
         ).toHaveValue(
-            `ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035, 555.55\necash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 1666.66\necash:qrq64hyel9hulnl9vsk29xjnuuqlpwqpcv6mk9pqly, 555.55\necash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m, 2222.22`,
+            [
+                'ecash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m, 2222.22',
+                'ecash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 1666.66',
+                'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035, 555.55',
+                'ecash:qrq64hyel9hulnl9vsk29xjnuuqlpwqpcv6mk9pqly, 555.55',
+            ].join('\n'),
         );
 
         // We can airdrop people with less of a token the same amount of XEC as other users in case we happen to think in this way
@@ -152,7 +190,12 @@
         expect(
             screen.getByPlaceholderText('Please input parameters above.'),
         ).toHaveValue(
-            `ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035, 1250\necash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 1250\necash:qrq64hyel9hulnl9vsk29xjnuuqlpwqpcv6mk9pqly, 1250\necash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m, 1250`,
+            [
+                'ecash:qzn3gqf7vvm2qdu2rac6m6r4kgfcsyaras7jfqja3m, 1250',
+                'ecash:qr204yfphngxthvnukyrz45u7500tf60vyqspva5a6, 1250',
+                'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035, 1250',
+                'ecash:qrq64hyel9hulnl9vsk29xjnuuqlpwqpcv6mk9pqly, 1250',
+            ].join('\n'),
         );
     });
     it('We can ignore addresses with less than a token balance for a token with decimals', async () => {
@@ -164,6 +207,12 @@
 
         const airdropTokenId =
             'bef614aac85c0c866f4d39e4d12a96851267d38d1bca5bdd6488bbd42e28b6b1';
+
+        const mockedAgora = new MockAgora();
+
+        // No agora offers
+        mockedAgora.setActiveOffersByTokenId(airdropTokenId, []);
+
         // Make sure the app can get this token's genesis info by calling a mock
         mockedChronik.setToken(airdropTokenId, decimalsTokenInfo);
 
@@ -176,7 +225,13 @@
             tokenUtxosDecimals.utxos,
         );
 
-        render(<CashtabTestWrapper chronik={mockedChronik} route="/airdrop" />);
+        render(
+            <CashtabTestWrapper
+                chronik={mockedChronik}
+                agora={mockedAgora}
+                route="/airdrop"
+            />,
+        );
 
         // Wait for the app to load
         await waitFor(() =>
@@ -206,7 +261,11 @@
         expect(
             screen.getByPlaceholderText('Please input parameters above.'),
         ).toHaveValue(
-            `ecash:qp6qkpeg5xmpcqtu6uc5qkhzexg4sq009sfeekcfk2, 499894.34\necash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj, 94.15\necash:qp89xgjhcqdnzzemts0aj378nfe2mhu9yvxj9nhgg6, 11.49`,
+            [
+                'ecash:qp6qkpeg5xmpcqtu6uc5qkhzexg4sq009sfeekcfk2, 499894.34',
+                'ecash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj, 94.15',
+                'ecash:qp89xgjhcqdnzzemts0aj378nfe2mhu9yvxj9nhgg6, 11.49',
+            ].join('\n'),
         );
 
         // We can ignore the mint address
@@ -219,7 +278,11 @@
         expect(
             screen.getByPlaceholderText('Please input parameters above.'),
         ).toHaveValue(
-            `ecash:qp6qkpeg5xmpcqtu6uc5qkhzexg4sq009sfeekcfk2, 499894.34\necash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj, 94.15\necash:qp89xgjhcqdnzzemts0aj378nfe2mhu9yvxj9nhgg6, 11.49`,
+            [
+                'ecash:qp6qkpeg5xmpcqtu6uc5qkhzexg4sq009sfeekcfk2, 499894.34',
+                'ecash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj, 94.15',
+                'ecash:qp89xgjhcqdnzzemts0aj378nfe2mhu9yvxj9nhgg6, 11.49',
+            ].join('\n'),
         );
 
         // We can ignore addresses based on having too little of the token
@@ -237,7 +300,10 @@
         expect(
             screen.getByPlaceholderText('Please input parameters above.'),
         ).toHaveValue(
-            `ecash:qp6qkpeg5xmpcqtu6uc5qkhzexg4sq009sfeekcfk2, 499905.84\necash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj, 94.15`,
+            [
+                'ecash:qp6qkpeg5xmpcqtu6uc5qkhzexg4sq009sfeekcfk2, 499905.84',
+                'ecash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj, 94.15',
+            ].join('\n'),
         );
 
         // We can ignore another address
@@ -258,4 +324,91 @@
             `ecash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj, 500000`,
         );
     });
+    it('We can include the p2pkh address of the creators of active agora listings together with those of p2pkh holders', async () => {
+        // Mock the app with context at the Send screen
+        const mockedChronik = await initializeCashtabStateForTests(
+            walletWithXecAndTokens,
+            localforage,
+        );
+        const mockedAgora = new MockAgora();
+
+        const airdropTokenId =
+            'aed861a31b96934b88c0252ede135cb9700d7649f69191235087a3030e553cb1';
+
+        // No agora offers
+        mockedAgora.setActiveOffersByTokenId(airdropTokenId, [
+            agoraOfferCachetAlphaOne,
+        ]);
+        // Make sure the app can get this token's genesis info by calling a mock
+        mockedChronik.setToken(airdropTokenId, cachetCacheMocks.token);
+        // Set tx mock so we can get its minting address
+        mockedChronik.setTx(airdropTokenId, cachetCacheMocks.tx);
+
+        // Mock a CACHET holder without an agora airdrop
+        const mockOutpoint = { txid: '11'.repeat(32), outIdx: 0 };
+        const mockTokenType = {
+            protocol: 'SLP',
+            type: 'SLP_TOKEN_TYPE_FUNGIBLE',
+            number: 1,
+        };
+        const mockToken = {
+            tokenId: airdropTokenId,
+            tokenType: mockTokenType,
+            amount: '100',
+            isMintBaton: false,
+        };
+        // Mock p2pkh holder
+        const mockHolderP2pkh = {
+            outpoint: mockOutpoint,
+            blockHeight: 800000,
+            isCoinbase: false,
+            script: '76a91400cd590bfb90b6dc1725530d6c36c78b88ddb60888ac',
+            value: 546,
+            isFinal: true,
+            token: mockToken,
+        };
+        mockedChronik.setUtxosByTokenId(airdropTokenId, [mockHolderP2pkh]);
+
+        render(
+            <CashtabTestWrapper
+                chronik={mockedChronik}
+                agora={mockedAgora}
+                route="/airdrop"
+            />,
+        );
+
+        // Wait for the app to load
+        await waitFor(() =>
+            expect(
+                screen.queryByTitle('Cashtab Loading'),
+            ).not.toBeInTheDocument(),
+        );
+
+        await user.type(
+            screen.getByPlaceholderText('Enter the eToken ID'),
+            airdropTokenId,
+        );
+
+        await user.type(
+            screen.getByPlaceholderText('Enter the total XEC airdrop'),
+            '5000',
+        );
+
+        await user.click(
+            screen.getByRole('button', { name: /Calculate Airdrop/ }),
+        );
+
+        expect(
+            await screen.findByText('One to Many Airdrop Payment Outputs'),
+        ).toBeInTheDocument();
+
+        expect(
+            screen.getByPlaceholderText('Please input parameters above.'),
+        ).toHaveValue(
+            [
+                'ecash:qqpmsv8yh8wwx3lnf92rrc0e6yq97j6zqs8av8vx8h, 4950.49',
+                'ecash:qqqv6kgtlwgtdhqhy4fs6mpkc79c3hdkpqwunu3dqx, 49.5',
+            ].join('\n'),
+        );
+    });
 });
diff --git a/cashtab/src/components/Airdrop/index.tsx b/cashtab/src/components/Airdrop/index.tsx
--- a/cashtab/src/components/Airdrop/index.tsx
+++ b/cashtab/src/components/Airdrop/index.tsx
@@ -14,7 +14,12 @@
     isValidAirdropExclusionArray,
 } from 'validation';
 import { SwitchLabel, PageHeader } from 'components/Common/Atoms';
-import { getAirdropTx, getEqualAirdropTx } from 'airdrop';
+import {
+    getAirdropTx,
+    getEqualAirdropTx,
+    getAgoraHolders,
+    getP2pkhHolders,
+} from 'airdrop';
 import Communist from 'assets/communist.png';
 import { toast } from 'react-toastify';
 import CashtabSwitch from 'components/Common/Switch';
@@ -33,7 +38,7 @@
         // Confirm we have all context required to load the page
         return null;
     }
-    const { chronik, cashtabState, updateCashtabState } = ContextValue;
+    const { chronik, agora, cashtabState, updateCashtabState } = ContextValue;
     const { wallets, cashtabCache } = cashtabState;
     const wallet = wallets[0];
     const location = useLocation();
@@ -232,17 +237,6 @@
         // hide any previous airdrop outputs
         setShowAirdropOutputs(false);
 
-        let tokenUtxos;
-        try {
-            tokenUtxos = await chronik.tokenId(formData.tokenId).utxos();
-        } catch (err) {
-            console.error(`Error getting token utxos from chronik`, err);
-            toast.error('Error retrieving airdrop recipients');
-            // Clear result field from earlier calc, if present, on any error
-            setAirdropRecipients('');
-            return setCalculatingAirdrop(false);
-        }
-
         const excludedAddresses = [];
         if (ignoreOwnAddress) {
             excludedAddresses.push(
@@ -285,19 +279,49 @@
                 .toString();
         }
 
+        // Get the holder map
+
+        let tokenHolderMap;
+        try {
+            const agoraHolders = await getAgoraHolders(agora, formData.tokenId);
+            console.log(`agoraHolders`, agoraHolders);
+            const p2pkhHolders = await getP2pkhHolders(
+                chronik,
+                formData.tokenId,
+            );
+            console.log(`p2pkhHolders`, p2pkhHolders);
+            tokenHolderMap = new Map(
+                [...agoraHolders].concat(
+                    [...p2pkhHolders].map(([k, v]) => [
+                        k,
+                        (agoraHolders.get(k) || 0n) + v,
+                    ]),
+                ),
+            );
+        } catch (err) {
+            console.error(
+                `Error getting token holders from chronik and agora`,
+                err,
+            );
+            toast.error('Error retrieving airdrop recipients');
+            // Clear result field from earlier calc, if present, on any error
+            setAirdropRecipients('');
+            return setCalculatingAirdrop(false);
+        }
+
         // Get the csv
         let csv;
 
         try {
             csv = equalDistributionRatio
                 ? getEqualAirdropTx(
-                      tokenUtxos,
+                      tokenHolderMap,
                       excludedAddresses,
                       formData.totalAirdrop,
                       undecimalizedMinTokenAmount,
                   )
                 : getAirdropTx(
-                      tokenUtxos,
+                      tokenHolderMap,
                       excludedAddresses,
                       formData.totalAirdrop,
                       undecimalizedMinTokenAmount,
diff --git a/contrib/teamcity/build-configurations.yml b/contrib/teamcity/build-configurations.yml
--- a/contrib/teamcity/build-configurations.yml
+++ b/contrib/teamcity/build-configurations.yml
@@ -784,6 +784,7 @@
       echo "Installing mock-chronik-client dependencies..."
       pushd "${TOPLEVEL}/modules/mock-chronik-client"
       npm ci
+      npm run build
 
       # chronik-client
       echo "Installing chronik-client dependencies..."