diff --git a/cashtab.Dockerfile b/cashtab.Dockerfile
--- a/cashtab.Dockerfile
+++ b/cashtab.Dockerfile
@@ -55,6 +55,11 @@
 RUN npm ci
 RUN npm run build
 
+# ecash-agora
+WORKDIR /app/modules/ecash-agora
+RUN npm ci
+RUN npm run build
+
 # ecash-script
 WORKDIR /app/modules/ecash-script
 COPY modules/ecash-script/ .
diff --git a/cashtab/package-lock.json b/cashtab/package-lock.json
--- a/cashtab/package-lock.json
+++ b/cashtab/package-lock.json
@@ -14,6 +14,7 @@
                 "bip66": "^1.1.5",
                 "bitcoinjs-message": "^2.2.0",
                 "chronik-client": "file:../modules/chronik-client",
+                "ecash-agora": "file:../modules/ecash-agora",
                 "ecash-lib": "file:../modules/ecash-lib",
                 "ecash-script": "file:../modules/ecash-script",
                 "ecashaddrjs": "file:../modules/ecashaddrjs",
@@ -98,7 +99,7 @@
             }
         },
         "../modules/chronik-client": {
-            "version": "0.28.0",
+            "version": "0.28.1",
             "license": "MIT",
             "dependencies": {
                 "@types/ws": "^8.2.1",
@@ -2872,9 +2873,35 @@
                 "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "../modules/ecash-lib": {
+        "../modules/ecash-agora": {
             "version": "0.1.1",
             "license": "MIT",
+            "dependencies": {
+                "chronik-client": "file:../chronik-client",
+                "ecash-lib": "file:../ecash-lib"
+            },
+            "devDependencies": {
+                "@istanbuljs/nyc-config-typescript": "^1.0.2",
+                "@types/chai": "^4.3.14",
+                "@types/chai-as-promised": "^7.1.8",
+                "@types/mocha": "^10.0.6",
+                "@types/node": "^20.12.7",
+                "chai": "^4.4.1",
+                "chai-as-promised": "^7.1.1",
+                "eslint-plugin-header": "^3.1.1",
+                "mocha": "^10.4.0",
+                "mocha-junit-reporter": "^2.2.1",
+                "nyc": "^15.1.0",
+                "source-map-support": "^0.5.21",
+                "ts-node": "^10.9.2",
+                "tsx": "^4.7.2",
+                "typescript": "^5.4.3",
+                "typescript-eslint": "^7.6.0"
+            }
+        },
+        "../modules/ecash-lib": {
+            "version": "0.1.3",
+            "license": "MIT",
             "devDependencies": {
                 "@istanbuljs/nyc-config-typescript": "^1.0.2",
                 "@types/chai": "^4.3.14",
@@ -2894,7 +2921,7 @@
             }
         },
         "../modules/ecash-script": {
-            "version": "2.1.2",
+            "version": "2.1.3",
             "license": "MIT",
             "devDependencies": {
                 "eslint": "^8.42.0",
@@ -2906,7 +2933,7 @@
             }
         },
         "../modules/ecashaddrjs": {
-            "version": "1.5.7",
+            "version": "1.5.8",
             "license": "MIT",
             "dependencies": {
                 "big-integer": "1.6.36",
@@ -16564,6 +16591,10 @@
             "dev": true,
             "license": "MIT"
         },
+        "node_modules/ecash-agora": {
+            "resolved": "../modules/ecash-agora",
+            "link": true
+        },
         "node_modules/ecash-lib": {
             "resolved": "../modules/ecash-lib",
             "link": true
diff --git a/cashtab/package.json b/cashtab/package.json
--- a/cashtab/package.json
+++ b/cashtab/package.json
@@ -31,6 +31,7 @@
         "bip66": "^1.1.5",
         "bitcoinjs-message": "^2.2.0",
         "chronik-client": "file:../modules/chronik-client",
+        "ecash-agora": "file:../modules/ecash-agora",
         "ecash-lib": "file:../modules/ecash-lib",
         "ecash-script": "file:../modules/ecash-script",
         "ecashaddrjs": "file:../modules/ecashaddrjs",
diff --git a/cashtab/src/agora/__tests__/index.test.js b/cashtab/src/agora/__tests__/index.test.js
new file mode 100644
--- /dev/null
+++ b/cashtab/src/agora/__tests__/index.test.js
@@ -0,0 +1,394 @@
+// Copyright (c) 2023-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 { getTokenOfferMap, isEqualTypedArray } from 'agora';
+import {
+    Ecc,
+    initWasm,
+    slpSend,
+    SLP_NFT1_CHILD,
+    SLP_NFT1_GROUP,
+    Script,
+    fromHex,
+} from 'ecash-lib';
+import { AgoraOneshot } from 'ecash-agora';
+
+import vectors from '../fixtures/vectors';
+
+describe('agora market methods', () => {
+    const OP_RETURN_HEX = '6a';
+    const SLP_LOKAD_HEX = '534c5000';
+    const SLP_NFT1_CHILD_HEX = '41';
+    const SLP_ACTION_SEND_HEX = '53454e44';
+    const SLP_AMOUNT_ZERO_HEX = '0000000000000000';
+    const SLP_AMOUNT_ONE_HEX = '0000000000000001';
+    const sellerSk = fromHex('11'.repeat(32));
+    const BASE_AD_TXID =
+        '0000000000000000000000000000000000000000000000000000000000000000';
+    const BASE_OFFERED_TOKENID =
+        '1111111111111111111111111111111111111111111111111111111111111111';
+    const BASE_SELLER_HASH = '95e79f51d4260bc0dc3ba7fb77c7be92d0fbdd1d';
+    const BASE_PRICE = 10000n;
+    const VALID_SLP1_NFT_ENFORCED_OUTPUT = {
+        value: BigInt(0),
+        script: slpSend(BASE_OFFERED_TOKENID, SLP_NFT1_CHILD, [0, 1]),
+    };
+    const VALID_NFT_PAYMENT_ENFORCED_OUTPUT = {
+        value: BASE_PRICE,
+        script: Script.p2pkh(fromHex(BASE_SELLER_HASH)),
+    };
+
+    let ecc, sellerPk, BASE_AGORA_TX;
+    beforeAll(async () => {
+        // Initialize web assembly
+        await initWasm();
+        // Initialize Ecc
+        ecc = new Ecc();
+        sellerPk = ecc.derivePubkey(sellerSk);
+    });
+    describe('We can compare typed Uint8Arrays', () => {
+        const { expectedReturns } = vectors.isEqualTypedArray;
+
+        expectedReturns.forEach(expectedReturn => {
+            const { description, a, b, returned } = expectedReturn;
+            it(`isEqualTypedArray: ${description}`, () => {
+                expect(isEqualTypedArray(a, b)).toBe(returned);
+            });
+        });
+    });
+    describe('We can build a Map of valid token offers from an array of parsed agora txs', () => {
+        it('An empty tx history array means an empty tokenOfferMap', () => {
+            expect(getTokenOfferMap([])).toStrictEqual(new Map());
+        });
+
+        it('A parsed agora ad that advertises a valid NFT sale is added to the token offer map', () => {
+            // Define base enforcedOutputs for a valid parsed agora tx
+            // Ref /modules/ecash-agora/oneshot.test.ts
+            const enforcedOutputs = [
+                VALID_SLP1_NFT_ENFORCED_OUTPUT,
+                VALID_NFT_PAYMENT_ENFORCED_OUTPUT,
+            ];
+            const agoraOneshot = new AgoraOneshot({
+                enforcedOutputs,
+                cancelPk: sellerPk,
+            });
+            const agoraScript = agoraOneshot.script();
+            BASE_AGORA_TX = {
+                type: 'ONESHOT',
+                params: agoraOneshot,
+                outpoint: {
+                    txid: BASE_AD_TXID,
+                    outIdx: 1,
+                },
+                txBuilderInput: {
+                    prevOut: {
+                        txid: BASE_AD_TXID,
+                        outIdx: 1,
+                    },
+                    signData: {
+                        redeemScript: agoraScript,
+                        value: 546,
+                    },
+                },
+                spentBy: undefined,
+            };
+            expect(getTokenOfferMap([BASE_AGORA_TX])).toStrictEqual(
+                new Map([
+                    [BASE_OFFERED_TOKENID, { priceSatoshis: BASE_PRICE }],
+                ]),
+            );
+        });
+        it('A value of undefined in the array of parsed agora txs is not added to the tokenOfferMap', () => {
+            // Note: parseAgoraTx returns undefined if it parses an invalid agora tx, so this is an expected condition
+            expect(getTokenOfferMap([undefined, BASE_AGORA_TX])).toStrictEqual(
+                new Map([
+                    [BASE_OFFERED_TOKENID, { priceSatoshis: BASE_PRICE }],
+                ]),
+            );
+        });
+        it('An offer with more than 2 enforced outputs is ignored', () => {
+            const enforcedOutputs = [
+                VALID_SLP1_NFT_ENFORCED_OUTPUT,
+                VALID_NFT_PAYMENT_ENFORCED_OUTPUT,
+                { ...VALID_NFT_PAYMENT_ENFORCED_OUTPUT, value: 123n },
+            ];
+            const agoraOneshot = new AgoraOneshot({
+                enforcedOutputs,
+                cancelPk: sellerPk,
+            });
+            const agoraScript = agoraOneshot.script();
+
+            const threeOutputOffer = {
+                ...BASE_AGORA_TX,
+                params: agoraOneshot,
+                txBuilderInput: {
+                    ...BASE_AGORA_TX.txBuilderInput,
+                    signData: {
+                        ...BASE_AGORA_TX.txBuilderInput.signData,
+                        redeemScript: agoraScript,
+                    },
+                },
+            };
+            expect(getTokenOfferMap([threeOutputOffer])).toStrictEqual(
+                new Map(),
+            );
+        });
+        it('An offer that burns XEC at the OP_RETURN is ignored', () => {
+            const enforcedOutputs = [
+                { ...VALID_SLP1_NFT_ENFORCED_OUTPUT, value: 100n },
+                VALID_NFT_PAYMENT_ENFORCED_OUTPUT,
+            ];
+            const agoraOneshot = new AgoraOneshot({
+                enforcedOutputs,
+                cancelPk: sellerPk,
+            });
+            const agoraScript = agoraOneshot.script();
+
+            const burningXecOffer = {
+                ...BASE_AGORA_TX,
+                params: agoraOneshot,
+                txBuilderInput: {
+                    ...BASE_AGORA_TX.txBuilderInput,
+                    signData: {
+                        ...BASE_AGORA_TX.txBuilderInput.signData,
+                        redeemScript: agoraScript,
+                    },
+                },
+            };
+            expect(getTokenOfferMap([burningXecOffer])).toStrictEqual(
+                new Map(),
+            );
+        });
+        it('An offer with a non-OP_RETURN script at index 0 enforced output is ignored', () => {
+            const enforcedOutputs = [
+                {
+                    ...VALID_SLP1_NFT_ENFORCED_OUTPUT,
+                    script: new Script(
+                        fromHex(
+                            `04${SLP_LOKAD_HEX}01${SLP_NFT1_CHILD_HEX}04${SLP_ACTION_SEND_HEX}20${BASE_OFFERED_TOKENID}08${SLP_AMOUNT_ZERO_HEX}08${SLP_AMOUNT_ONE_HEX}`,
+                        ),
+                    ),
+                },
+                VALID_NFT_PAYMENT_ENFORCED_OUTPUT,
+            ];
+            const agoraOneshot = new AgoraOneshot({
+                enforcedOutputs,
+                cancelPk: sellerPk,
+            });
+            const agoraScript = agoraOneshot.script();
+
+            const nonOpReturnOffer = {
+                ...BASE_AGORA_TX,
+                params: agoraOneshot,
+                txBuilderInput: {
+                    ...BASE_AGORA_TX.txBuilderInput,
+                    signData: {
+                        ...BASE_AGORA_TX.txBuilderInput.signData,
+                        redeemScript: agoraScript,
+                    },
+                },
+            };
+            expect(getTokenOfferMap([nonOpReturnOffer])).toStrictEqual(
+                new Map(),
+            );
+        });
+        it('An offer whose LOKAD ID does not match SLP type 1 is ignored', () => {
+            const badLokadHex = '01020304';
+            const enforcedOutputs = [
+                {
+                    ...VALID_SLP1_NFT_ENFORCED_OUTPUT,
+                    script: new Script(
+                        fromHex(
+                            `${OP_RETURN_HEX}04${badLokadHex}01${SLP_NFT1_CHILD_HEX}04${SLP_ACTION_SEND_HEX}20${BASE_OFFERED_TOKENID}08${SLP_AMOUNT_ZERO_HEX}08${SLP_AMOUNT_ONE_HEX}`,
+                        ),
+                    ),
+                },
+                VALID_NFT_PAYMENT_ENFORCED_OUTPUT,
+            ];
+            const agoraOneshot = new AgoraOneshot({
+                enforcedOutputs,
+                cancelPk: sellerPk,
+            });
+            const agoraScript = agoraOneshot.script();
+
+            const nonSlpOneOffer = {
+                ...BASE_AGORA_TX,
+                params: agoraOneshot,
+                txBuilderInput: {
+                    ...BASE_AGORA_TX.txBuilderInput,
+                    signData: {
+                        ...BASE_AGORA_TX.txBuilderInput.signData,
+                        redeemScript: agoraScript,
+                    },
+                },
+            };
+            expect(getTokenOfferMap([nonSlpOneOffer])).toStrictEqual(new Map());
+        });
+        it('A sale offer of an NFT collection/parent token is not added to the token offer map (type bit does not match NFT)', () => {
+            const nftParentEnforcedOutput = {
+                ...VALID_SLP1_NFT_ENFORCED_OUTPUT,
+                script: slpSend(BASE_OFFERED_TOKENID, SLP_NFT1_GROUP, [0, 1]),
+            };
+            const enforcedOutputs = [
+                nftParentEnforcedOutput,
+                VALID_NFT_PAYMENT_ENFORCED_OUTPUT,
+            ];
+            const agoraOneshot = new AgoraOneshot({
+                enforcedOutputs,
+                cancelPk: sellerPk,
+            });
+            const agoraScript = agoraOneshot.script();
+
+            const nftCollectionOffer = {
+                ...BASE_AGORA_TX,
+                params: agoraOneshot,
+                txBuilderInput: {
+                    ...BASE_AGORA_TX.txBuilderInput,
+                    signData: {
+                        ...BASE_AGORA_TX.txBuilderInput.signData,
+                        redeemScript: agoraScript,
+                    },
+                },
+            };
+            expect(getTokenOfferMap([nftCollectionOffer])).toStrictEqual(
+                new Map(),
+            );
+        });
+        it('If enforced output SLP action is not SEND, offer is ignored', () => {
+            const badSlpActionHex = '99999999';
+            const enforcedOutputs = [
+                {
+                    ...VALID_SLP1_NFT_ENFORCED_OUTPUT,
+                    script: new Script(
+                        fromHex(
+                            `${OP_RETURN_HEX}04${SLP_LOKAD_HEX}01${SLP_NFT1_CHILD_HEX}04${badSlpActionHex}20${BASE_OFFERED_TOKENID}08${SLP_AMOUNT_ZERO_HEX}08${SLP_AMOUNT_ONE_HEX}`,
+                        ),
+                    ),
+                },
+                VALID_NFT_PAYMENT_ENFORCED_OUTPUT,
+            ];
+            const agoraOneshot = new AgoraOneshot({
+                enforcedOutputs,
+                cancelPk: sellerPk,
+            });
+            const agoraScript = agoraOneshot.script();
+
+            const nonSlpActionSendOffer = {
+                ...BASE_AGORA_TX,
+                params: agoraOneshot,
+                txBuilderInput: {
+                    ...BASE_AGORA_TX.txBuilderInput,
+                    signData: {
+                        ...BASE_AGORA_TX.txBuilderInput.signData,
+                        redeemScript: agoraScript,
+                    },
+                },
+            };
+            expect(getTokenOfferMap([nonSlpActionSendOffer])).toStrictEqual(
+                new Map(),
+            );
+        });
+        it('If SLP OP_RETURN is invalid (invalid tokenId), offer is ignored', () => {
+            const tooLongTokenId = `${BASE_OFFERED_TOKENID}11`;
+            const enforcedOutputs = [
+                {
+                    ...VALID_SLP1_NFT_ENFORCED_OUTPUT,
+                    script: new Script(
+                        fromHex(
+                            `${OP_RETURN_HEX}04${SLP_LOKAD_HEX}01${SLP_NFT1_CHILD_HEX}04${SLP_ACTION_SEND_HEX}21${tooLongTokenId}08${SLP_AMOUNT_ZERO_HEX}08${SLP_AMOUNT_ONE_HEX}`,
+                        ),
+                    ),
+                },
+                VALID_NFT_PAYMENT_ENFORCED_OUTPUT,
+            ];
+            const agoraOneshot = new AgoraOneshot({
+                enforcedOutputs,
+                cancelPk: sellerPk,
+            });
+            const agoraScript = agoraOneshot.script();
+
+            const invalidTokenIdOffer = {
+                ...BASE_AGORA_TX,
+                params: agoraOneshot,
+                txBuilderInput: {
+                    ...BASE_AGORA_TX.txBuilderInput,
+                    signData: {
+                        ...BASE_AGORA_TX.txBuilderInput.signData,
+                        redeemScript: agoraScript,
+                    },
+                },
+            };
+            expect(getTokenOfferMap([invalidTokenIdOffer])).toStrictEqual(
+                new Map(),
+            );
+        });
+        it('If SLP OP_RETURN enforced output does not have send value of 0 for first first outIdx, offer is ignored', () => {
+            const enforcedOutputs = [
+                {
+                    ...VALID_SLP1_NFT_ENFORCED_OUTPUT,
+                    script: new Script(
+                        fromHex(
+                            `${OP_RETURN_HEX}04${SLP_LOKAD_HEX}01${SLP_NFT1_CHILD_HEX}04${SLP_ACTION_SEND_HEX}20${BASE_OFFERED_TOKENID}08${SLP_AMOUNT_ONE_HEX}08${SLP_AMOUNT_ONE_HEX}`,
+                        ),
+                    ),
+                },
+                VALID_NFT_PAYMENT_ENFORCED_OUTPUT,
+            ];
+            const agoraOneshot = new AgoraOneshot({
+                enforcedOutputs,
+                cancelPk: sellerPk,
+            });
+            const agoraScript = agoraOneshot.script();
+
+            const wrongAmountOutidxOneOffer = {
+                ...BASE_AGORA_TX,
+                params: agoraOneshot,
+                txBuilderInput: {
+                    ...BASE_AGORA_TX.txBuilderInput,
+                    signData: {
+                        ...BASE_AGORA_TX.txBuilderInput.signData,
+                        redeemScript: agoraScript,
+                    },
+                },
+            };
+            expect(getTokenOfferMap([wrongAmountOutidxOneOffer])).toStrictEqual(
+                new Map(),
+            );
+        });
+        it('If SLP OP_RETURN enforced output does not have send value of 1 for second outIdx, offer is ignored', () => {
+            const nonOneOutputHex = '0000000000000008';
+            const enforcedOutputs = [
+                {
+                    ...VALID_SLP1_NFT_ENFORCED_OUTPUT,
+                    script: new Script(
+                        fromHex(
+                            `${OP_RETURN_HEX}04${SLP_LOKAD_HEX}01${SLP_NFT1_CHILD_HEX}04${SLP_ACTION_SEND_HEX}20${BASE_OFFERED_TOKENID}08${SLP_AMOUNT_ZERO_HEX}08${nonOneOutputHex}`,
+                        ),
+                    ),
+                },
+                VALID_NFT_PAYMENT_ENFORCED_OUTPUT,
+            ];
+            const agoraOneshot = new AgoraOneshot({
+                enforcedOutputs,
+                cancelPk: sellerPk,
+            });
+            const agoraScript = agoraOneshot.script();
+
+            const wrongAmountOutidxTwoOffer = {
+                ...BASE_AGORA_TX,
+                params: agoraOneshot,
+                txBuilderInput: {
+                    ...BASE_AGORA_TX.txBuilderInput,
+                    signData: {
+                        ...BASE_AGORA_TX.txBuilderInput.signData,
+                        redeemScript: agoraScript,
+                    },
+                },
+            };
+            expect(getTokenOfferMap([wrongAmountOutidxTwoOffer])).toStrictEqual(
+                new Map(),
+            );
+        });
+    });
+});
diff --git a/cashtab/src/agora/fixtures/vectors.js b/cashtab/src/agora/fixtures/vectors.js
new file mode 100644
--- /dev/null
+++ b/cashtab/src/agora/fixtures/vectors.js
@@ -0,0 +1,48 @@
+// Copyright (c) 2023 The Bitcoin developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+// Test vectors for agora functions
+export default {
+    isEqualTypedArray: {
+        expectedReturns: [
+            {
+                description: 'Empty Uint8Arrays are equal',
+                a: new Uint8Array(),
+                b: new Uint8Array(),
+                returned: true,
+            },
+            {
+                description:
+                    'Arrays with equal length and equal entries are equal',
+                a: new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0]),
+                b: new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0]),
+                returned: true,
+            },
+            {
+                description:
+                    'Arrays with unequal length and equal entries are not equal',
+                a: new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0]),
+                b: new Uint8Array([0, 0, 0, 0, 0, 0, 0]),
+                returned: false,
+            },
+            {
+                description:
+                    'Arrays with equal length and unequal entries are not equal',
+                a: new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0]),
+                b: new Uint8Array([0, 0, 0, 0, 0, 0, 0, 1]),
+                returned: false,
+            },
+        ],
+    },
+    getTokenOfferMap: {
+        expectedReturns: [
+            {
+                description: 'Empty agoraTxs array returns empty map',
+                agoraTxs: [],
+                returned: new Map(),
+            },
+        ],
+        // TODO errors
+    },
+};
diff --git a/cashtab/src/agora/index.js b/cashtab/src/agora/index.js
new file mode 100644
--- /dev/null
+++ b/cashtab/src/agora/index.js
@@ -0,0 +1,137 @@
+// 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 {
+    toHex,
+    OP_RETURN,
+    SLP_LOKAD_ID,
+    SLP_NFT1_CHILD,
+    SEND,
+    slpAmount,
+} from 'ecash-lib';
+
+/**
+ * Parse an array of ParsedAds from ecash-agora for Cashtab display and sales
+ * Why are we parsing something that is already a "parsed" ad:
+ * - Remove any agora txs that are not ONESHOT
+ * - Remove any agora txs that are already spent
+ * - Remove any agora txs that have enforced outputs not supported by Cashtab
+ * - Separate listings into two maps, myListings and offeredListings
+ *   Cashtab user can cancel myListings and buy offeredListings
+ * @param {{ParsedAd | undefined}[]} agoraTx ParsedAd (see ecash-agora types) or undefined
+ * @returns {object} {myListings: {tokenId => {agoraInfo}, offeredListings: {tokenId => {agoraInfo}}}
+ */
+export const getTokenOfferMaps = (agoraTxs, publicKey) => {
+    const VALID_LENGTH_SLP1_NFT = 64;
+    const TOKEN_ID_BYTES = 32;
+    // We do not define a function to parse a single agoraTx
+    // We always get agoraTxs as an array, and some may be undefined
+    // So, we need to conver this batch into a batch that is useful for rendering listed NFTs
+    const myListings = new Map();
+    const offeredListings = new Map();
+
+    for (const agoraTx of agoraTxs) {
+        if (
+            typeof agoraTx !== 'undefined' &&
+            typeof agoraTx.spentBy === 'undefined' &&
+            agoraTx.type === 'ONESHOT'
+        ) {
+            console.log(`this agoraTx makes it`, agoraTx);
+            // If this an agoraTx that has not yet been spent (i.e. bought or canceled)
+            // (note: this implies it has params key with an enforcedOutputs key,
+            // each enforcedOutput having value and script keys, see ParsedAd interface in ecash-agora)
+            if (agoraTx.params.enforcedOutputs.length === 2) {
+                // slpv1 NFTs listed by Cashtab are created with 2 enforced outputs
+                if (agoraTx.params.enforcedOutputs[0].value === 0n) {
+                    const { script } = agoraTx.params.enforcedOutputs[0];
+                    if (script.bytecode.length !== VALID_LENGTH_SLP1_NFT) {
+                        // Byte length does not match SLP type 1 NFT OP_RETURN
+                        // Do not include this agoraTx in tokenOfferMap
+                        continue;
+                    }
+
+                    const ops = script.ops();
+                    if (ops.next() !== OP_RETURN) {
+                        // Index 0 script is not OP_RETURN
+                        // Do not include this agoraTx in tokenOfferMap
+                        continue;
+                    }
+                    if (!isEqualTypedArray(ops.next().data, SLP_LOKAD_ID)) {
+                        // First push is not SLP 1 LOKAD ID
+                        // Do not include this agoraTx in tokenOfferMap
+                        continue;
+                    }
+                    const typeByte = ops.next();
+                    if (
+                        typeByte.opcode !== 1 ||
+                        typeByte.data[0] !== SLP_NFT1_CHILD
+                    ) {
+                        // Second push is not NFT child
+                        // Do not include this agoraTx in tokenOfferMap
+                        continue;
+                    }
+                    if (!isEqualTypedArray(ops.next().data, SEND)) {
+                        // Third push is not a SEND
+                        // Do not include this agoraTx in tokenOfferMap
+                        continue;
+                    }
+                    const tokenIdPush = ops.next();
+                    const { opcode, data } = tokenIdPush;
+                    if (opcode !== TOKEN_ID_BYTES) {
+                        // Fourth push is not a valid tokenId
+                        // Do not include this agoraTx in tokenOfferMap
+                        continue;
+                    }
+                    const tokenId = toHex(data);
+                    if (!isEqualTypedArray(ops.next().data, slpAmount(0n))) {
+                        // Fifth push is not expected 0 amount for Cashtab NFT agora offer
+                        // Do not include this agoraTx in tokenOfferMap
+                        continue;
+                    }
+                    if (!isEqualTypedArray(ops.next().data, slpAmount(1n))) {
+                        // Sixth push is not expected 1 amount for Cashtab NFT agora offer
+                        // Do not include this agoraTx in tokenOfferMap
+                        continue;
+                    }
+                    if (typeof ops.next() !== 'undefined') {
+                        // We do not expect any more pushes than what we have checked above
+                        // If we have more, invalid
+                        // Do not include this agoraTx in tokenOfferMap
+                        continue;
+                    }
+                    // If we are here, it is a valid offer
+                    const { params, txBuilderInput } = agoraTx;
+                    const offerInfo = { params, txBuilderInput };
+
+                    if (isEqualTypedArray(params.cancelPk, publicKey)) {
+                        myListings.set(tokenId, offerInfo);
+                    } else {
+                        offeredListings.set(tokenId, offerInfo);
+                    }
+                }
+            }
+        }
+    }
+    return { myListings, offeredListings };
+};
+
+/**
+ * Compare script pushes
+ * JS does not have a way to compare typed arrays
+ * ref https://stackoverflow.com/questions/76127214/compare-equality-of-two-uint8array
+ * @param {Uint8Array} a
+ * @param {Uint8Array} b
+ * @returns {boolean}
+ */
+export const isEqualTypedArray = (a, b) => {
+    if (a.length !== b.length) {
+        return false;
+    }
+    for (let i = 0; i < a.length; i += 1) {
+        if (a[i] !== b[i]) {
+            return false;
+        }
+    }
+    return true;
+};
diff --git a/cashtab/src/assets/nft.svg b/cashtab/src/assets/nft.svg
new file mode 100644
--- /dev/null
+++ b/cashtab/src/assets/nft.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
+ <path d="m914.11 712.5v-225c-0.046875-38.344-20.672-73.688-54-92.625l-207.98-117c-32.438-17.859-71.812-17.859-104.25 0l-207.98 117.14c-33.281 18.891-53.906 54.188-54 92.484v225c0.046875 38.344 20.672 73.688 54 92.625l207.98 117.14c32.391 18.141 71.859 18.141 104.25 0l207.98-117.28c33.281-18.891 53.906-54.188 54-92.484zm-37.5 0c0 24.797-13.359 47.672-34.969 59.859l-207.89 117.14c-21.047 11.484-46.453 11.484-67.5 0l-207.89-117.23c-21.562-12.188-34.922-35.016-34.969-59.766v-225c0-24.797 13.359-47.672 34.969-59.859l207.89-117.14c21-11.672 46.5-11.672 67.5 0l207.89 117.23c21.562 12.188 34.922 35.016 34.969 59.766zm-59.484-241.26-207.89-117.09c-5.7188-3.2812-12.75-3.2812-18.469 0l-207.89 117.09c-5.8125 3.375-9.4688 9.5625-9.5156 16.266v225c0 6.75 3.6562 13.031 9.5156 16.359l207.89 117.14c5.7656 3.1406 12.703 3.1406 18.469 0l207.89-117.23c5.8125-3.375 9.4688-9.5625 9.5156-16.266v-225c-0.046875-6.7031-3.7031-12.891-9.5156-16.266zm-27.984 230.16-189.14 106.59-189.14-106.59v-202.78l189.14-106.59 189.14 106.64zm298.36-552.37-72.234-40.641c-13.406-7.4062-29.625-7.4062-43.031 0l-72.234 40.641c-13.734 7.875-22.172 22.594-21.984 38.484v3.375h-556.03v-3.375c0.1875-15.891-8.25-30.609-21.984-38.484l-72.234-40.641c-13.406-7.4062-29.625-7.4062-43.031 0l-72.234 40.641c-13.734 7.875-22.172 22.594-21.984 38.484v75c0.23438 15.562 8.6719 29.812 22.219 37.5l72 40.641c1.875 0.84375 3.7969 1.5938 5.7656 2.25v512.48c-1.9688 0.65625-3.8906 1.4062-5.7656 2.25l-72.234 41.391c-13.547 7.6406-22.031 21.938-22.266 37.5v75.984c0.23438 15.562 8.7188 29.812 22.266 37.5l72 40.641c13.406 7.3125 29.625 7.3125 42.984 0l72.516-39.656c13.547-7.6406 22.031-21.938 22.266-37.5v-3.375h555.74v2.3906c0.23438 15.562 8.6719 29.812 22.219 37.5l72 40.641c13.406 7.3125 29.625 7.3125 43.031 0l72.234-39.656c13.547-7.6406 22.031-21.938 22.266-37.5v-75.984c-0.23437-15.562-8.7188-29.812-22.266-37.5l-72-40.641c-1.875-0.84375-3.7969-1.5938-5.7656-2.25v-512.48c1.9688-0.65625 3.8906-1.4062 5.7656-2.25l72-41.391c13.547-7.6406 22.031-21.938 22.266-37.5v-75.984c0.09375-15.938-8.3906-30.656-22.266-38.484zm-96.516-7.875c0.89062-0.60938 1.9688-0.89062 3-0.89062 1.125 0 2.2031 0.28125 3.1406 0.89062l59.859 33.844-63.234 35.625-63.234-35.625zm-787.5 0c0.89062-0.60938 1.9688-0.89062 3-0.89062 1.125 0 2.2031 0.28125 3.1406 0.89062l59.859 33.844-63.234 35.625-63.234-35.625zm-72.375 127.13c-1.9688-1.0781-3.1875-3.1406-3.0938-5.3906v-53.391l69 39c5.7656 3.1875 12.703 3.1875 18.469 0l69-39v53.016c0.09375 2.25-1.125 4.3125-3.0938 5.3906l-72.141 40.641v-0.046875c-1.875 1.125-4.2188 1.125-6.1406 0zm72.141 622.87c0.89062-0.60938 1.9219-0.89062 3-0.89062 1.125 0 2.2031 0.28125 3.1406 0.89062l60.094 33.844-63.234 35.625-63.234-35.625zm78.141 127.13-72.141 40.641v-0.046875c-1.875 1.125-4.2188 1.125-6.1406 0l-72-40.641v0.046875c-1.9688-1.0781-3.1875-3.1406-3.0938-5.3906v-53.391l69 39c5.7656 3.1875 12.703 3.1875 18.469 0l69-39v53.016c0.23438 2.3906-0.98438 4.6406-3.0938 5.7656zm40.641-46.266-0.046875-34.5c-0.23438-15.562-8.6719-29.812-22.219-37.5l-71.766-40.359v-518.26l72-40.406c13.547-7.6406 22.031-21.938 22.266-37.5v-35.484h555.74v34.5c0.23438 15.562 8.6719 29.812 22.219 37.5l71.766 40.359v518.26l-72 40.406c-13.547 7.6406-22.031 21.938-22.266 37.5v34.875zm746.86 46.266-72.141 40.641v-0.046875c-1.875 1.125-4.2188 1.125-6.1406 0l-72-40.641v0.046875c-1.9688-1.0781-3.1875-3.1406-3.0938-5.3906v-53.391l69 39c5.7656 3.1875 12.703 3.1875 18.469 0l69-39v53.016c0.23438 2.3906-0.98438 4.6406-3.0938 5.7656zm-12.516-93.234-62.625 35.578-63.234-35.625 60.234-33.891v0.046875c0.89062-0.60938 1.9219-0.89062 3-0.89062 1.125 0 2.2031 0.28125 3.1406 0.89062zm12.516-656.76-72.141 40.641v-0.046875c-1.875 1.125-4.2188 1.125-6.1406 0l-72-40.641v0.046875c-1.9688-1.0781-3.1875-3.1406-3.0938-5.3906v-53.391l69 39c5.7656 3.1875 12.703 3.1875 18.469 0l69-39v53.016c0.23438 2.3906-0.98438 4.6406-3.0938 5.7656zm-481.4 256.74h36.516c10.359 0 18.75 8.3906 18.75 18.75 0 10.359-8.3906 18.75-18.75 18.75h-17.766v12.516h17.766c10.359 0 18.75 8.3906 18.75 18.75 0 10.359-8.3906 18.75-18.75 18.75h-17.766v44.484c0 10.359-8.3906 18.75-18.75 18.75s-18.75-8.3906-18.75-18.75v-113.77c0.32812-10.125 8.625-18.188 18.75-18.234zm-36 18.75v113.48c-0.14062 8.3906-5.8594 15.656-13.969 17.766-1.5938 0.1875-3.1875 0.1875-4.7812 0-6.7969-0.046876-13.031-3.7031-16.359-9.6094l-28.125-50.016v41.391c0 10.359-8.3906 18.75-18.75 18.75s-18.75-8.3906-18.75-18.75v-113.53c0-8.5312 5.7656-15.984 14.016-18.141 8.2969-2.1094 16.969 1.5938 21.094 9.0469l28.125 50.016v-40.922c0-10.359 8.3906-18.75 18.75-18.75s18.75 8.3906 18.75 18.75zm99.984 0h0.046875c0-10.359 8.3906-18.75 18.75-18.75h60c10.359 0 18.75 8.3906 18.75 18.75 0 10.359-8.3906 18.75-18.75 18.75h-11.25v94.734c0 10.359-8.3906 18.75-18.75 18.75s-18.75-8.3906-18.75-18.75v-94.734h-11.25c-5.0156-0.046876-9.7969-2.1562-13.312-5.7656-3.4688-3.6094-5.3438-8.4844-5.2031-13.5z" fill="#00abe5"/>
+</svg>
diff --git a/cashtab/src/chronik/__tests__/index.test.js b/cashtab/src/chronik/__tests__/index.test.js
--- a/cashtab/src/chronik/__tests__/index.test.js
+++ b/cashtab/src/chronik/__tests__/index.test.js
@@ -12,6 +12,7 @@
     getHistory,
     getUtxos,
     getAllTxHistoryByTokenId,
+    getAllTxHistoryByLokadId,
     getChildNftsFromParent,
 } from 'chronik';
 import vectors from '../fixtures/vectors';
@@ -456,4 +457,50 @@
             ]);
         });
     });
+    describe('We can get tx history by lokadId', () => {
+        it('We can get tx history if total txs are less than one page', async () => {
+            // Initialize chronik mock with history info
+            const mockedChronik = new MockChronikClient();
+            const lokadId = '63686174'; // eCashChat
+            mockedChronik.setLokadId(lokadId);
+            mockedChronik.setTxHistoryByLokadId(lokadId, [
+                { txid: 'deadbeef' },
+            ]);
+            expect(
+                await getAllTxHistoryByLokadId(mockedChronik, lokadId),
+            ).toStrictEqual([{ txid: 'deadbeef' }]);
+        });
+        it('We can get tx history if we need to fetch multiple pages', async () => {
+            // Initialize chronik mock with history info
+            const mockedChronik = new MockChronikClient();
+            const lokadId = '63686174'; // eCashChat
+            mockedChronik.setLokadId(lokadId);
+            mockedChronik.setTxHistoryByTokenId(
+                lokadId,
+                [
+                    { txid: 'deadbeef' },
+                    { txid: 'deadbeef' },
+                    { txid: 'deadbeef' },
+                ],
+                1,
+            );
+            expect(
+                await getAllTxHistoryByLokadId(mockedChronik, lokadId),
+            ).toStrictEqual([
+                { txid: 'deadbeef' },
+                { txid: 'deadbeef' },
+                { txid: 'deadbeef' },
+            ]);
+        });
+        it('We get an empty array if the token has no tx history', async () => {
+            // Initialize chronik mock with history info
+            const mockedChronik = new MockChronikClient();
+            const lokadId = '63686174'; // eCashChat
+            mockedChronik.setLokadId(lokadId);
+            mockedChronik.setTxHistoryByLokadId(lokadId, []);
+            expect(
+                await getAllTxHistoryByLokadId(mockedChronik, lokadId),
+            ).toStrictEqual([]);
+        });
+    });
 });
diff --git a/cashtab/src/chronik/index.js b/cashtab/src/chronik/index.js
--- a/cashtab/src/chronik/index.js
+++ b/cashtab/src/chronik/index.js
@@ -614,3 +614,53 @@
     }
     return childNftsFromThisParent;
 };
+
+/**
+ * Get all tx history of a lokad id
+ * In Cashtab, this is used to get NFT listings
+ * Tx history is paginated by chronik, so we need to get all the pages
+ * @param {ChronikClientNode} chronik
+ * @param {string} lokadId
+ * @param {number} pageSize usually 200, the chronik max, but accept a parameter to simplify unit testing
+ * @returns
+ */
+export const getAllTxHistoryByLokadId = async (
+    chronik,
+    lokadId,
+    pageSize = CHRONIK_MAX_PAGE_SIZE,
+) => {
+    // We will throw an error if we get an error from chronik fetch
+    const firstPageResponse = await chronik
+        .lokadId(lokadId)
+        // call with page=0 (to get first page) and max page size, as we want all the history
+        .history(0, pageSize);
+    const { txs, numPages } = firstPageResponse;
+    // Get tx history from all pages
+    // We start with i = 1 because we already have the data from page 0
+    const tokenHistoryPromises = [];
+    for (let i = 1; i < numPages; i += 1) {
+        tokenHistoryPromises.push(
+            new Promise((resolve, reject) => {
+                chronik
+                    .lokadId(lokadId)
+                    .history(i, CHRONIK_MAX_PAGE_SIZE)
+                    .then(
+                        result => {
+                            resolve(result.txs);
+                        },
+                        err => {
+                            reject(err);
+                        },
+                    );
+            }),
+        );
+    }
+    // Get rest of txHistory using Promise.all() to execute requests in parallel
+    const restOfTxHistory = await Promise.all(tokenHistoryPromises);
+    // Flatten so we have an array of tx objects, and not an array of arrays of tx objects
+    const flatTxHistory = restOfTxHistory.flat();
+    // Combine with the first page
+    const allHistory = txs.concat(flatTxHistory);
+
+    return allHistory;
+};
diff --git a/cashtab/src/components/App/App.js b/cashtab/src/components/App/App.js
--- a/cashtab/src/components/App/App.js
+++ b/cashtab/src/components/App/App.js
@@ -17,6 +17,7 @@
     SwapIcon,
     TokensIcon,
     RewardIcon,
+    NftIcon,
 } from 'components/Common/CustomIcons';
 import Spinner from 'components/Common/Spinner';
 import { ThemeProvider } from 'styled-components';
@@ -38,6 +39,7 @@
 import Rewards from 'components/Rewards';
 import NotFound from 'components/App/NotFound';
 import OnBoarding from 'components/OnBoarding';
+import Nfts from 'components/Nfts';
 import { LoadingCtn } from 'components/Common/Atoms';
 import Cashtab from 'assets/cashtab_xec.png';
 import './App.css';
@@ -206,6 +208,12 @@
                                                     <WalletIcon />
                                                 </NavHeader>
                                             )}
+                                            {location.pathname === '/nfts' && (
+                                                <NavHeader>
+                                                    NFTs
+                                                    <NftIcon />
+                                                </NavHeader>
+                                            )}
                                             {location.pathname ===
                                                 '/contacts' && (
                                                 <NavHeader>
@@ -337,6 +345,10 @@
                                                     path="/wallets"
                                                     element={<Wallets />}
                                                 />
+                                                <Route
+                                                    path="/nfts"
+                                                    element={<Nfts />}
+                                                />
                                                 <Route
                                                     path="/contacts"
                                                     element={<Contacts />}
@@ -448,6 +460,14 @@
                                         <p>Wallets</p>
                                         <BankIcon />
                                     </NavItem>
+                                    <NavItem
+                                        active={location.pathname === '/nfts'}
+                                        onClick={() => navigate('/nfts')}
+                                    >
+                                        {' '}
+                                        <p>NFTs</p>
+                                        <NftIcon />
+                                    </NavItem>
                                     <NavItem
                                         active={
                                             location.pathname === '/contacts'
diff --git a/cashtab/src/components/Common/CustomIcons.js b/cashtab/src/components/Common/CustomIcons.js
--- a/cashtab/src/components/Common/CustomIcons.js
+++ b/cashtab/src/components/Common/CustomIcons.js
@@ -44,6 +44,7 @@
 import { ReactComponent as SelfSend } from 'assets/selfsend.svg';
 import { ReactComponent as FanOut } from 'assets/fanout.svg';
 import { ReactComponent as MintNft } from 'assets/mintnft.svg';
+import { ReactComponent as Nft } from 'assets/nft.svg';
 
 import appConfig from 'config/app';
 
@@ -212,6 +213,7 @@
 export const SelfSendIcon = () => <SelfSend title="Self Send" />;
 export const FanOutIcon = () => <FanOut title="Fan Out" />;
 export const MintNftIcon = () => <MintNft title="Mint NFT" />;
+export const NftIcon = () => <Nft title="NFT" />;
 
 const GithubIconWrapper = styled.div`
     svg {
diff --git a/cashtab/src/components/Common/Inputs.js b/cashtab/src/components/Common/Inputs.js
--- a/cashtab/src/components/Common/Inputs.js
+++ b/cashtab/src/components/Common/Inputs.js
@@ -126,6 +126,12 @@
 const SendXecDropdown = styled(CurrencyDropdown)`
     width: 100px;
 `;
+
+const SellPriceDropdown = styled(CurrencyDropdown)`
+    width: 100px;
+    border-radius: 0 9px 9px 0;
+`;
+
 const CurrencyOption = styled.option`
     text-align: left;
     background-color: ${props => props.theme.forms.selectionBackground};
@@ -433,6 +439,61 @@
     handleOnMax: PropTypes.func,
 };
 
+export const ListPriceInput = ({
+    name = 'listPriceInput',
+    placeholder = 'listPriceInput',
+    value = 0,
+    inputDisabled = false,
+    selectValue = '',
+    selectDisabled = false,
+    fiatCode = 'USD',
+    error = false,
+    handleInput,
+    handleSelect,
+}) => {
+    return (
+        <CashtabInputWrapper>
+            <InputRow invalid={typeof error === 'string'}>
+                <LeftInput
+                    name={name}
+                    placeholder={placeholder}
+                    type="number"
+                    value={value}
+                    onChange={e => handleInput(e)}
+                    disabled={inputDisabled}
+                />
+                <SellPriceDropdown
+                    data-testid="currency-select-dropdown"
+                    value={selectValue}
+                    onChange={e => handleSelect(e)}
+                    disabled={selectDisabled}
+                >
+                    <CurrencyOption data-testid="xec-option" value="XEC">
+                        XEC
+                    </CurrencyOption>
+                    <CurrencyOption data-testid="fiat-option" value={fiatCode}>
+                        {fiatCode}
+                    </CurrencyOption>
+                </SellPriceDropdown>
+            </InputRow>
+            <ErrorMsg>{typeof error === 'string' ? error : ''}</ErrorMsg>
+        </CashtabInputWrapper>
+    );
+};
+
+ListPriceInput.propTypes = {
+    name: PropTypes.string,
+    placeholder: PropTypes.string,
+    value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
+    inputDisabled: PropTypes.bool,
+    selectValue: PropTypes.string,
+    selectDisabled: PropTypes.bool,
+    fiatCode: PropTypes.string,
+    error: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
+    handleInput: PropTypes.func,
+    handleSelect: PropTypes.func,
+};
+
 export const AliasInput = ({
     name = '',
     placeholder = '',
diff --git a/cashtab/src/components/Etokens/Token/index.js b/cashtab/src/components/Etokens/Token/index.js
--- a/cashtab/src/components/Etokens/Token/index.js
+++ b/cashtab/src/components/Etokens/Token/index.js
@@ -20,6 +20,7 @@
     isValidTokenSendOrBurnAmount,
     parseAddressInput,
     isValidTokenMintAmount,
+    getXecListPriceError,
 } from 'validation';
 import { formatDate } from 'utils/formatting';
 import TokenIcon from 'components/Etokens/TokenIcon';
@@ -42,8 +43,13 @@
     getNft,
     getNftChildSendTargetOutputs,
 } from 'slpv1';
-import { sendXec } from 'transactions';
-import { hasEnoughToken, decimalizeTokenAmount } from 'wallet';
+import { sendXec, ignoreUnspendableUtxos } from 'transactions';
+import {
+    hasEnoughToken,
+    decimalizeTokenAmount,
+    toSatoshis,
+    toXec,
+} from 'wallet';
 import Modal from 'components/Common/Modal';
 import { toast } from 'react-toastify';
 import {
@@ -51,6 +57,7 @@
     SendTokenInput,
     ModalInput,
     InputFlex,
+    ListPriceInput,
 } from 'components/Common/Inputs';
 import { QuestionIcon } from 'components/Common/CustomIcons';
 import { decimalizedTokenQtyToLocaleFormat } from 'utils/formatting';
@@ -80,6 +87,7 @@
     NftTokenIdAndCopyIcon,
     NftNameTitle,
     NftCollectionTitle,
+    ListPricePreview,
 } from 'components/Etokens/Token/styled';
 import CreateTokenForm from 'components/Etokens/CreateTokenForm';
 import {
@@ -87,6 +95,19 @@
     getChildNftsFromParent,
     getTokenGenesisInfo,
 } from 'chronik';
+import { supportedFiatCurrencies } from 'config/cashtabSettings';
+import {
+    slpSend,
+    SLP_NFT1_CHILD,
+    Script,
+    TxBuilder,
+    fromHex,
+    shaRmd160,
+    P2PKHSignatory,
+    ALL_BIP143,
+} from 'ecash-lib';
+import { AgoraOneshot, AgoraOneshotAdSignatory } from 'ecash-agora';
+import * as wif from 'wif';
 
 const Token = () => {
     let navigate = useNavigate();
@@ -98,6 +119,7 @@
         ecc,
         chaintipBlockheight,
         loading,
+        fiatPrice,
     } = useContext(WalletContext);
     const { settings, wallets, cashtabCache } = cashtabState;
     const wallet = wallets.length > 0 ? wallets[0] : false;
@@ -197,6 +219,9 @@
     const [confirmationOfEtokenToBeBurnt, setConfirmationOfEtokenToBeBurnt] =
         useState('');
     const [aliasInputAddress, setAliasInputAddress] = useState(false);
+    const [selectedCurrency, setSelectedCurrency] = useState(appConfig.ticker);
+    const [nftListPriceError, setNftListPriceError] = useState(false);
+    const [showConfirmListNft, setShowConfirmListNft] = useState(false);
 
     // By default, we load the app with all switches disabled
     // For SLP v1 tokens, we want showSend to be enabled by default
@@ -208,6 +233,7 @@
         showMint: false,
         showFanout: false,
         showMintNft: false,
+        showSellNft: false,
     };
     const [switches, setSwitches] = useState(switchesOff);
     const [showLargeIconModal, setShowLargeIconModal] = useState(false);
@@ -235,6 +261,7 @@
         address: '',
         burnAmount: '',
         mintAmount: '',
+        nftListPrice: null,
     };
 
     const [formData, setFormData] = useState(emptyFormData);
@@ -343,6 +370,12 @@
         }
     }, [tokenId]);
 
+    useEffect(() => {
+        console.log(`fiatPrice useEffect`, fiatPrice);
+        // Clear NFT list price and de-select fiat currency if rate is unavailable
+        handleSelectedCurrencyChange({ target: { value: 'XEC' } });
+    }, [fiatPrice]);
+
     const getNfts = async tokenId => {
         const nftParentTxHistory = await getAllTxHistoryByTokenId(
             chronik,
@@ -846,6 +879,290 @@
         }
     };
 
+    const handleSelectedCurrencyChange = e => {
+        setSelectedCurrency(e.target.value);
+        // Clear NFT price input field to prevent unit confusion
+        // User must re-specify price in new units
+        setFormData(p => ({
+            ...p,
+            nftListPrice: '',
+        }));
+    };
+
+    const handleNftListPriceChange = e => {
+        const { name, value } = e.target;
+        setNftListPriceError(getXecListPriceError(value));
+        setFormData(p => ({
+            ...p,
+            [name]: value,
+        }));
+    };
+
+    const listNft = async () => {
+        console.log(`listNft`);
+        const CASHTAB_WALLET_PATH = 1899;
+        const listPriceSatoshis =
+            selectedCurrency === appConfig.ticker
+                ? BigInt(toSatoshis(formData.nftListPrice))
+                : BigInt(
+                      toSatoshis(
+                          // Just round to the nearest XEC here
+                          Math.ceil(
+                              parseFloat(formData.nftListPrice) / fiatPrice,
+                          ),
+                      ),
+                  );
+        console.log(`listNft called with listPriceSatoshis`, listPriceSatoshis);
+        const satsPerKb =
+            settings.minFeeSends &&
+            (hasEnoughToken(
+                tokens,
+                appConfig.vipTokens.grumpy.tokenId,
+                appConfig.vipTokens.grumpy.vipBalance,
+            ) ||
+                hasEnoughToken(
+                    tokens,
+                    appConfig.vipTokens.cachet.tokenId,
+                    appConfig.vipTokens.cachet.vipBalance,
+                ))
+                ? appConfig.minFee
+                : appConfig.defaultFee;
+        // Send to ad setup output
+        // Send to p2sh
+
+        // Can we do this in 1 function? this is a bit much to hit the user with.
+
+        // Build the ad tx
+        // The advertisement tx is an SLP send tx of the listed NFT to the seller's wallet
+        // Instead of dust, the output holding the NFT is the asking price
+        // TODO what if we want to sell it for more eCash than the user holds?
+
+        // TODO make a function for the ad tx in a new file, ecash-agora
+        // implement it here
+        // Need public key for cancelling listing
+        const sellerSk = wif.decode(
+            wallet.paths.get(CASHTAB_WALLET_PATH).wif,
+        ).privateKey;
+
+        const sellerPk = ecc.derivePubkey(sellerSk);
+        const sellerP2pkh = Script.p2pkh(
+            fromHex(wallet.paths.get(CASHTAB_WALLET_PATH).hash),
+        );
+
+        const enforcedOutputs = [
+            {
+                value: 0n,
+                script: slpSend(tokenId, SLP_NFT1_CHILD, [0, 1]),
+            },
+            {
+                value: listPriceSatoshis,
+                script: Script.p2pkh(
+                    fromHex(wallet.paths.get(CASHTAB_WALLET_PATH).hash),
+                ),
+            },
+        ];
+
+        const agoraOneshot = new AgoraOneshot({
+            enforcedOutputs,
+            cancelPk: sellerPk,
+        });
+        const agoraAdScript = agoraOneshot.adScript();
+        const agoraAdP2sh = Script.p2sh(shaRmd160(agoraAdScript.bytecode));
+
+        // Input needs to be the child NFT utxo with appropriate signData
+        // Get the NFT utxo from Cashtab wallet
+        const [thisNftUtxo] = getNft(tokenId, wallet.state.slpUtxos);
+        console.log(`thisNftUtxo`, thisNftUtxo);
+
+        // TODO we need utxos sufficient to send dust
+        // Really we want a utxo big enough for 2 txs so we can then use the change utxo
+        // of the ad setup tx in the next tx
+        // For now, arbitrarily say we need 5,000 XEC to list an NFT
+        const NFT_LIST_MIN_SPENDABLE_SATS = 500000;
+        const spendableUtxos = ignoreUnspendableUtxos(
+            wallet.state.nonSlpUtxos,
+            chaintipBlockheight,
+        );
+        let inputSatoshis = 0;
+        const adSetupTxInputs = [];
+        let sufficientFundsToListNft = false;
+        for (const utxo of spendableUtxos) {
+            const sk = wif.decode(wallet.paths.get(utxo.path).wif).privateKey;
+            const pk = ecc.derivePubkey(sk);
+            adSetupTxInputs.push({
+                input: {
+                    prevOut: utxo.outpoint,
+                    signData: {
+                        value: utxo.value,
+                        // Cashtab inputs will always be p2pkh utxos
+                        outputScript: Script.p2pkh(
+                            fromHex(wallet.paths.get(utxo.path).hash),
+                        ),
+                    },
+                },
+                signatory: P2PKHSignatory(sk, pk, ALL_BIP143),
+            });
+            inputSatoshis += utxo.value;
+            if (inputSatoshis >= NFT_LIST_MIN_SPENDABLE_SATS) {
+                sufficientFundsToListNft = true;
+                break;
+            }
+        }
+
+        if (!sufficientFundsToListNft) {
+            const fundsError = `Insufficient funds to list NFT. Must have at least ${NFT_LIST_MIN_SPENDABLE_SATS} spendable XEC`;
+            toast.error(fundsError);
+            return;
+        }
+
+        // Add the ad setup p2sh input
+        adSetupTxInputs.push({
+            input: {
+                prevOut: {
+                    txid: thisNftUtxo.outpoint.txid,
+                    outIdx: thisNftUtxo.outpoint.outIdx,
+                },
+                signData: {
+                    value: appConfig.dustSats,
+                    outputScript: sellerP2pkh,
+                },
+            },
+            signatory: P2PKHSignatory(sellerSk, sellerPk, ALL_BIP143),
+        });
+
+        console.log(`adSetupTxInputs`, adSetupTxInputs);
+
+        const txBuildAdSetup = new TxBuilder({
+            inputs: adSetupTxInputs,
+            outputs: [
+                {
+                    value: 0,
+                    script: slpSend(tokenId, SLP_NFT1_CHILD, [1]),
+                },
+                { value: appConfig.dustSats, script: agoraAdP2sh },
+                // Change output (will always have change if inputs are >= 5000 XEC)
+                sellerP2pkh,
+            ],
+        });
+        const adSetupTx = txBuildAdSetup.sign(
+            ecc,
+            satsPerKb,
+            appConfig.dustSats,
+        );
+        console.log(`adSetupTx`, adSetupTx);
+        const adSetupTxChangeOutput = adSetupTx.outputs[2];
+        console.log(`adSetupTxChangeOutput`, adSetupTxChangeOutput);
+
+        // Broadcast the ad setup tx
+        let adSetupTxid;
+        try {
+            adSetupTxid = (await chronik.broadcastTx(adSetupTx.ser())).txid;
+
+            toast(
+                <TokenSentLink
+                    href={`${explorer.blockExplorerUrl}/tx/${adSetupTxid}`}
+                    target="_blank"
+                    rel="noopener noreferrer"
+                >
+                    Created NFT ad
+                </TokenSentLink>,
+                {
+                    icon: <TokenIcon size={32} tokenId={tokenId} />,
+                },
+            );
+        } catch (err) {
+            console.error(`Error creating NFT listing ad`, err);
+            toast.error(`Error creating NFT listing ad: ${err}`);
+            // Do not attempt to list the NFT if the ad tx fails
+            return;
+        }
+
+        // TODO should "set up ad" and "list nft" be two manual steps...?
+        // better to automate this. edge case if chronik fails halfway, may never happen
+
+        // Seller finishes offer setup + sends NFT to the advertised P2SH
+        const agoraScript = agoraOneshot.script();
+        const agoraP2sh = Script.p2sh(shaRmd160(agoraScript.bytecode));
+        const txBuildOffer = new TxBuilder({
+            inputs: [
+                // The actual NFT
+                {
+                    input: {
+                        prevOut: {
+                            // Since we just broadcast the ad tx and know how it was built,
+                            // this prevOut will always look like this
+                            txid: adSetupTxid,
+                            outIdx: 1,
+                        },
+                        signData: {
+                            value: appConfig.dustSats,
+                            redeemScript: agoraAdScript,
+                        },
+                    },
+                    signatory: AgoraOneshotAdSignatory(sellerSk),
+                },
+                // Some eCash to help the tx on its way
+                {
+                    input: {
+                        prevOut: {
+                            // Since we just broadcast the ad tx and know how it was built,
+                            // this prevOut will always look like this
+                            txid: adSetupTxid,
+                            // The change output from adSetupTxid
+                            outIdx: 2,
+                        },
+                        signData: {
+                            value: adSetupTxChangeOutput.value,
+                            // Cashtab inputs will always be p2pkh utxos
+                            outputScript: adSetupTxChangeOutput.script,
+                        },
+                    },
+                    signatory: P2PKHSignatory(sellerSk, sellerPk, ALL_BIP143),
+                },
+            ],
+            outputs: [
+                {
+                    value: 0,
+                    script: slpSend(tokenId, SLP_NFT1_CHILD, [1]),
+                },
+                { value: 546, script: agoraP2sh },
+                // Change output (will always have change if inputs of ad tx >= 5000 XEC)
+                sellerP2pkh,
+            ],
+        });
+        const offerTx = txBuildOffer.sign(ecc, satsPerKb, appConfig.dustSats);
+        console.log(`offerTx`, offerTx);
+
+        let offerTxid;
+        try {
+            offerTxid = (await chronik.broadcastTx(offerTx.ser())).txid;
+
+            toast(
+                <TokenSentLink
+                    href={`${explorer.blockExplorerUrl}/tx/${offerTxid}`}
+                    target="_blank"
+                    rel="noopener noreferrer"
+                >
+                    NFT listed for{' '}
+                    {toXec(parseInt(listPriceSatoshis)).toLocaleString(
+                        userLocale,
+                        {
+                            minimumFractionDigits: 2,
+                            maximumFractionDigits: 2,
+                        },
+                    )}{' '}
+                    XEC
+                </TokenSentLink>,
+                {
+                    icon: <TokenIcon size={32} tokenId={tokenId} />,
+                },
+            );
+        } catch (err) {
+            console.error(`Error listing NFT`, err);
+            toast.error(`Error listing NFT: ${err}`);
+        }
+    };
+
     return (
         <>
             {tokenBalance &&
@@ -970,6 +1287,72 @@
                                 />
                             </Modal>
                         )}
+                        {showConfirmListNft && formData.nftListPrice !== '' && (
+                            <Modal
+                                title={`List ${tokenTicker} for ${
+                                    selectedCurrency === appConfig.ticker
+                                        ? `${parseFloat(
+                                              formData.nftListPrice,
+                                          ).toLocaleString(userLocale)}
+                                                        XEC (${
+                                                            settings
+                                                                ? `${
+                                                                      supportedFiatCurrencies[
+                                                                          settings
+                                                                              .fiatCurrency
+                                                                      ].symbol
+                                                                  } `
+                                                                : '$ '
+                                                        }${(
+                                              parseFloat(
+                                                  formData.nftListPrice,
+                                              ) * fiatPrice
+                                          ).toLocaleString(userLocale, {
+                                              minimumFractionDigits:
+                                                  appConfig.cashDecimals,
+                                              maximumFractionDigits:
+                                                  appConfig.cashDecimals,
+                                          })} ${
+                                              settings && settings.fiatCurrency
+                                                  ? settings.fiatCurrency.toUpperCase()
+                                                  : 'USD'
+                                          })?`
+                                        : `${
+                                              settings
+                                                  ? `${
+                                                        supportedFiatCurrencies[
+                                                            settings
+                                                                .fiatCurrency
+                                                        ].symbol
+                                                    } `
+                                                  : '$ '
+                                          }${parseFloat(
+                                              formData.nftListPrice,
+                                          ).toLocaleString(userLocale)} ${
+                                              settings && settings.fiatCurrency
+                                                  ? settings.fiatCurrency.toUpperCase()
+                                                  : 'USD'
+                                          } (${(
+                                              parseFloat(
+                                                  formData.nftListPrice,
+                                              ) / fiatPrice
+                                          ).toLocaleString(userLocale, {
+                                              minimumFractionDigits:
+                                                  appConfig.cashDecimals,
+                                              maximumFractionDigits:
+                                                  appConfig.cashDecimals,
+                                          })}
+                                                        XEC)?`
+                                }`}
+                                handleOk={listNft}
+                                handleCancel={() =>
+                                    setShowConfirmListNft(false)
+                                }
+                                showCancelButton
+                                description={`This will create a sell offer. Your NFT is only transferred if your full price is paid. The price is fixed in XEC. If your NFT is not purchased, you can cancel or renew your listing at any time.`}
+                                height={275}
+                            />
+                        )}
                         {renderedTokenType === 'NFT' ? (
                             <>
                                 <NftNameTitle>{tokenName}</NftNameTitle>
@@ -1230,6 +1613,157 @@
 
                         {isSupportedToken && (
                             <SendTokenForm title="Token Actions">
+                                {isNftChild && (
+                                    <>
+                                        <SwitchHolder>
+                                            <Switch
+                                                name="Toggle Sell"
+                                                on="💰"
+                                                off="💰"
+                                                checked={switches.showSellNft}
+                                                handleToggle={() => {
+                                                    // We turn everything else off, whether we are turning this one on or off
+                                                    setSwitches({
+                                                        ...switchesOff,
+                                                        showSellNft:
+                                                            !switches.showSellNft,
+                                                    });
+                                                }}
+                                            />
+                                            <SwitchLabel>
+                                                Sell {tokenName} ({tokenTicker})
+                                            </SwitchLabel>
+                                        </SwitchHolder>
+                                        {switches.showSellNft && (
+                                            <>
+                                                <SendTokenFormRow>
+                                                    <InputRow>
+                                                        <ListPriceInput
+                                                            name="nftListPrice"
+                                                            placeholder="Enter NFT list price"
+                                                            value={
+                                                                formData.nftListPrice
+                                                            }
+                                                            selectValue={
+                                                                selectedCurrency
+                                                            }
+                                                            selectDisabled={
+                                                                fiatPrice ===
+                                                                null
+                                                            }
+                                                            fiatCode={settings.fiatCurrency.toUpperCase()}
+                                                            error={
+                                                                nftListPriceError
+                                                            }
+                                                            handleInput={
+                                                                handleNftListPriceChange
+                                                            }
+                                                            handleSelect={
+                                                                handleSelectedCurrencyChange
+                                                            }
+                                                        ></ListPriceInput>
+                                                    </InputRow>
+                                                </SendTokenFormRow>
+                                                {!nftListPriceError &&
+                                                    formData.nftListPrice !==
+                                                        '' &&
+                                                    fiatPrice !== null && (
+                                                        <ListPricePreview>
+                                                            {selectedCurrency ===
+                                                            appConfig.ticker
+                                                                ? `${parseFloat(
+                                                                      formData.nftListPrice,
+                                                                  ).toLocaleString(
+                                                                      userLocale,
+                                                                  )}
+                                                        XEC = ${
+                                                            settings
+                                                                ? `${
+                                                                      supportedFiatCurrencies[
+                                                                          settings
+                                                                              .fiatCurrency
+                                                                      ].symbol
+                                                                  } `
+                                                                : '$ '
+                                                        }${(
+                                                                      parseFloat(
+                                                                          formData.nftListPrice,
+                                                                      ) *
+                                                                      fiatPrice
+                                                                  ).toLocaleString(
+                                                                      userLocale,
+                                                                      {
+                                                                          minimumFractionDigits:
+                                                                              appConfig.cashDecimals,
+                                                                          maximumFractionDigits:
+                                                                              appConfig.cashDecimals,
+                                                                      },
+                                                                  )} ${
+                                                                      settings &&
+                                                                      settings.fiatCurrency
+                                                                          ? settings.fiatCurrency.toUpperCase()
+                                                                          : 'USD'
+                                                                  }`
+                                                                : `${
+                                                                      settings
+                                                                          ? `${
+                                                                                supportedFiatCurrencies[
+                                                                                    settings
+                                                                                        .fiatCurrency
+                                                                                ]
+                                                                                    .symbol
+                                                                            } `
+                                                                          : '$ '
+                                                                  }${parseFloat(
+                                                                      formData.nftListPrice,
+                                                                  ).toLocaleString(
+                                                                      userLocale,
+                                                                  )} ${
+                                                                      settings &&
+                                                                      settings.fiatCurrency
+                                                                          ? settings.fiatCurrency.toUpperCase()
+                                                                          : 'USD'
+                                                                  } = ${(
+                                                                      parseFloat(
+                                                                          formData.nftListPrice,
+                                                                      ) /
+                                                                      fiatPrice
+                                                                  ).toLocaleString(
+                                                                      userLocale,
+                                                                      {
+                                                                          minimumFractionDigits:
+                                                                              appConfig.cashDecimals,
+                                                                          maximumFractionDigits:
+                                                                              appConfig.cashDecimals,
+                                                                      },
+                                                                  )}
+                                                        XEC`}
+                                                        </ListPricePreview>
+                                                    )}
+                                                <SendTokenFormRow>
+                                                    <PrimaryButton
+                                                        style={{
+                                                            marginTop: '12px',
+                                                        }}
+                                                        disabled={
+                                                            apiError ||
+                                                            nftListPriceError ||
+                                                            formData.nftListPrice ===
+                                                                ''
+                                                        }
+                                                        onClick={() =>
+                                                            setShowConfirmListNft(
+                                                                true,
+                                                            )
+                                                        }
+                                                    >
+                                                        List {tokenName}
+                                                    </PrimaryButton>
+                                                </SendTokenFormRow>
+                                            </>
+                                        )}
+                                    </>
+                                )}
                                 {!isNftParent && (
                                     <>
                                         <SwitchHolder>
diff --git a/cashtab/src/components/Etokens/Token/styled.js b/cashtab/src/components/Etokens/Token/styled.js
--- a/cashtab/src/components/Etokens/Token/styled.js
+++ b/cashtab/src/components/Etokens/Token/styled.js
@@ -175,3 +175,8 @@
     color: ${props => props.theme.contrast};
     word-break: break-all;
 `;
+
+export const ListPricePreview = styled.div`
+    text-align: center;
+    color: ${props => props.theme.contrast};
+`;
diff --git a/cashtab/src/components/Nfts/__tests__/index.test.js b/cashtab/src/components/Nfts/__tests__/index.test.js
new file mode 100644
--- /dev/null
+++ b/cashtab/src/components/Nfts/__tests__/index.test.js
@@ -0,0 +1,151 @@
+// 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 React from 'react';
+import {
+    EtokensWalletMock,
+    EtokensStoredCashtabCache,
+} from 'components/Etokens/fixtures/mocks';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import '@testing-library/jest-dom';
+import {
+    initializeCashtabStateForTests,
+    clearLocalForage,
+} from 'components/App/fixtures/helpers';
+import 'fake-indexeddb/auto';
+import localforage from 'localforage';
+import { when } from 'jest-when';
+import appConfig from 'config/app';
+import CashtabTestWrapper from 'components/App/fixtures/CashtabTestWrapper';
+
+// https://stackoverflow.com/questions/39830580/jest-test-fails-typeerror-window-matchmedia-is-not-a-function
+Object.defineProperty(window, 'matchMedia', {
+    writable: true,
+    value: jest.fn().mockImplementation(query => ({
+        matches: false,
+        media: query,
+        onchange: null,
+        addListener: jest.fn(), // Deprecated
+        removeListener: jest.fn(), // Deprecated
+        addEventListener: jest.fn(),
+        removeEventListener: jest.fn(),
+        dispatchEvent: jest.fn(),
+    })),
+});
+
+// https://stackoverflow.com/questions/64813447/cannot-read-property-addlistener-of-undefined-react-testing-library
+window.matchMedia = query => ({
+    matches: false,
+    media: query,
+    onchange: null,
+    addListener: jest.fn(), // deprecated
+    removeListener: jest.fn(), // deprecated
+    addEventListener: jest.fn(),
+    removeEventListener: jest.fn(),
+    dispatchEvent: jest.fn(),
+});
+
+describe('<Etokens />', () => {
+    beforeEach(() => {
+        // Mock the fetch call for Cashtab's price API
+        global.fetch = jest.fn();
+        const fiatCode = 'usd'; // Use usd until you mock getting settings from localforage
+        const cryptoId = appConfig.coingeckoId;
+        // Keep this in the code, because different URLs will have different outputs requiring different parsing
+        const priceApiUrl = `https://api.coingecko.com/api/v3/simple/price?ids=${cryptoId}&vs_currencies=${fiatCode}&include_last_updated_at=true`;
+        const xecPrice = 0.00003;
+        const priceResponse = {
+            ecash: {
+                usd: xecPrice,
+                last_updated_at: 1706644626,
+            },
+        };
+        when(fetch)
+            .calledWith(priceApiUrl)
+            .mockResolvedValue({
+                json: () => Promise.resolve(priceResponse),
+            });
+    });
+    afterEach(async () => {
+        jest.clearAllMocks();
+        await clearLocalForage(localforage);
+    });
+    it('Large token list is rendered and searchable', async () => {
+        const mockedChronik = await initializeCashtabStateForTests(
+            EtokensWalletMock,
+            localforage,
+        );
+
+        // Set a big token cache
+        await localforage.setItem('cashtabCache', EtokensStoredCashtabCache);
+
+        render(<CashtabTestWrapper chronik={mockedChronik} route="/etokens" />);
+
+        // The Etokens page is rendered
+        expect(await screen.findByTitle('Wallet Tokens')).toBeInTheDocument();
+
+        // Wait for loader to be gone
+        await waitFor(() =>
+            expect(
+                screen.queryByTitle('Loading tokens'),
+            ).not.toBeInTheDocument(),
+        );
+
+        const renderedTokens = screen.getAllByTitle('Token List Item');
+
+        // We render all 55 tokens
+        expect(renderedTokens.length).toBe(55);
+        // Tokens are sorted alphabetically
+        expect(renderedTokens[0]).toHaveTextContent('223');
+        expect(renderedTokens[1]).toHaveTextContent('ABC');
+        expect(renderedTokens[2]).toHaveTextContent('Alita');
+
+        // We can search for a token by ticker
+        const searchInput = screen.getByPlaceholderText(
+            'Start typing a token ticker or name',
+        );
+        await userEvent.type(searchInput, 'VSP');
+
+        // Now only one token is rendered
+        expect(screen.getAllByTitle('Token List Item').length).toBe(1);
+        // The lone rendered token is what we searched for
+        expect(screen.getByTitle('Token List Item')).toHaveTextContent('VSP');
+
+        // The search is not case sensitive
+        await userEvent.clear(searchInput);
+        await userEvent.type(searchInput, 'vsp');
+
+        // Now only one token is rendered
+        expect(screen.getAllByTitle('Token List Item').length).toBe(1);
+        // The lone rendered token is what we searched for
+        expect(screen.getByTitle('Token List Item')).toHaveTextContent('VSP');
+
+        // We can also search by the name
+        await userEvent.clear(searchInput);
+        await userEvent.type(searchInput, 'vespene gas');
+
+        // We get the same token, and only this token
+        expect(screen.getAllByTitle('Token List Item').length).toBe(1);
+        expect(screen.getByTitle('Token List Item')).toHaveTextContent('VSP');
+
+        // We can also search by the name
+        await userEvent.clear(searchInput);
+        await userEvent.type(searchInput, 'vespene gas');
+
+        // We get the same token, and only this token
+        expect(screen.getAllByTitle('Token List Item').length).toBe(1);
+        expect(screen.getByTitle('Token List Item')).toHaveTextContent('VSP'); // We can also search by the name
+
+        // We get expected msg if our search has no results
+        await userEvent.clear(searchInput);
+        await userEvent.type(searchInput, 'zz');
+
+        // No tokens are found
+        expect(screen.queryByTitle('Token List Item')).not.toBeInTheDocument();
+
+        // We get expected msg for no search result
+        expect(screen.getByText('No tokens matching zz')).toBeInTheDocument();
+    });
+});
diff --git a/cashtab/src/components/Nfts/index.js b/cashtab/src/components/Nfts/index.js
new file mode 100644
--- /dev/null
+++ b/cashtab/src/components/Nfts/index.js
@@ -0,0 +1,631 @@
+// 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 React, { useState, useEffect } from 'react';
+import { Link } from 'react-router-dom';
+import { WalletContext } from 'wallet/context';
+import { LoadingCtn } from 'components/Common/Atoms';
+import { Input } from 'components/Common/Inputs';
+import { getAllTxHistoryByLokadId, getTokenGenesisInfo } from 'chronik';
+import {
+    parseAgoraTx,
+    AgoraOneshotCancelSignatory,
+    AgoraOneshotSignatory,
+} from 'ecash-agora';
+import { Script, fromHex } from 'ecash-lib';
+import { getTokenOfferMaps } from 'agora';
+import {
+    NftsCtn,
+    OfferTitle,
+    OfferTable,
+    OfferCol,
+    OfferRow,
+    CancelBtn,
+    BuyBtn,
+} from './styled';
+import {
+    NftTokenIdAndCopyIcon,
+    TokenIconExpandButton,
+    TokenSentLink,
+} from 'components/Etokens/Token/styled';
+import { getWalletState } from 'utils/cashMethods';
+import TokenIcon from 'components/Etokens/TokenIcon';
+import { explorer } from 'config/explorer';
+import { CopyIconButton } from 'components/Common/Buttons';
+import Modal from 'components/Common/Modal';
+import { toXec, hasEnoughToken } from 'wallet';
+import { getUserLocale } from 'helpers';
+import { supportedFiatCurrencies } from 'config/cashtabSettings';
+import * as wif from 'wif';
+import { getNftChildSendTargetOutputs } from 'slpv1';
+import { finalizeAndBroadcastTx } from 'transactions';
+import appConfig from 'config/app';
+import { toast } from 'react-toastify';
+
+const Nfts = () => {
+    const userLocale = getUserLocale(navigator);
+    const ContextValue = React.useContext(WalletContext);
+    const {
+        ecc,
+        fiatPrice,
+        loading,
+        chronik,
+        cashtabState,
+        chaintipBlockheight,
+    } = ContextValue;
+    const { wallets, settings, cashtabCache } = cashtabState;
+    const wallet = wallets.length > 0 ? wallets[0] : false;
+    const walletState = getWalletState(wallet);
+    const { tokens } = walletState;
+
+    const [nftSearch, setNftSearch] = useState('');
+
+    // renderedTokens is a subset of tokensInWallet, filtered by the user's search query
+    const [myListingsMap, setMyListingsMap] = useState(null);
+    const [offeredListingsMap, setOfferedListingsMap] = useState(null);
+    const [showLargeNftIcon, setShowLargeNftIcon] = useState('');
+    const [cancelListingTokenId, setCancelListingTokenId] = useState('');
+    const [buyListingTokenId, setBuyListingTokenId] = useState('');
+    const [activePublicKey, setActivePublicKey] = useState(null);
+
+    // TODO need a spinner while loading all the NFTs and getting token info
+    const getListedNfts = async sellerPk => {
+        const AGORA_LOKAD = '41475230';
+        // Get all listed NFTs
+        const listedNfts = await getAllTxHistoryByLokadId(chronik, AGORA_LOKAD);
+        console.log(`listedNfts`, listedNfts);
+
+        // Parse all listed NFTs for agora txs
+        const agoraTxs = listedNfts.map(parseAgoraTx);
+        console.log(`agoraTxs`, agoraTxs);
+
+        // TODO parse which offers are sold / canceled / available, interesting to browse this
+        const { myListings, offeredListings } = getTokenOfferMaps(
+            agoraTxs,
+            sellerPk,
+        );
+        console.log(`myListings`, myListings);
+        console.log(`offeredListings`, offeredListings);
+
+        setMyListingsMap(myListings);
+        setOfferedListingsMap(offeredListings);
+
+        // We may not have offered listings cached
+        // In this case, we need to get token info from chronik and cache it
+        for (const key of offeredListings.keys()) {
+            console.log(`key`, key);
+            const thisTokenId = key;
+            let tokenCache = cashtabCache.tokens;
+            let thisTokenCachedInfo = tokenCache.get(thisTokenId);
+            console.log(`thisTokenCachedInfo`, thisTokenCachedInfo);
+            if (typeof thisTokenCachedInfo === 'undefined') {
+                console.log(`we have not cached this token, getting info`);
+                // If we have not cached this token before, cache it
+                thisTokenCachedInfo = await getTokenGenesisInfo(
+                    chronik,
+                    thisTokenId,
+                );
+                tokenCache.set(thisTokenId, thisTokenCachedInfo);
+            }
+
+            // TODO update cashtabCache if we need to
+        }
+
+        // We need to get token info for all displayed tokens
+        // TODO evaluate if this is too much to cache
+        // TODO we need to cap what we display and paginate so we aren't doing this with thousands of tokens
+        // TODO tradeoff as we still want to load more than we display since searchability is important
+
+        // TODO tiled display of listed NFTs, leverage what you already have for NFT collection display
+        // TODO can you organize NFTs by collection
+    };
+
+    useEffect(() => {
+        // TODO need a spinner until we switch wallets, get the new public key, and load the listings
+        if (!wallet) {
+            // We only load logic if the user has an active wallet
+            return;
+        }
+        // TODO define SK in wallet in ecash-lib-friendly format
+        const sellerSk = wif.decode(wallet.paths.get(1899).wif).privateKey;
+        const sellerPk = ecc.derivePubkey(sellerSk);
+        setActivePublicKey(sellerPk);
+        // TODO you must also reload NFTs when the wallet changes
+
+        // On page load, look up all advertised NFTs
+        getListedNfts(sellerPk);
+    }, [wallet]);
+
+    const buyListing = async () => {
+        console.log(`buyListing called`);
+        // Get info you need from the parsed Agora tx
+        // The input is in the txBuilderInput key of the parsed Agora tx
+        const { params, txBuilderInput } =
+            offeredListingsMap.get(buyListingTokenId);
+        console.log(`params`, params);
+        const buyNftInputs = [
+            {
+                input: txBuilderInput,
+                signatory: AgoraOneshotSignatory(
+                    wif.decode(wallet.paths.get(1899).wif).privateKey,
+                    activePublicKey,
+                    params.enforcedOutputs.length,
+                ),
+            },
+        ];
+
+        console.log(`buyNftInputs`, buyNftInputs);
+
+        const buyNftTargetOutputs = [
+            // enforcedOutputs are
+            // index 0, slpSend script for the NFT, coloring output at index 2 as new NFT utxo
+            // index 1, the payment to the seller
+            ...params.enforcedOutputs,
+            // index 3, the colored utxo holding the NFT at the buyer's address
+            {
+                value: BigInt(appConfig.dustSats),
+                script: Script.p2pkh(fromHex(wallet.paths.get(1899).hash)),
+            },
+        ];
+
+        console.log(`buyNftTargetOutputs`, buyNftTargetOutputs);
+        const { response } = await finalizeAndBroadcastTx(
+            chronik,
+            ecc,
+            wallet,
+            buyNftTargetOutputs,
+            settings.minFeeSends &&
+                (hasEnoughToken(
+                    tokens,
+                    appConfig.vipTokens.grumpy.tokenId,
+                    appConfig.vipTokens.grumpy.vipBalance,
+                ) ||
+                    hasEnoughToken(
+                        tokens,
+                        appConfig.vipTokens.cachet.tokenId,
+                        appConfig.vipTokens.cachet.vipBalance,
+                    ))
+                ? appConfig.minFee
+                : appConfig.defaultFee,
+            chaintipBlockheight,
+            buyNftInputs,
+        );
+        console.log(`buyNft resp`, response);
+
+        toast(
+            <TokenSentLink
+                href={`${explorer.blockExplorerUrl}/tx/${response.txid}`}
+                target="_blank"
+                rel="noopener noreferrer"
+            >
+                Bought NFT
+            </TokenSentLink>,
+            {
+                icon: <TokenIcon size={32} tokenId={buyListingTokenId} />,
+            },
+        );
+
+        // TODO hide modal once it is canceled
+        // TODO toast success when it is canceled
+        setBuyListingTokenId('');
+    };
+    const cancelListing = async () => {
+        console.log(`Cancel NFT listing`);
+        // TODO hide cancel modal? notification?
+
+        // The input is in agora txs
+        const tokenInputs = [
+            {
+                input: myListingsMap.get(cancelListingTokenId).txBuilderInput,
+                signatory: AgoraOneshotCancelSignatory(
+                    wif.decode(wallet.paths.get(1899).wif).privateKey,
+                ),
+            },
+        ];
+        console.log(`tokenInputs`, tokenInputs);
+
+        // Get target outputs for sending this NFT to yourself
+        const cancelListingNftTargetOutputs = getNftChildSendTargetOutputs(
+            cancelListingTokenId,
+            wallet.paths.get(1899).address,
+        );
+
+        console.log(
+            `cancelListingNftTargetOutputs`,
+            cancelListingNftTargetOutputs,
+        );
+
+        const { response } = await finalizeAndBroadcastTx(
+            chronik,
+            ecc,
+            wallet,
+            cancelListingNftTargetOutputs,
+            settings.minFeeSends &&
+                (hasEnoughToken(
+                    tokens,
+                    appConfig.vipTokens.grumpy.tokenId,
+                    appConfig.vipTokens.grumpy.vipBalance,
+                ) ||
+                    hasEnoughToken(
+                        tokens,
+                        appConfig.vipTokens.cachet.tokenId,
+                        appConfig.vipTokens.cachet.vipBalance,
+                    ))
+                ? appConfig.minFee
+                : appConfig.defaultFee,
+            chaintipBlockheight,
+            tokenInputs,
+        );
+        console.log(`cancel response`, response);
+
+        toast(
+            <TokenSentLink
+                href={`${explorer.blockExplorerUrl}/tx/${response.txid}`}
+                target="_blank"
+                rel="noopener noreferrer"
+            >
+                Canceled listing
+            </TokenSentLink>,
+            {
+                icon: <TokenIcon size={32} tokenId={cancelListingTokenId} />,
+            },
+        );
+
+        // TODO hide modal once it is canceled
+        // TODO toast success when it is canceled
+        setCancelListingTokenId('');
+    };
+
+    const handleNftSearchInput = e => {
+        const { value } = e.target;
+        setNftSearch(value);
+
+        // make the search case insensitive
+        // const searchString = value.toLowerCase();
+
+        // TODO Filter listed NFTs by name or ticker
+
+        // Only render tokens that appear in filteredTokensInWallet
+    };
+    return (
+        <>
+            {loading ||
+            myListingsMap === null ||
+            offeredListingsMap === null ? (
+                <LoadingCtn title="Loading tokens" />
+            ) : (
+                <>
+                    {showLargeNftIcon !== '' && (
+                        <Modal
+                            height={275}
+                            showButtons={false}
+                            handleCancel={() => setShowLargeNftIcon('')}
+                        >
+                            <TokenIcon size={256} tokenId={showLargeNftIcon} />
+                        </Modal>
+                    )}
+                    {cancelListingTokenId !== '' && (
+                        <Modal
+                            height={275}
+                            handleOk={cancelListing}
+                            handleCancel={() => setCancelListingTokenId('')}
+                            showCancelButton
+                        >
+                            <TokenIcon
+                                size={128}
+                                tokenId={cancelListingTokenId}
+                            />
+                        </Modal>
+                    )}
+                    {buyListingTokenId !== '' && (
+                        <Modal
+                            height={275}
+                            handleOk={buyListing}
+                            handleCancel={() => setBuyListingTokenId('')}
+                            showCancelButton
+                        >
+                            <TokenIcon size={128} tokenId={buyListingTokenId} />
+                            Buy NFT for TODO price?
+                        </Modal>
+                    )}
+                    <NftsCtn title="Listed NFTs">
+                        <OfferTitle>Your listings</OfferTitle>
+                        {myListingsMap.size > 0 ? (
+                            <>
+                                <OfferTable>
+                                    {Array.from(myListingsMap.keys()).map(
+                                        nftTokenId => {
+                                            const cachedNftInfo =
+                                                cashtabCache.tokens.get(
+                                                    nftTokenId,
+                                                );
+                                            const thisOfferInfo =
+                                                myListingsMap.get(nftTokenId);
+
+                                            // Note that we validate for Cashtab-supported format
+                                            // In this format, the price is always the value at the
+                                            // index-1 enforced output
+                                            const priceXec = toXec(
+                                                parseInt(
+                                                    thisOfferInfo.params
+                                                        .enforcedOutputs[1]
+                                                        .value,
+                                                ),
+                                            );
+
+                                            return (
+                                                <OfferCol key={nftTokenId}>
+                                                    <OfferRow>
+                                                        <TokenIconExpandButton
+                                                            onClick={() =>
+                                                                setShowLargeNftIcon(
+                                                                    nftTokenId,
+                                                                )
+                                                            }
+                                                        >
+                                                            <TokenIcon
+                                                                size={64}
+                                                                tokenId={
+                                                                    nftTokenId
+                                                                }
+                                                            />
+                                                        </TokenIconExpandButton>
+                                                    </OfferRow>
+                                                    <OfferRow>
+                                                        <NftTokenIdAndCopyIcon>
+                                                            <a
+                                                                href={`${explorer.blockExplorerUrl}/tx/${nftTokenId}`}
+                                                                target="_blank"
+                                                                rel="noopener noreferrer"
+                                                            >
+                                                                {nftTokenId.slice(
+                                                                    0,
+                                                                    3,
+                                                                )}
+                                                                ...
+                                                                {nftTokenId.slice(
+                                                                    -3,
+                                                                )}
+                                                            </a>
+                                                            <CopyIconButton
+                                                                data={
+                                                                    nftTokenId
+                                                                }
+                                                                showToast
+                                                                customMsg={`NFT Token ID "${nftTokenId}" copied to clipboard`}
+                                                            />
+                                                        </NftTokenIdAndCopyIcon>
+                                                    </OfferRow>
+                                                    {typeof cachedNftInfo !==
+                                                        'undefined' && (
+                                                        <>
+                                                            <OfferRow>
+                                                                {typeof tokens.get(
+                                                                    nftTokenId,
+                                                                ) !==
+                                                                'undefined' ? (
+                                                                    <Link
+                                                                        to={`/token/${nftTokenId}`}
+                                                                    >
+                                                                        {
+                                                                            cachedNftInfo
+                                                                                .genesisInfo
+                                                                                .tokenName
+                                                                        }
+                                                                    </Link>
+                                                                ) : (
+                                                                    cachedNftInfo
+                                                                        .genesisInfo
+                                                                        .tokenName
+                                                                )}
+                                                            </OfferRow>
+                                                        </>
+                                                    )}
+                                                    <OfferRow>
+                                                        {priceXec.toLocaleString(
+                                                            userLocale,
+                                                        )}{' '}
+                                                        XEC
+                                                    </OfferRow>
+                                                    {fiatPrice !== null && (
+                                                        <OfferRow>
+                                                            {
+                                                                supportedFiatCurrencies[
+                                                                    settings
+                                                                        .fiatCurrency
+                                                                ].symbol
+                                                            }{' '}
+                                                            {(
+                                                                fiatPrice *
+                                                                priceXec
+                                                            ).toLocaleString(
+                                                                userLocale,
+                                                                {
+                                                                    minimumFractionDigits: 2,
+                                                                    maximumFractionDigits: 2,
+                                                                },
+                                                            )}
+                                                        </OfferRow>
+                                                    )}
+                                                    <OfferRow>
+                                                        <CancelBtn
+                                                            onClick={() =>
+                                                                setCancelListingTokenId(
+                                                                    nftTokenId,
+                                                                )
+                                                            }
+                                                        >
+                                                            Cancel
+                                                        </CancelBtn>
+                                                    </OfferRow>
+                                                </OfferCol>
+                                            );
+                                        },
+                                    )}
+                                </OfferTable>
+                            </>
+                        ) : (
+                            <p>This wallet has no listed NFTs</p>
+                        )}
+                        <OfferTitle>NFTs for sale</OfferTitle>
+                        {offeredListingsMap.size > 0 ? (
+                            <>
+                                <Input
+                                    placeholder="Start typing an NFT ticker or name"
+                                    name="nftSearch"
+                                    value={nftSearch}
+                                    handleInput={handleNftSearchInput}
+                                />
+                                {/*TODO this should be renderedListings, the search results */}
+                                {offeredListingsMap.size > 0 ? (
+                                    <>
+                                        <OfferTable>
+                                            {Array.from(
+                                                offeredListingsMap.keys(),
+                                            ).map(nftTokenId => {
+                                                const cachedNftInfo =
+                                                    cashtabCache.tokens.get(
+                                                        nftTokenId,
+                                                    );
+                                                const thisOfferInfo =
+                                                    offeredListingsMap.get(
+                                                        nftTokenId,
+                                                    );
+
+                                                // Note that we validate for Cashtab-supported format
+                                                // In this format, the price is always the value at the
+                                                // index-1 enforced output
+                                                const priceXec = toXec(
+                                                    parseInt(
+                                                        thisOfferInfo.params
+                                                            .enforcedOutputs[1]
+                                                            .value,
+                                                    ),
+                                                );
+
+                                                return (
+                                                    <OfferCol key={nftTokenId}>
+                                                        <OfferRow>
+                                                            <TokenIconExpandButton
+                                                                onClick={() =>
+                                                                    setShowLargeNftIcon(
+                                                                        nftTokenId,
+                                                                    )
+                                                                }
+                                                            >
+                                                                <TokenIcon
+                                                                    size={64}
+                                                                    tokenId={
+                                                                        nftTokenId
+                                                                    }
+                                                                />
+                                                            </TokenIconExpandButton>
+                                                        </OfferRow>
+                                                        <OfferRow>
+                                                            <NftTokenIdAndCopyIcon>
+                                                                <a
+                                                                    href={`${explorer.blockExplorerUrl}/tx/${nftTokenId}`}
+                                                                    target="_blank"
+                                                                    rel="noopener noreferrer"
+                                                                >
+                                                                    {nftTokenId.slice(
+                                                                        0,
+                                                                        3,
+                                                                    )}
+                                                                    ...
+                                                                    {nftTokenId.slice(
+                                                                        -3,
+                                                                    )}
+                                                                </a>
+                                                                <CopyIconButton
+                                                                    data={
+                                                                        nftTokenId
+                                                                    }
+                                                                    showToast
+                                                                    customMsg={`NFT Token ID "${nftTokenId}" copied to clipboard`}
+                                                                />
+                                                            </NftTokenIdAndCopyIcon>
+                                                        </OfferRow>
+                                                        {typeof cachedNftInfo !==
+                                                            'undefined' && (
+                                                            <>
+                                                                <OfferRow>
+                                                                    {typeof tokens.get(
+                                                                        nftTokenId,
+                                                                    ) !==
+                                                                    'undefined' ? (
+                                                                        <Link
+                                                                            to={`/token/${nftTokenId}`}
+                                                                        >
+                                                                            {
+                                                                                cachedNftInfo
+                                                                                    .genesisInfo
+                                                                                    .tokenName
+                                                                            }
+                                                                        </Link>
+                                                                    ) : (
+                                                                        cachedNftInfo
+                                                                            .genesisInfo
+                                                                            .tokenName
+                                                                    )}
+                                                                </OfferRow>
+                                                            </>
+                                                        )}
+                                                        <OfferRow>
+                                                            {priceXec.toLocaleString(
+                                                                userLocale,
+                                                            )}{' '}
+                                                            XEC
+                                                        </OfferRow>
+                                                        {fiatPrice !== null && (
+                                                            <OfferRow>
+                                                                {
+                                                                    supportedFiatCurrencies[
+                                                                        settings
+                                                                            .fiatCurrency
+                                                                    ].symbol
+                                                                }{' '}
+                                                                {(
+                                                                    fiatPrice *
+                                                                    priceXec
+                                                                ).toLocaleString(
+                                                                    userLocale,
+                                                                    {
+                                                                        minimumFractionDigits: 2,
+                                                                        maximumFractionDigits: 2,
+                                                                    },
+                                                                )}
+                                                            </OfferRow>
+                                                        )}
+                                                        <OfferRow>
+                                                            <BuyBtn
+                                                                onClick={() =>
+                                                                    setBuyListingTokenId(
+                                                                        nftTokenId,
+                                                                    )
+                                                                }
+                                                            >
+                                                                Buy
+                                                            </BuyBtn>
+                                                        </OfferRow>
+                                                    </OfferCol>
+                                                );
+                                            })}
+                                        </OfferTable>
+                                    </>
+                                ) : (
+                                    <p>No tokens matching {nftSearch}</p>
+                                )}
+                            </>
+                        ) : (
+                            <p>No NFTs are currently listed for sale</p>
+                        )}
+                    </NftsCtn>
+                </>
+            )}
+        </>
+    );
+};
+
+export default Nfts;
diff --git a/cashtab/src/components/Nfts/styled.js b/cashtab/src/components/Nfts/styled.js
new file mode 100644
--- /dev/null
+++ b/cashtab/src/components/Nfts/styled.js
@@ -0,0 +1,70 @@
+// 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 styled from 'styled-components';
+
+export const NftsCtn = styled.div`
+    color: ${props => props.theme.contrast};
+    width: 100%;
+    h2 {
+        margin: 0 0 20px;
+        margin-top: 10px;
+    }
+    padding-top: 24px;
+`;
+
+export const OfferTitle = styled.div`
+    color: ${props => props.theme.contrast};
+    font-size: 20px;
+    text-align: center;
+    font-weight: bold;
+`;
+
+export const OfferTable = styled.div`
+    display: flex;
+    flex-wrap: wrap;
+    justify-content: center;
+    gap: 9px;
+    width: 100%;
+    background-color: ${props => props.theme.panel}
+    border-radius: 9px;
+    color: ${props => props.theme.contrast};
+    max-height: 220px;
+    overflow: auto;
+    &::-webkit-scrollbar {
+        width: 12px;
+    }
+
+    &::-webkit-scrollbar-track {
+        -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
+        background-color: ${props => props.theme.eCashBlue};
+        border-radius: 10px;
+        height: 80%;
+    }
+
+    &::-webkit-scrollbar-thumb {
+        border-radius: 10px;
+        color: ${props => props.theme.eCashBlue};
+        -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.5);
+    }
+`;
+export const OfferRow = styled.div`
+    display: flex;
+    flex-direction: row;
+    gap: 3px;
+    align-items: center;
+    justify-content: center;
+`;
+export const OfferCol = styled.div`
+    display: flex;
+    flex-direction: column;
+    svg {
+        width: 18px;
+        height: 18px;
+    }
+    gap: 6px;
+`;
+
+export const CancelBtn = styled.button``;
+export const BuyBtn = styled.button``;
diff --git a/cashtab/src/transactions/index.js b/cashtab/src/transactions/index.js
--- a/cashtab/src/transactions/index.js
+++ b/cashtab/src/transactions/index.js
@@ -123,6 +123,8 @@
     const inputs = [];
     let inputSatoshis = 0;
     for (const tokenInput of tokenInputs) {
+        // If the input is already fully-formed, e.g. an ecash-agora input, do not prep it in this step
+
         const sk = wif.decode(wallet.paths.get(tokenInput.path).wif).privateKey;
         const pk = ecc.derivePubkey(sk);
         inputs.push({
@@ -199,6 +201,179 @@
     throw new Error('Insufficient funds');
 };
 
+/**
+ * Collect and prepare appropriate eCash utxos for a complex eCash tx
+ * For example, NFT listings, NFT cancellations -- txs where the inputs are already specified
+ * for ecash-lib
+ * TODO Cashtab should be refactored so that its utxos are already stored in an optimized way for
+ * ecash-lib txs
+ * @param {ChronikClientNode} chronik
+ * @param {Ecc} ecc
+ * @param {object} wallet
+ * @param {array} targetOutputs
+ * @param {number} satsPerKb integer, fee in satoshis per kb
+ * @param {number} chaintipBlockheight
+ * @param {array} tokenInputs unlike tokenInputs in sendXec, these are ready to roll with no modification
+ * @throws {error} if error building or broadcasting tx
+ */
+export const finalizeAndBroadcastTx = async (
+    chronik,
+    ecc,
+    wallet,
+    targetOutputs,
+    satsPerKb,
+    chaintipBlockheight,
+    tokenInputs = [],
+) => {
+    // Prepare outputs for ecash-lib by applying correct types
+    // TODO refactor so this "prep" is not necessary (update ecash-lib to accept address input)
+    const outputs = [];
+    for (const targetOutput of targetOutputs) {
+        if ('script' in targetOutput) {
+            outputs.push(targetOutput);
+            continue;
+        }
+        if (
+            !('address' in targetOutput) &&
+            targetOutput.value === appConfig.dustSats
+        ) {
+            // If we have no address and no script, assign change address
+            outputs.push({
+                value: targetOutput.value,
+                script: Script.p2pkh(fromHex(wallet.paths.get(1899).hash)),
+            });
+            continue;
+        }
+        // We must convert address to the appropriate outputScript
+        const { type, hash } = cashaddr.decode(targetOutput.address, true);
+        switch (type) {
+            case 'p2pkh': {
+                outputs.push({
+                    value: targetOutput.value,
+                    script: Script.p2pkh(fromHex(hash)),
+                });
+                break;
+            }
+            case 'p2sh': {
+                outputs.push({
+                    value: targetOutput.value,
+                    script: Script.p2sh(fromHex(hash)),
+                });
+                break;
+            }
+            default: {
+                throw new Error(
+                    `Unsupported address type for ${targetOutput.address}`,
+                );
+            }
+        }
+    }
+
+    // Get the total amount of satoshis being sent in this tx
+    const satoshisToSend = outputs.reduce(
+        (prevSatoshis, output) => prevSatoshis + output.value,
+        0n,
+    );
+    console.log(`satoshisToSend`, satoshisToSend);
+
+    if (satoshisToSend < appConfig.dustSats) {
+        throw new Error(
+            `Transaction output amount must be at least the dust threshold of ${appConfig.dustSats} satoshis`,
+        );
+    }
+
+    // Add a change output
+    // Note: ecash-lib expects this added as simply a script
+    // Note: if a change output is not needed, ecash-lib will omit
+    outputs.push(Script.p2pkh(fromHex(wallet.paths.get(1899).hash)));
+
+    // Collect input utxos using accumulative algorithm
+
+    // Use only eCash utxos
+    const utxos = wallet.state.nonSlpUtxos;
+
+    // Ignore immature coinbase utxos
+    const spendableUtxos = ignoreUnspendableUtxos(utxos, chaintipBlockheight);
+
+    // Sign token inputs, if present
+    // These inputs are required for the tx if present, so there is no selection algorithm for them here
+    const inputs = [];
+    let inputSatoshis = 0n;
+    for (const tokenInput of tokenInputs) {
+        inputs.push(tokenInput);
+        inputSatoshis += BigInt(tokenInput.input.signData.value);
+    }
+
+    // Add and sign required inputUtxos to create tx with specified targetOutputs
+    let txBuilder;
+    for (const utxo of spendableUtxos) {
+        console.log(`adding input utxo for XEC`, utxo);
+        const sk = wif.decode(wallet.paths.get(utxo.path).wif).privateKey;
+        const pk = ecc.derivePubkey(sk);
+        inputs.push({
+            input: {
+                prevOut: utxo.outpoint,
+                signData: {
+                    value: utxo.value,
+                    // Cashtab inputs will always be p2pkh utxos
+                    outputScript: Script.p2pkh(
+                        fromHex(wallet.paths.get(utxo.path).hash),
+                    ),
+                },
+            },
+            signatory: P2PKHSignatory(sk, pk, ALL_BIP143),
+        });
+        inputSatoshis += BigInt(utxo.value);
+
+        console.log(`inputs here`, inputs);
+
+        if (inputSatoshis > satoshisToSend) {
+            console.log(
+                `inputSatoshis greater than satoshisToSend`,
+                inputSatoshis,
+            );
+            // If value of inputs exceeds value of outputs, we check to see if we also cover the fee
+            // Determine if you have enough inputs to cover this tx
+            // Initialize TransactionBuilder
+            console.log(`inputs`, inputs);
+            console.log(`outputs`, outputs);
+            txBuilder = new TxBuilder({
+                inputs,
+                outputs,
+            });
+            let tx;
+            try {
+                tx = txBuilder.sign(ecc, satsPerKb, 546);
+            } catch (err) {
+                console.log(`signing error`, err);
+                if (
+                    typeof err.message !== 'undefined' &&
+                    err.message.startsWith('Insufficient input value')
+                ) {
+                    // If we have insufficient funds to cover satoshisToSend + fee
+                    // we need to add another input
+                    continue;
+                }
+                // Throw any other error
+                throw err;
+            }
+
+            // Otherwise, broadcast the tx
+            const txSer = tx.ser();
+            const hex = toHex(txSer);
+            console.log(`hex`, hex);
+            // Will throw error on node failing to broadcast tx
+            // e.g. 'txn-mempool-conflict (code 18)'
+
+            const response = await chronik.broadcastTx(hex);
+
+            return { hex, response };
+        }
+    }
+    // If we go over all input utxos but do not have enough to send the tx, throw Insufficient funds error
+    throw new Error('Insufficient funds');
+};
+
 /**
  * Determine the max amount a wallet can send
  * @param {object} wallet Cashtab wallet
diff --git a/cashtab/src/validation/__tests__/index.test.js b/cashtab/src/validation/__tests__/index.test.js
--- a/cashtab/src/validation/__tests__/index.test.js
+++ b/cashtab/src/validation/__tests__/index.test.js
@@ -32,6 +32,7 @@
     getContactAddressError,
     getWalletNameError,
     TOKEN_DOCUMENT_URL_MAX_CHARACTERS,
+    getXecListPriceError,
 } from 'validation';
 import {
     validXecAirdropExclusionList,
@@ -539,4 +540,13 @@
             });
         });
     });
+    describe('Gets error or false for list price input', () => {
+        const { expectedReturns } = vectors.getXecListPriceError;
+        expectedReturns.forEach(expectedReturn => {
+            const { description, xecListPrice, returned } = expectedReturn;
+            it(`getXecListPriceError: ${description}`, () => {
+                expect(getXecListPriceError(xecListPrice)).toBe(returned);
+            });
+        });
+    });
 });
diff --git a/cashtab/src/validation/fixtures/vectors.js b/cashtab/src/validation/fixtures/vectors.js
--- a/cashtab/src/validation/fixtures/vectors.js
+++ b/cashtab/src/validation/fixtures/vectors.js
@@ -2118,4 +2118,43 @@
             },
         ],
     },
+    getXecListPriceError: {
+        expectedReturns: [
+            {
+                description: 'Accepts an integer greater than zero',
+                xecListPrice: '12',
+                returned: false,
+            },
+            {
+                description: 'Accepts zero',
+                xecListPrice: '0',
+                returned: false,
+            },
+            {
+                description: 'Accepts a price with 2-decimal places',
+                xecListPrice: '111.12',
+                returned: false,
+            },
+            {
+                description: 'Rejects negative number',
+                xecListPrice: '-33',
+                returned: 'List price cannot be less than zero.',
+            },
+            {
+                description: 'Rejects non-number input',
+                xecListPrice: 'abc',
+                returned: 'List price must be a number.',
+            },
+            {
+                description: 'Rejects empty input',
+                xecListPrice: '',
+                returned: 'List price is required.',
+            },
+            {
+                description: `Rejects input of greater than ${appConfig.cashDecimals} decimal places`,
+                xecListPrice: '111.123',
+                returned: `List price supports up to ${appConfig.cashDecimals} decimal places.`,
+            },
+        ],
+    },
 };
diff --git a/cashtab/src/validation/index.js b/cashtab/src/validation/index.js
--- a/cashtab/src/validation/index.js
+++ b/cashtab/src/validation/index.js
@@ -948,3 +948,28 @@
     }
     return false;
 };
+
+/**
+ * Validation for a user-input XEC amount
+ * Note that validation is not the same here as for sending XEC
+ * User balance irrelevant
+ * @param {string} xecListPrice user input list price of an NFT in XEC
+ */
+export const getXecListPriceError = xecListPrice => {
+    if (xecListPrice < 0) {
+        return 'List price cannot be less than zero.';
+    }
+    if (!STRINGIFIED_DECIMALIZED_REGEX.test(xecListPrice)) {
+        // Must be a number (can't necessarily rely on Number input field to validate this)
+        return 'List price must be a number.';
+    }
+    if (xecListPrice === '') {
+        return 'List price is required.';
+    }
+    if (xecListPrice.includes('.')) {
+        if (xecListPrice.split('.')[1].length > appConfig.cashDecimals) {
+            return `List price supports up to ${appConfig.cashDecimals} decimal places.`;
+        }
+    }
+    return false;
+};
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
@@ -795,6 +795,12 @@
       npm ci
       npm run build
 
+      # ecash-agora
+      echo "Installing ecash-agora dependencies..."
+      pushd "${TOPLEVEL}/modules/ecash-agora"
+      npm ci
+      npm run build
+
       pushd "${TOPLEVEL}/cashtab"
       npm ci
       npm run build