/*
 * last modified---
 * 	06-09-26 add DOM ids for automation support
 * 	04-07-25 supply AES keys with MVO spend requests
 * 	03-21-25 implement chain.link price feeds where available
 * 	02-24-25 limit memo lines to 1024 chars
 * 	07-26-24 minor wording tweaks
 * 	01-16-24 fix sync problem with SelectableEnft checkboxes
 * 	12-12-23 import PayeeConfig from its own file
 * 	11-27-23 additional UI debugging
 * 	11-20-23 do all payee amount operations using BigNumber
 * 	11-06-23 rework resetPayeeConfigs(), renamed from replacePayeeConfigs()
 * 	10-30-23 debugging
 * 	10-20-23 refactor to store eNFTs[] in local state and establish
 * 			 availability; add template price passing
 * 	10-18-23 remove any use of payeeConfigs passed in from Enshroud
 * 	10-06-23 move SmartContractSubmitter invocation here from Enshroud
 * 	09-14-23 use Array not Map for payeeConfigs
 * 	08-29-23 complete the implementation
 * 	08-07-23 use LoadingButton
 * 	08-03-23 use local sendWalletReqToMVO() to support EIP-712 sigs
 * 	07-18-23 use useEth() state
 *
 * purpose---
 * 	UI for making spends of eNFTs
 */

import React, { useState, Fragment } from 'react';
import Container from 'react-bootstrap/Container';
import Table from 'react-bootstrap/Table';
import Button from 'react-bootstrap/Button';
import Image from 'react-bootstrap/Image';
import Form from 'react-bootstrap/Form';
import Accordion from 'react-bootstrap/Accordion';
import { useAccordionButton } from 'react-bootstrap/AccordionButton';
import ListGroup from 'react-bootstrap/ListGroup';
import InputGroup from 'react-bootstrap/InputGroup';
import Badge from 'react-bootstrap/Badge';
import useEth from './EthContext/useEth';
import MVOComm from './MVOComm.js';
import SmartContractSubmitter from './SmartContractSubmitter.jsx';
import LoadingButton from './LoadingButton.jsx';
import PayeeConfig from './PayeeConfig.js';
import NoticeWrongNetwork, { NoticeNoArtifact } from './Notices.jsx';
import DAlert from './DAlert.jsx';
import ChainLinkPriceFeed from './priceFeeds.js';
const BigNumber = require("bignumber.js");


// max number of array entries (inputs or outputs), EnshroudProtocol.ARRAY_LIMIT
const ARRAY_LIMIT = 20;
const MEMO_MAX = 1024;	// max length of a memo field

/* render an asset total as a table row, with switch selector
 * @param props.asset the token contract address involved
 * @param props.amount total value user has in this asset in eNFTs
 * @param props.avail sum of all eNFTs of this type (max that can be spent)
 * @param props.activeConfigs list of active/selected PayeeConfigS
 * @param props.onConfigSelect method to call to report asset is active or not
 * @param props.onTotalChange method to call when total spend amount changes
 * @param props.tokenSymbols the list of token symbols we know about so far
 */
function TotalItem(props) {
	const { state: { chainConn, web3 } } = useEth();

	// get the token symbol for the asset
	var tokenSymbol = '';
	const tokenRecord
		= props.tokenSymbols.find(elt => elt.contract === props.asset);
	if (tokenRecord !== undefined) {
		tokenSymbol = tokenRecord.symbol;
	}
	const avail = new BigNumber(props.avail);

	// local state to store the amount in the form
	const [spendAmount, setSpendAmount] = useState("0.0");

	// local state for market price of this asset
	const [marketPrice, setMarketPrice] = useState("0");

	// shorthands
	const switchId = `sel-${props.asset}`;
	const symId = `sym-${props.asset}`;
	const amtId = `amt-${props.asset}`;
	const availId = `avail-${props.asset}`;
	const maxId = `max-${props.asset}`;
	const amtTitle = "Enter spend amount in ";
	const aria = `select ${tokenSymbol}`;

	// determine whether the switch should be checked or not
	var switchState = props.activeConfigs.some(elt => elt === props.asset);
	const activeConfig
		= props.payeeConfigs.find(elt => elt.asset === props.asset);
	if (activeConfig !== undefined) {
		// override with config's setting
		switchState = activeConfig.active;
	}

	// if switch isn't active, clear the Total Spend Amount field and reset
	if (!switchState) {
		// manually turn off switch selection in DOM after it's mounted
		const swi = document.getElementById(switchId);
		if (swi !== null) swi.checked = false;
	}

	var switchEnabled = true;
	// force init state off + disable if available balance in this asset is zero
	if (avail.lte(0)) {
		switchState = false;
		switchEnabled = false;
	}

	// set max amount
	function setMaxAmount() {
		setSpendAmount(props.avail);
		props.onTotalChange(props.asset, props.avail);
	}

	// restrict reduction to a calculated minimum by limiting field value min=
	var fieldMin = new BigNumber(0.0);
	if (activeConfig !== undefined) {
		// cannot go below payee total
		fieldMin = activeConfig.total;
	}

	/* update spendAmount if it's less than fieldMin (can occur after a sig
	 * reject by user that causes a change payee to get added to the config)
	 */
	if (fieldMin.gt(spendAmount)) {
		setSpendAmount(fieldMin.toString());
	}

	// process a change to the amount field
	const handleAmountChange = async (e) => {
		var amt = new BigNumber(0.0);
		var inpVal = e.target.value.toString();
		if (inpVal === '') inpVal = "0.0";
		if (isNaN(inpVal)) inpVal = "0.0";
		// in order to support trailing zeros, track string value separately
		var spendAmtStr = inpVal;
		var enteredAmt = new BigNumber(spendAmtStr);
		if (enteredAmt.isNegative()) {
			spendAmtStr = "0.0";
			enteredAmt = amt;
		}
		amt = amt.plus(enteredAmt);

		// get exact unrounded min
		var newAmt = BigNumber.minimum(amt, avail);
		// check vs allocated amount in payee config
		if (newAmt.lt(fieldMin)) {
			alert("Cannot reduce amount below payee total of " + fieldMin);
		}
		newAmt = BigNumber.maximum(newAmt, fieldMin);

		// update string quantities appropriately
		if (!newAmt.eq(enteredAmt)) {
			spendAmtStr = newAmt.toString();
		}
		setSpendAmount(spendAmtStr);
		props.onTotalChange(props.asset, newAmt.toString());

		// get ChainAsset for this asset, extract price feed if there is one
		var mktPriceFeed = '';
		const chAsset = chainConn.chainConfig.assetList.find(
									elt => elt.contractAddress === props.asset);
		if (chAsset !== undefined) {
			mktPriceFeed = chAsset.priceFeed;
		}
		if (mktPriceFeed !== undefined && mktPriceFeed !== '') {
			// get the market price for this asset
			const clFeed = new ChainLinkPriceFeed();
			const usdPrice = await clFeed.getPrice(web3, mktPriceFeed);
			if (usdPrice !== undefined) setMarketPrice(usdPrice.toString());
		}
	};

	// prep and show a fiat value column matching the total amount being spent
	const tokenVal = new BigNumber(spendAmount).multipliedBy(marketPrice)
					.decimalPlaces(2).toString();
	var dispSpend = spendAmount === "0.0" ? "" : spendAmount;
	// check for entry of a series of zeros
	const zeroPat = /0*\.0*/;
	if (spendAmount.match(zeroPat)) dispSpend = spendAmount;

	// render line
	return (
		<tr align="center" valign="middle">
			<td>
				<div className="form-check form-switch">
					<Form.Switch id={switchId} name={switchId}
						selected={switchState} className="mx-auto"
						onClick={(asset, active) => props.onConfigSelect(props.asset, !switchState)}
						aria-label={aria} disabled={!switchEnabled}
					/>
				</div>
			</td>
			<td id={symId} title={props.asset}>{tokenSymbol}</td>
			<td id={availId}>
				{props.amount}&nbsp;&nbsp;<i>({props.avail})</i>
			</td>
			<td>
				<InputGroup className="p-2">
					<Form.Control type="text" id={amtId} name={amtId}
						value={dispSpend} title={amtTitle + tokenSymbol}
						onChange={handleAmountChange} placeholder="0"
					/>
					<Button title="Set amount to available balance"
						variant="link" size="sm" onClick={setMaxAmount}
						role="button" id={maxId}
					>
						Max
					</Button>
				</InputGroup>
			</td>
			<td>{tokenVal > 0 ? tokenVal : "N/A"}</td>
		</tr>
	);
}

// toggle the eNFT list accordion for a particular asset type
function NftListToggle({ children, eventKey }) {
	const decoratedOnClick = useAccordionButton(eventKey, () =>
		"",
	);

	const bId = "listToggle-" + eventKey;
	return (
		<Button size="lg" onClick={decoratedOnClick} variant="info" id={bId}
			title="Toggle eNFT list" type="button"
		>
			{children}
		</Button>
	);
}

/* render a single eNFT as a suitable table row (accordion contents)
 * @param props.eNFT the Enft record
 * @param props.asset the token contract involved
 * @param tokenSymbols the list of token symbols we know about so far
 * @param props.payeeConfigs the existing list of PayeeConfig records
 */
function SelectableEnft(props) {
	const { state: { chainConn, web3 } } = useEth();

	// local state for selection
	const [enftSelected, setEnftSelected] = useState(false);
	
	// get the token symbol for the asset
	var tokenSymbol = '';
	const tokenRecord
		= props.tokenSymbols.find(elt => elt.contract === props.asset);
	if (tokenRecord !== undefined) {
		tokenSymbol = tokenRecord.symbol;
	}

	// shorthands
	const enft = props.eNFT;
	const aria = `select ${tokenSymbol} eNFT`;
	const name = `${tokenSymbol}select`;
	const truncId = enft.id.substring(0,4) + '...'
					+ enft.id.substring(60);
	const amt = web3.utils.fromWei(enft.amount.toString());
	const availId = `avail-${enft.id}`;

	// get the relevant PayeeConfig record
	const payeeConfig
		= props.payeeConfigs.find(elt => elt.asset === props.asset);
	if (payeeConfig !== undefined) {
		// allow for possibility eNFT was auto-selected by algorithm
		const currSelected = payeeConfig.isENFTselected(enft.id);
		if (currSelected !== enftSelected) {
			setEnftSelected(currSelected);
			// manually force DOM to make the checkbox match
			const chkBox = document.getElementById(enft.id);
			if (chkBox !== null) {
				chkBox.checked = currSelected;
			}
		}
	}

	// set the selected individual eNFT
	function selectENFT(asset, id, selected) {
		if (payeeConfig !== undefined) {
			if (selected && !enftSelected) {
				// select this Enft if available
				if (enft.avail) {
					payeeConfig.selectENFT(id, enft);
					setEnftSelected(true);
				}
			}
			else if (!selected && enftSelected) {
				// deselect this Enft
				payeeConfig.deselectENFT(id);
				setEnftSelected(false);
			}
		} else {
			alert("No payee config associated with this Enft");
		}
		// NB: this doesn't need to re-render because checkbox is only display
	}

	return (
		<tr align="center" valign="middle">
			<td>
				<Form.Check type="checkbox" id={enft.id} name={name}
					selected={enftSelected}
					onClick={(asset, selId, stat) => selectENFT(enft.asset, enft.id, !enftSelected)}
					value={enft.id} aria-label={aria} disabled={!enft.avail}
				/>
			</td>
			<td id={availId}>
				{amt}&nbsp;&nbsp;
				{enft.avail ?
					<Badge pill bg="success"
						title={"Available " + enft.unavailReason}>+</Badge>
					: <Badge pill bg="warning"
						title={"Unavailable: " + enft.unavailReason}>-</Badge>
				}
			</td>
			<td className="text-break" title={enft.id}>{truncId}</td>
			<td>{enft.generation}</td>
			<td>
				{enft.validateSig(chainConn, web3)
					? <Badge pill bg="success" title="MVO signature validated">
						<Image src="images/check2.svg"
						fluid rounded className="p-2" height={40} width={40} />
					  </Badge>
					: <Badge pill bg="danger" title="MVO signature invalid">
						<Image src="images/x-lg.svg"
						fluid rounded className="p-2" height={40} width={40} />
					  </Badge>
				}
			</td>
		</tr>
	);
}

/* generate a table of eNFTs, inside an accordion
 * @param props.asset the token contract
 * @param props.id suitable key value
 * @param props.enftData array of EnftS we are to represent
 * @param props.tokenSymbols the list of token symbols we know about so far
 * @param props.payeeConfigs the existing list of PayeeConfig records
 */
function AssetENFTdetails(props) {
	// get the token symbol for the asset
	var tokenSymbol = '';
	const tokenRecord
		= props.tokenSymbols.find(elt => elt.contract === props.asset);
	if (tokenRecord !== undefined) {
		tokenSymbol = tokenRecord.symbol;
	}
	const tokenTitle = tokenSymbol + " (" + props.asset + ")";

	return (
		<tr align="left" valign="middle">
			<td colSpan="5">
				<Accordion flush>
					<Accordion.Item eventKey={props.id}>
						<NftListToggle
							eventKey={props.id}
							key={`toggle${props.id}`} >
							Show {tokenSymbol} eNFTs&nbsp;
							<Badge bg="success">
								{props.enftData.length}
							</Badge>&nbsp;&nbsp;
							<Image src="images/zoom-in.svg" fluid rounded
								height={40} width={40}
							/>
						</NftListToggle>
						<Accordion.Body>
							<div className="accordion-body">
							<Table striped bordered hover responsive
								variant="dark">
								<caption className="caption-top">
									{tokenTitle} eNFT details:
								</caption>
								<thead>
									<tr align="center" key="header{props.id}">
										<th scope="col"
											title="Use to override auto-selection of input eNFTs for Total Spend Amount">
											Select
										</th>
										<th scope="col"
											title="In ethers units, converted from wei for display">
											Amount (1e-18)
										</th>
										<th scope="col"
											title="Use hover text to see complete ID, or download on Home page">
											eNFT ID
										</th>
										<th scope="col"
											title="Number of times this value has circulated since it was deposited on-chain">
											Generation
										</th>
										<th scope="col"
											title="Results of checking the MVO's signature on the details of this eNFT">
											Sig Verified
										</th>
									</tr>
								</thead>
								<tbody>
									{props.enftData.map((enft) =>
										<SelectableEnft
											key={enft.id}
											eNFT={enft}
											asset={props.asset}
											tokenSymbols={props.tokenSymbols}
											payeeConfigs={props.payeeConfigs}
										/>
									)}
								</tbody>
							</Table>
							</div>
						</Accordion.Body>
					</Accordion.Item>
				</Accordion>
			</td>
		</tr>
	);
}

/* method to display the table of eNFTs for a given asset type
 * @param prop.details the list of records, in this form:
 * 	{id: <key index>, asset: <token>, total: <eNFT sum>, available: <sum avail>,
 * 	eNFTs: [Enft]}
 * @param props.payeeConfigs the existing list of PayeeConfig records
 * @param props.activeConfigs the list of assets currently configured active
 * @param props.onConfigSelect method to set the PayeeConfig active or not
 * @param props.onTotalChange method to change the total amount of asset spent
 * @param props.onNewPayeeConfig method to initialize a new PayeeConfig record
 * @param props.tokenSymbols the list of token symbols we know about so far
 */
function ENFTTable(props) {
	var doBody = true;
	if (props.payeeConfigs.length === 0) {
		// no configs yet, no rendering to be done
		doBody = false;
	}

	if (doBody) {
		return (
			<tbody>
				{props.details.map((row) =>
					<Fragment key={"frag_" + row.id}>
						<TotalItem
							asset={row.asset}
							amount={row.total}
							avail={row.available}
							id={row.id}
							activeConfigs={props.activeConfigs}
							payeeConfigs={props.payeeConfigs}
							onConfigSelect={(contract, active) => props.onConfigSelect(contract, active)}
							onTotalChange={(asset, amount) => props.onTotalChange(asset, amount)}
							tokenSymbols={props.tokenSymbols}
						/>
						<AssetENFTdetails
							asset={row.asset}
							enftData={row.eNFTs}
							id={row.id}
							tokenSymbols={props.tokenSymbols}
							payeeConfigs={props.payeeConfigs}
						/>
					</Fragment>
				)}
			</tbody>
		);
	} else {
		return (
			// empty table body
			<tbody>
				<tr></tr>
			</tbody>
		);
	}
}

/* render a form to enter a new payee in the config
 * @param props.userAcct the user's current account
 * @param props.payeeConfig the payee config for this asset
 * @param props.onConfigUpdate method to record payee config changes
 * @param props.symbol the token symbol
 * @param props.spendAmount the Total Spend Amount entered for this asset
 */
function NewPayeeRenderer(props) {
	const { state: { web3 } } = useEth();

	// form input address field
	const [inputAddr, setInputAddr] = useState("");

	// form input amount field
	const [inputAmount, setInputAmount] = useState("0.0");

	// form memo field
	const [inputMemo, setInputMemo] = useState("");

	// compute max we can add for all payees
	var maxAvail = new BigNumber(0.0);
	if (props.spendAmount !== undefined) {
		maxAvail = maxAvail.plus(props.spendAmount.amount).minus(
													props.payeeConfig.total);
	}

	// process a change to the amount field
    const handleAmountChange = e => {
		let amt = new BigNumber(0.0);
		var inpVal = e.target.value.toString();
		if (inpVal === '') inpVal = "0.0";
		if (isNaN(inpVal)) inpVal = "0.0";
		// in order to support trailing zeros, track string value separately
		var spendAmtStr = inpVal;
		var enteredAmt = new BigNumber(spendAmtStr);
		if (enteredAmt.isNegative()) {
			spendAmtStr = "0.0";
			enteredAmt = amt;
		}
		amt = amt.plus(enteredAmt);
		// get exact unrounded min
		const minAmt = BigNumber.minimum(amt, maxAvail);

		// update string quantities appropriately
		if (!minAmt.eq(enteredAmt)) {
			spendAmtStr = minAmt.toString();
		}
        setInputAmount(spendAmtStr);
    };

    // process a change to the address field
    const handleAddressChange = e => {
		if (e.target.value === '') {
			setInputAddr("");
		} else if (/^0x[0-9a-fA-F]+/.test(e.target.value)) {
			const bareAddr = web3.utils.stripHexPrefix(e.target.value);
			const addr = "0x" + bareAddr;
			if (web3.utils.isAddress(addr)) {
				setInputAddr(web3.utils.toChecksumAddress(addr));
			} else {
				alert("Invalid address, " + addr);
			}
		}
    };

    // process a change to the memo field
    const handleMemoChange = e => {
		var memo = e.target.value.toString();
		if (memo.length > MEMO_MAX) {
			alert("Memo too long, max " + MEMO_MAX + " characters");
		} else {
        	setInputMemo(memo);
		}
    }

    // set the address field to self (active user account)
    const setSelf = e => {
        setInputAddr(props.userAcct);
    };

	// set the amount field to max remaining
	const setMax = e => {
		setInputAmount(maxAvail.toString());
	};

	// shorthand to fit inside input
    const addr = inputAddr.substring(0, 7) + '...' + inputAddr.substring(37);
	const payeeSeq = props.payeeConfig.payees.length + 1;
	const addrId = "addr-" + payeeSeq;
	const amtId = "amt-" + payeeSeq;
	const memoId = "memo-" + payeeSeq;
	const selfId = "self-" + payeeSeq;
	const addId = "add-" + payeeSeq;
	const maxId = "max-" + payeeSeq;
	const defaultTitle = "Enter payee #" + payeeSeq + " address";

	// method to record addition of new payee
	function addPayee() {
		// validate payee is complete
		let payeeOk = true;
		if (!inputAddr.startsWith("0x")) {
			payeeOk = false;
		}
		if (inputAmount < 0) {
			payeeOk = false;
		}
		if (payeeOk) {
			// add the payee and register the update
			const payee
				= props.payeeConfig.boundAddPayee(inputAddr, inputAmount);
			payee.memo = inputMemo;
			// reset input fields
			setInputAmount("0.0");
			setInputAddr('');
			setInputMemo('');
			// register alteration
			props.onConfigUpdate(props.payeeConfig);
		} else {
			alert("Missing address or amount, please check payee data");
		}
	}

	// render add-payee form
	var dispAmt = inputAmount === "0.0" ? "" : inputAmount;
	// check for entry of a series of zeros
	const zeroPat = /0*\.0*/;
	if (inputAmount.match(zeroPat)) dispAmt = inputAmount;

	// render line
	return (
		<tr align="center" valign="middle" key={"np" + payeeSeq}>
			<td>{ /* address field */ }
				<InputGroup className="p-2">
					<Form.Control type="text" maxLength={45} id={addrId}
						value={inputAddr} placeholder="0x" name={addrId}
						onChange={handleAddressChange}
						title={inputAddr === '' ? defaultTitle : addr}
					/>
					<Button variant="link" className="btn-sm" role="button"
						title="Set this payee to your own address" id={selfId}
						onClick={setSelf}>
						Self
					</Button>
				</InputGroup>
			</td>

			{ /* symbol field */ }
			<td title={props.payeeConfig.asset}> {props.symbol} </td>

			<td>{ /* amount to payee */ }
				<InputGroup className="p-2">
					<Form.Control type="text" id={amtId} name={amtId}
						value={dispAmt} title="Amount to spend to this address"
						onChange={handleAmountChange} placeholder="0"
					/>
					<Button variant="link" className="btn-sm"
						title="Set amount to unspent Total Spend Amount (use up Remainder)"
						onClick={setMax} id={maxId}
					>
						Max
					</Button>
				</InputGroup>
			</td>

			<td>{ /* optional memo to payee */ }
				<Form.Control type="text" id={memoId} name={memoId}
					value={inputMemo} title="Memo line to send to payee"
					onChange={handleMemoChange}
					className="p-2" />
			</td>

			<th role="row">{ /* action icon */ }
				<Button title="Add this address and amount to payee config"
					variant="secondary" className="btn-sm"
					onClick={addPayee} id={addId}
				>
					<Image src="images/plus-lg.svg" className="p-2"
						fluid rounded height="40" width="40" />
				</Button>
			</th>
		</tr>
	);
}

/* render a Remainder line in the config, showing how much is not yet allocated
 * @param props.payeeConfig the payee configuration for this asset
 * @param props.symbol the token mnemonic symbol for the asset
 * @param props.spendAmount the Total Spend Amount entered for this asset
 */
function RemainderLine(props) {
	const keyLabel = props.symbol + "-" + props.payeeConfig.payees.length;
	var leftover = 0;
	if (props.spendAmount !== undefined) {
		leftover = new BigNumber(props.spendAmount.amount).minus(
													props.payeeConfig.total);
		// disallow negatives
		leftover = BigNumber.maximum(leftover, 0.0);
	}
	const amtId = "amt-" + keyLabel;
	const title
		= `Automatic change eNFT back to your own address (in ${props.symbol})`;

	// render remainder form line
	return (
		<tr align="center" valign="middle" key={keyLabel}>
			<td>Remainder (to self):</td>

			<td title={props.payeeConfig.asset}>{props.symbol}</td>

			<td>{ /* amount leftover */ }
				<Form.Control type="text" id={amtId} name={amtId}
					value={leftover.toString()} title={title} readOnly disabled
				/>
			</td>

			<td>&nbsp;</td>

			<th role="row">&nbsp;</th>
		</tr>
	);
}

/* render a single payee element (one row of 5 columns)
 * @param props.payeeDetails the SpendPayee record we're rendering
 * @param props.symbol the token mnemonic symbol for the asset
 * @param props.userAcct the user's address (normally accounts[0])
 * @param props.payeeConfig the entire PayeeConfig of which we're one payee
 * @param props.onConfigUpdate the method to call when props.payeeConfig updates
 * @param props.onDelPayee the method to call when we delete this payee
 * @param props.spendAmount the Total Spend Amount entered for this asset
 */
function PayeeLine(props) {
	const { state: { web3 } } = useEth();

	// shorthands
	const payee = props.payeeDetails;
	const addrId = "addr-" + payee.sequence;
	const amtId = "amt-" + payee.sequence;
	const memoId = "memo-" + payee.sequence;
	const delId = "rm-" + payee.sequence;
	const selfId = "self-" + payee.sequence;
	const maxId = "max-" + payee.sequence;
	const defaultTitle = "Enter payee #" + payee.sequence + " address";
	// compute max we can add for all payees (equal to RemainderLine value)
	var maxAvail = new BigNumber(props.spendAmount.amount).minus(
													props.payeeConfig.total);
	maxAvail = BigNumber.maximum(maxAvail, 0.0);

	// form input address field
	const [inputAddr, setInputAddr] = useState(payee.address);

	// form input amount field
	const [inputAmount, setInputAmount] = useState(payee.amount.toString());

	// form memo field
	const [inputMemo, setInputMemo] = useState(payee.memo);

	// limit value for max= in the amount input (id=amtId)
	var fieldMax = new BigNumber(inputAmount).plus(maxAvail);

	// process a change to the (existing) amount field
    const handleAmountChange = e => {
		let amt = new BigNumber(0.0);
		var inpVal = e.target.value.toString();
		if (inpVal === '') inpVal = "0.0";
		if (isNaN(inpVal)) inpVal = "0.0";
		// in order to support trailing zeros, track string value separately
		var spendAmtStr = inpVal;
		var enteredAmt = new BigNumber(spendAmtStr);
		if (enteredAmt.isNegative()) {
			spendAmtStr = "0.0";
			enteredAmt = amt;
		}
		amt = amt.plus(enteredAmt);

		// we must limit any increase to the headroom (remainder) available
		const existingAmt = new BigNumber(inputAmount);
		const max = existingAmt.plus(maxAvail);
		const constrainedAmt = BigNumber.minimum(amt, max);
		const diff = constrainedAmt.minus(payee.amount);
		const newAmt = existingAmt.plus(diff);
		payee.amount = newAmt;
		props.payeeConfig.total = props.payeeConfig.total.plus(diff);
		// recalculate fieldMax in case user pushes Max button next
		maxAvail = new BigNumber(props.spendAmount.amount).minus(
													props.payeeConfig.total);
		fieldMax = newAmt.plus(maxAvail);
		if (!newAmt.eq(enteredAmt)) {
			spendAmtStr = newAmt.toString();
		}
		// register alteration
		setInputAmount(spendAmtStr);
		props.onConfigUpdate(props.payeeConfig);
    };

    // process a change to the address field
    const handleAddressChange = e => {
		if (e.target.value === '') {
			setInputAddr("");
		} else if (/^0x[0-9a-fA-F]+/.test(e.target.value)) {
			const bareAddr = web3.utils.stripHexPrefix(e.target.value);
			const addr = "0x" + bareAddr;
			if (web3.utils.isAddress(addr)) {
				payee.address = web3.utils.toChecksumAddress(addr);
				setInputAddr(payee.address);
				// register alteration
				props.onConfigUpdate(props.payeeConfig);
			} else {
				alert("Invalid address, " + addr);
			}
		}
    };

    // process a change to the memo field
    const handleMemoChange = e => {
		var memo = e.target.value.toString();
		if (memo.length > MEMO_MAX) {
			alert("Memo too long, max " + MEMO_MAX + " characters");
		} else {
			setInputMemo(memo);
			payee.memo = memo;
			// register alteration
			props.onConfigUpdate(props.payeeConfig);
		}
    }

    // set the address field to self (active user account)
    const setSelf = e => {
		setInputAddr(props.userAcct);
        payee.address = props.userAcct;
		// register alteration
		props.onConfigUpdate(props.payeeConfig);
    };

	// set the amount field to max remaining
	const setMax = e => {
		const netDiff = fieldMax.minus(payee.amount);
		payee.amount = fieldMax;
		props.payeeConfig.total = props.payeeConfig.total.plus(netDiff);
		// register alteration
		setInputAmount(payee.amount.toString());
		props.onConfigUpdate(props.payeeConfig);
	};

	// delete the payee
	const delPayee = e => {
		props.onDelPayee(payee.sequence);
	};

	// shorthand to fit inside input
    const addr = inputAddr.substring(0, 7) + '...' + inputAddr.substring(37);
	var dispInpAmt = inputAmount === "0.0" ? "" : inputAmount;
	// check for entry of a series of zeros
	const zeroPat = /0*\.0*/;
	if (inputAmount.match(zeroPat)) dispInpAmt = inputAmount;

	// render line
	return (
		<tr align="center" valign="middle">
			<td>{ /* address field */ }
				<InputGroup className="p-2">
					<Form.Control type="text" maxLength={45} id={addrId}
						value={inputAddr} placeholder="0x" name={addrId}
						onChange={handleAddressChange}
						title={inputAddr === '' ? defaultTitle : addr}
					/>
					<Button variant="link" className="btn-sm" id={selfId}
						title="Set this payee to your own address"
						onClick={setSelf}>
						Self
					</Button>
				</InputGroup>
			</td>

			<td title={props.payeeConfig.asset}>{ /* symbol field */ }
				{props.symbol}
			</td>

			<td>{ /* amount to payee */ }
				<InputGroup className="p-2">
					<Form.Control type="text" id={amtId} name={amtId}
						value={dispInpAmt} onChange={handleAmountChange}
						title="Amount to spend to this address"
						placeholder="0"
					/>
					<Button variant="link" className="btn-sm" role="button"
						title="Set amount to unspent Total Spend Amount and zero Remainder"
						onClick={setMax} id={maxId}
					>
						Max
					</Button>
				</InputGroup>
			</td>

			<td>{ /* optional memo to payee */ }
				<Form.Control type="text" id={memoId} name={memoId}
					value={inputMemo} title="Memo line to send to payee"
					onChange={handleMemoChange} className="p-2" />
			</td>

			<th role="row">{ /* action icon */ }
				<Button title="Remove this address and amount from payee config"
					variant="secondary" className="btn-sm" onClick={delPayee}
					id={delId}
				>
					<Image src="images/x-lg.svg" className="p-2"
						fluid rounded height="40" width="40" />
				</Button>
			</th>
		</tr>
	);
}

/* render a single row of the payee config table (i.e. config for single asset)
 * @param props.asset the token contract address
 * @param props.availBal the total available to spend (sum of all avail eNFTs)
 * @param props.payeeConfig the config for the payee (a PayeeConfig)
 * @param props.selectedConfigs the list of tokens activated by switches
 * @param props.onConfigUpdate the method to call when a PayeeConfig is updated
 * @param props.tokenSymbols the token symbols we know about so far
 * @param props.spendAmounts the Total Spend Amount for all assets so far
 */
function PayeeConfigRenderer(props) {
	// enable use of our contracts and accounts (guaranteed not to be null)
	const { state: { accounts, web3 } } = useEth();
	const userAcct = web3.utils.toChecksumAddress(accounts[0]);

	// get the token symbol for the asset
	var tokenSymbol = '';
	const tokenRecord
		= props.tokenSymbols.find(elt => elt.contract === props.asset);
	if (tokenRecord !== undefined) {
		tokenSymbol = tokenRecord.symbol;
	}
	const assetTotalAmt
		= props.spendAmounts.find(elt => elt.contract === props.asset);

	/* method to remove a payee from the selected config
     * @param sequence the sequence Id of the config (used as search index)
     */
    function handleDelPayee(sequence) {
        const existing = props.payeeConfig.payees.find(
                                            elt => elt.sequence === sequence);
        if (existing !== undefined) {
            props.payeeConfig.boundDelPayee(existing.sequence);
			props.onConfigUpdate(props.payeeConfig);
        }
		/* update spendAmount value to the payeeTotal so that RemainderLine
		 * updates to the correct value (if spendAmount.amount is less, which
		 * should not happen since spendAmount.amount is used as an upper bound
		 * on the payeeConfig.total)
		 */
		if (props.payeeConfig.total.gt(assetTotalAmt.amount)) {
			const maxAssetTot
				= BigNumber.minimum(props.availBal, props.payeeConfig.total);
			assetTotalAmt.amount = maxAssetTot.toString();
		}
    }

	return (
		<>
			{props.payeeConfig !== undefined
			 && props.payeeConfig.payees.length > 0
			 && props.payeeConfig.payees.map((payee) =>
				<PayeeLine
					key={"p" + payee.sequence}
					payeeDetails={payee}
					symbol={tokenSymbol}
					userAcct={userAcct}
					payeeConfig={props.payeeConfig}
					onConfigUpdate={(updatedConfig) => props.onConfigUpdate(updatedConfig)}
					onDelPayee={(seq) => handleDelPayee(seq)}
					spendAmount={assetTotalAmt}
				/>
			)}

			{ /* add entry form for new payee */ }
			<NewPayeeRenderer
				userAcct={userAcct}
				payeeConfig={props.payeeConfig}
				symbol={tokenSymbol}
				onConfigUpdate={(updatedConfig) => props.onConfigUpdate(updatedConfig)}
				spendAmount={assetTotalAmt}
			/>

			{ /* add remainder line (becomes an implicit payee) */ }
			<RemainderLine
				payeeConfig={props.payeeConfig}
				symbol={tokenSymbol}
				spendAmount={assetTotalAmt}
			/>
		</>
	);
}

/* method to display the table of eNFT payees as configured by the user
 * @param props.details the array of PayeeConfigS and other data defined
 * @param props.selectedConfigs all the tokens for which payeeConfig is active
 * @param props.onConfigUpdate the method to record changes to a payeeConfig
 * @param props.tokenSymbols the token symbols we know about so far
 * @param props.spendAmounts the Total Spend Amount values we have so far
 */
function PayeeTable(props) {
	/* each element of the props.details[] looks like this:
	 *	{id: <key index>, asset: <token contract address>,
	 *	 available: <avail balance in this asset>,
	 *   payeeConfig: <PayeeConfig record for this asset, if any>});
	 */
	const nf = new Intl.NumberFormat("en-US", {minimumIntegerDigits: 3});

	const bodyContents = (
		<Fragment key="payeeTable">
			{ /* display this asset's payeeConfig */ }
			{props.details.length > 0 && props.details.map((row) =>
				<PayeeConfigRenderer
					key={nf.format(row.id)}
					asset={row.asset}
					availBal={row.available}
					payeeConfig={row.payeeConfig}
					selectedConfigs={props.selectedConfigs}
					onConfigUpdate={(updatedConfig) => props.onConfigUpdate(updatedConfig)}
					tokenSymbols={props.tokenSymbols}
					spendAmounts={props.spendAmounts}
				/>
			)}
		</Fragment>
	);
	return (
		<tbody>
			{bodyContents}
		</tbody>
	);
}

/* main display method for both input eNFT table and eNFT payee config table
 * @param props.parseReplyMethod method to process MVO replies received
 * @param props.onSelect method to switch to other pages (unused)
 * @param props.eNFTList the list of user EnftS (decrypted) avail as inputs
 * @param props.opsBlock decrypted JSON object last received from the MVO
 * @param props.active whether SmartContractSubmitter should display content
 */
function SpendENFTs(props) {
	// enable use of our blockchain interfaces
	const { state: { artifacts, contracts, accounts, web3, chainConn } }
		= useEth();
	const userAcct = web3.utils.toChecksumAddress(accounts[0]);
	const enshContract = contracts["EnshroudProtocol"];

	// state storage of passed eNFTs, downloaded and decrypted and passed down
	const [eNFTs, setENFTs] = useState([]);

	/* method to check the availability of passed eNFTs
	 * @param enftList the list of eNFTs to check
	 */
	async function checkEnftAvailability(enftList) {
		const redoEnfts = [];
		for await (const enft of enftList) {
			/* Determine whether the eNFT is "available," meaning aged for the
			 * required number of blocks since minting, plus not greylisted.
			 * This async method sets enft.avail, and if set to false, also
			 * sets enft.unavailReason showing why.
			 */
			await enft.isAvailable(web3, enshContract);

			// update by merging state
			redoEnfts.push(enft);
		}

		// mass delete and re-add any that changed (queued w/ updater fn.)
		if (redoEnfts.length > 0) {
			// NB: this will trigger two re-renders, one after each operation
			setENFTs(eNFTs => (eNFTs.filter(
						elt => !redoEnfts.some(enft => enft.id === elt.id))));
			setENFTs(eNFTs => ([...eNFTs, ...redoEnfts]));
		}
	}

	// audit that passed eNFTs (if any) are known (NB: all are decrypted)
	const newEnfts = [];
	props.eNFTList.forEach(enft => {
		const existEnft = eNFTs.find(elt => elt.id === enft.id);
		if (existEnft === undefined) {
			// add
			newEnfts.push(enft);
		}
	});
	if (newEnfts.length > 0) {
		if (eNFTs.length === 0) {
			setENFTs([...newEnfts]);
		} else {
			setENFTs([...eNFTs, ...newEnfts]);
		}

		// fetch and update availability of these EnftS
		checkEnftAvailability(newEnfts);
	}

	/* Based on passed-in EnftS, build a list of assets, totals, and eNFTs.
	 * NB: these are all read-only datums, so we don't need to useState.
	 * Instead we build the mappings afresh with each render.  (This of course
	 * requires that the code below be completely reentrant, as it will be
	 * invoked many times as various state elements are added or updated.)
	 */
	const assetTotals = new Map();		// contract -> total in wei (ledger)
	const assetAvails = new Map();		// contract -> total in wei (confirmed)
	const assetENFTs = new Map();		// contract -> array of Enft
	eNFTs.forEach(enft => {
		let asset = enft.asset;
		if (assetTotals.has(asset)) {
			// increase the total
			let existAmt = assetTotals.get(asset);
			existAmt = existAmt.add(web3.utils.toBN(enft.amount));
			assetTotals.set(asset, existAmt);

			// add eNFT to array
			assetENFTs.get(asset).push(enft);
		} else {
			// initialize the total
			assetTotals.set(asset, web3.utils.toBN(enft.amount));

			// init array with this eNFT
			const nftArray = [];
			nftArray.push(enft);
			assetENFTs.set(asset, nftArray);
		}

		// add this Enft to available balance if it's marked available
		if (enft.avail) {
			if (assetAvails.has(asset)) {
				// increase
				let existAvail = assetAvails.get(asset);
				existAvail = existAvail.add(web3.utils.toBN(enft.amount));
				assetAvails.set(asset, existAvail);
			} else {
				// init total
				assetAvails.set(asset, web3.utils.toBN(enft.amount));
			}
		}
	});

	// construct data to pass to ENFTTable as props (all numbers in ethers)
	let assetIdx = 1;
	const eNFTTableProps = [];
	for (const asst of assetTotals.keys()) {
		const amount = web3.utils.fromWei(assetTotals.get(asst));
		var avail = "0";
		const avBucket = assetAvails.get(asst);
		if (avBucket !== undefined) {
			avail = web3.utils.fromWei(avBucket);
		}
		const nftArray = assetENFTs.get(asst);
		eNFTTableProps.push({id: assetIdx++,
							asset: asst,
							total: amount,
							available: avail,
							eNFTs: nftArray});
	}

	/* Dynamic payee configurations for every asset type represented in the
	 * collection of EnftS.  Note that the list of PayeeConfigS are strictly
	 * dictated by the downloaded Enft data, thus local to this page.
	 */
	const [payeeConfigs, setPayeeConfigs] = useState([]);

	// build all the new PayeeConfig records at once from a list
	function initPayeeConfigs(contractList) {
		const pConfigs = [];
		for (const contract of contractList) {
			const config = new PayeeConfig();
			config.asset = contract;
			pConfigs.push(config);
		}
		if (payeeConfigs.length === 0) {
			setPayeeConfigs([...pConfigs]);
		} else {
			console.warn("unexpected initPayeeConfigs() with existing configs");
			setPayeeConfigs([...payeeConfigs, ...pConfigs]);
		}
	}

	// add a new payeeConfig to the list
	function addPayeeConfig(tokenContract) {
		var payeeConfig = payeeConfigs.find(elt => elt.asset === tokenContract);
		// we need a payeeConfig for every asset type
		if (payeeConfig === undefined) {
			payeeConfig = new PayeeConfig();
			payeeConfig.asset = tokenContract;
			setPayeeConfigs(payeeConfigs => ([...payeeConfigs, payeeConfig]));
		} else {
			// do nothing; handleUpdatePayeeConfig() should be called
			console.warn("PayeeConfig for added contract " + tokenContract
						+ " already exists; use handleUpdatePayeeConfig()");
		}
	}

	// method to update a PayeeConfig record by merging state
	function handleUpdatePayeeConfig(updConfig) {
		if (updConfig === undefined) return;
		const match = payeeConfigs.find(elt => elt.asset === updConfig.asset);
		if (match !== undefined) {
			setPayeeConfigs(payeeConfigs.map(c => {
				if (c.asset === match.asset) {
					// replace with the modified one
					return updConfig;
				} else {
					return c;
				}
			}));
		} else {
			console.error("No PayeeConfig found in state for asset "
						+ updConfig.asset);
		}
	}

	/* List of symbols for each token cotnract for which EnftS (and therefore
	 * PayeeConfigS) exist.  These records look like:
	 * 	{contract: address, symbol: contract.symbol()}.
	 */
	const [tokenSymbols, setTokenSymbols] = useState([]);

	// set all the new tokenSymbol records at once from a list
	function initTokenSymbols(symbolList) {
		if (tokenSymbols.length === 0) {
			// replace entire list
			setTokenSymbols([...symbolList]);
		} else {
			console.warn("unexpected initTokenSymbols() with existing configs");
			// append to list
			setTokenSymbols([...tokenSymbols, ...symbolList]);
		}

		// record symbols in PayeeConfig for same contract
		symbolList.forEach(symb => {
			const payeeConfig
				= payeeConfigs.find(elt => elt.asset === symb.contract);
			if (payeeConfig !== undefined) {
				payeeConfig.symbol = symb.symbol;
			}
		});
	}

	/* See if we need to create a PayeeConfig for each asset type.
	 * Also check whether we need to fetch a symbol for the token.
	 * This has the effect of queuing up a number of async token fetches, each
	 * of which will (upon completion) bring us back through this code again.
	 */
	const newAssets = [];
	const newSymbols = [];
	eNFTTableProps.forEach(confRec => {
		// check for a PayeeConfig
		const existingConfig
			= payeeConfigs.find(elt => elt.asset === confRec.asset);
		if (existingConfig === undefined) {
			newAssets.push(confRec.asset);
		}

		// check for a token symbol
		const existSymbol
			= tokenSymbols.find(elt => elt.contract === confRec.asset);
		if (existSymbol === undefined) {
			newSymbols.push(confRec.asset);
		}
	});

	// NB: the Refresh button may be used multiple times while on this page
	if (payeeConfigs.length === 0) {
		// add new all at once
		if (newAssets.length > 0) {
			initPayeeConfigs(newAssets);
		}
	} else if (newAssets.length > 0) {
		// add only the new ones
		for (const asset of newAssets) {
			addPayeeConfig(asset);
		}
	}

	// method to get symbol of an erc20-compatible contract
	const getTokenSymbol = async (tokenAddr) => {
		var symbol = '';
		const callData = web3.eth.abi.encodeFunctionCall({
			name: 'symbol',
			type: 'function',
			constant: true,
			inputs: []
		}, []);
		await web3.eth.call({
			to: tokenAddr,	// erc20/erc777/erc4626 contract address
			data: callData	// function call encoding
		})
			.then(sym => {
				symbol = web3.eth.abi.decodeParameter('string', sym);
			}).catch(err => {
				console.error("error fetching symbol at " + tokenAddr + ": "
							+ err);
			});
		return symbol;
	};

	// obtain all token symbols from each respective token contract w/ symbol()
	const getAllTokenSymbols = async () => {
		if (newSymbols.length === 0) {
			return;
		}
		const tokSymbols = [];
		for await (const tokenContract of newSymbols) {
			const tokSym = await getTokenSymbol(tokenContract);
			if (tokSym !== '') {
				const tokRec = {contract: tokenContract, symbol: tokSym};
				tokSymbols.push(tokRec);
			} else {
				console.error("Error fetching token symbol for "
							+ tokenContract);
			}
		}

		// update state
		initTokenSymbols(tokSymbols);
	};
	// obtain any token symbols we don't already have
	getAllTokenSymbols();

	/* List of assets (contract addresses) for which the PayeeConfig has been
	 * set active by the UI switch setting.  Every asset represented by at least
	 * one Enft will exist with a displayed config, but the deactivated ones
	 * will be greyed out.
	 */
	const [selectedConfigs, setSelectedConfigs] = useState([]);

	/* method to select an asset's PayeeConfig by merging state
	 * @param token the contract address
	 * @param active true to select, false to unselect
	 */
	function selectPayeeConfig(token, active) {
		const config = payeeConfigs.find(elt => elt.asset === token);
		// mark the PayeeConfig internally as active
		config.active = active;
		const present = selectedConfigs.find(elt => elt === token);
		if (present !== undefined) {
			if (!active) {
				// remove
				setSelectedConfigs(
								selectedConfigs.filter(elt => elt !== token));
			}
		} else {
			if (active) {
				// add
				setSelectedConfigs([...selectedConfigs, token]);
			}
		}
	}

	/* List of balances to be spent, by asset type (differs from .total value
	 * in a PayeeConfig; an implicit SpendPayee will be created for difference).
	 * These records look like: {contract: address, amount: value (in ethers)}.
	 */
	const [spendAmounts, setSpendAmounts] = useState([]);

	/* method to set the balance to be spent for an asset type
	 * @param asset the token contract
	 * @param amount the amount of this user wants to spend
	 */
	function setAssetSpendAmount(asset, amount) {
		const existing = spendAmounts.find(elt => elt.contract === asset);
		if (existing !== undefined) {
			// update this one
			existing.amount = amount;
			setSpendAmounts(spendAmounts.map(s => {
				if (s.contract === existing.contract) {
					return existing;
				} else {
					return s;
				}
			}));
		} else {
			// add new
			const assetAmt = {contract: asset, amount: amount};
			setSpendAmounts([...spendAmounts, assetAmt]);
		}
	}

	// result of smart contract submission (used only on success)
	const [smartResult, setSmartResult] = useState("");

	/* reset the user's payee configs (called by the SmartContractSubmitter
	 * upon a successful smart contract spend)
	 * @param configList the updated array of configs that were part of payment
	 */
	function resetPayeeConfigs(configList) {
		if (!(configList instanceof Array)) {
			console.error("resetPayeeConfigs(): bad config array, got a "
						+ typeof(configList));
			return;
		}

		/* 1. To prevent errors re-rendering SmartContractSubmitter, add a done
		 * 	  flag to the passed OperationsBlock json object.
		 *
		 * NB: in this method we do everything without a setState() on purpose,
		 * 	   except setting the success DAlert.  This is to minimize
		 * 	   re-renderings and possible looping problems.
		 */
		if (props.opsBlock !== undefined) {
			props.opsBlock.done = true;
		}

		// clear selected data for each PayeeConfig involved in the payment
		configList.forEach(pConfig => {
			const existing
				= payeeConfigs.find(elt => elt.asset === pConfig.asset);
			if (existing !== undefined) {
				// 2. remove all input eNFTs, now burned
				pConfig.selectedENFTs.forEach((enft, id) => {
					// get index of this Id in the eNFT list
					let delIdx = -1;
					for (let idIdx = 0; idIdx < eNFTs.length; idIdx++) {
						if (eNFTs[idIdx].id === id) {
							delIdx = idIdx;
							break;
						}
					}
					// if index found, remove it
					if (delIdx >= 0) {
						eNFTs.splice(delIdx, 1);
					} else {
						console.error("spent eNFT id " + id + " not found");
					}
				});
				// and clear the selected list map
				pConfig.selectedENFTs.clear();

				// 3. wipe previous payees and zero total
				pConfig.payees.splice(0, pConfig.payees.length);
				pConfig.total = new BigNumber(0.0);

				// 4. de-select active in PayeeConfig and remove from list
				pConfig.active = false;
				selectPayeeConfig(pConfig.asset, false);

				// 5. zero out spendAmount for this asset
				setAssetSpendAmount(pConfig.asset, 0.0);
			} else {
				console.error("no preexisting config for " + pConfig.symbol);
			}
		});

		// indicate that the spend worked
		if (smartResult !== '') setSmartResult('');
		const spendSuccess = <DAlert variant="success"
							title="Your spend of eNFTs succeeded!"
							data="If any were paid to your own account, you can now download them to view details." />;
		setSmartResult(smartResult => (spendSuccess));
	}

	// construct data to pass to PayeeTable as props (all numbers in ethers)
	const eNFTPayeeProps = [];
	assetIdx = 1;
	for (const asst of assetTotals.keys()) {
		const availBucket = assetAvails.get(asst);
		var availTot = "0";
		if (availBucket !== undefined) {
			availTot = web3.utils.fromWei(availBucket);
		}
		// all payeeConfigS should have been created by <ENFTTable/>
		var payeeConfig = payeeConfigs.find(elt => elt.asset === asst);
		eNFTPayeeProps.push({id: assetIdx++,
							asset: asst,
							available: availTot,
							payeeConfig: payeeConfig});
	}

	// method to send a signed wallet download request to an MVO
	async function sendWalletReqToMVO(resolve, reject) {
		// examine passed MVO configuration to ensure it's been downloaded
		const mvoConfig = chainConn.MVOConf;
		const chId = chainConn.chainConfig.chainId;
		if (mvoConfig.availableMVOs.length === 0) {
			let inputErr = new Error("No MVOs listed for chainId " + chId
					+ "; is " + chainConn.chainConfig.chain + " connected?");
			alert(inputErr.message);
			reject(inputErr);
			return false;
		}

		// obtain secure communicator to randomly selected MVO for this chain
		const mvoComm = mvoConfig.getMVOCommunicator('wallet', true);
		if (!(mvoComm instanceof MVOComm)) {
			let mvoErr = new Error("Could not select an MVO");
			alert(mvoErr.message);
			reject(mvoErr);
			return false;
		}

		// access msg.sender and verifying contract address
		const sender = userAcct;
		const enshAddress = enshContract.options.address;

		// generate reply key and the payload we must sign
		var replyKey = '';
		var payload = '';
		if (!mvoComm.encrypted) {
			// old version, for use without encryption (passed as POST param)
			payload = 'walletDownload={"chainId":"' + chId
						+ '","sender":"' + sender + '","IDList":[]}';

			// send plain data unencrypted and unsigned
			mvoComm.sendToMVO(payload, props.parseReplyMethod);
		} else {
			// generate an AES key for the MVO to use to encrypt normal replies
			mvoComm.generateAesKey();
			// NB: generateAesKey() stored raw key in mvoComm.replyKeyB64
			replyKey = mvoComm.decryptKey;

			// define eth_signTypedData_v4 parameters
			const msgParams = JSON.stringify({
				// EIP-712 domain info (depends upon chId scan URL for display)
				domain: {
					chainId: chId,
					name: 'Enshroud',
					verifyingContract: enshAddress,
					version: '1',
				},
				// descriptive info on what's being signed and for whom
				message: {
					contents: 'Send encrypted request to download the eNFTs and keys in your wallet',
					to: {
						MVOId: mvoComm.mvo,
						URL: mvoComm.mvoURL,
					},
					requestJson: {
						walletDownload: {
							chainId: `${chId}`,
							sender: sender,
							IDList: [],
							replyKey: replyKey,
						},
					},
				},
				primaryType: 'Request',
				types: {
					// the domain the contract is hosted on
					EIP712Domain: [
						{ name: 'chainId', type: 'uint256' },
						{ name: 'name', type: 'string' },
						{ name: 'verifyingContract', type: 'address' },
						{ name: 'version', type: 'string' },
					],
					// refer to primaryType
					Request: [
						{ name: 'contents', type: 'string' },
						{ name: 'to', type: 'MVO' },
						{ name: 'requestJson', type: 'WalletDownload' },
					],
					// not an EIP712Domain definition
					MVO: [
						{ name: 'MVOId', type: 'string' },
						{ name: 'URL', type: 'string' },
					],
					// not an EIP712Domain definition
					WalletDownload: [
						{ name: 'walletDownload', type: 'Payload' },
					],
					// not an EIP712Domain definition
					Payload: [
						{ name: 'chainId', type: 'string' },
						{ name: 'sender', type: 'address' },
						{ name: 'IDList', type: 'string[]' },
						{ name: 'replyKey', type: 'string' },
					],
				},
			});
			const method = 'eth_signTypedData_v4';
			var params = [sender, msgParams];

			// now obtain signature on params in a EIP-712 compatible way
			var userSig;
			await web3.currentProvider.sendAsync(
				{
					method,
					params,
					from: sender,
				},
				function (err, result) {
					if (err) console.dir(err);
					if (result.error) alert(result.error.message);
					if (result.error) console.error('ERROR', result.error);
					userSig = result.result;
					if (userSig === undefined) {
						let sigErr
							= new Error("Error building EIP712 signature");
						reject(sigErr);
						return false;
					}

					// append signature to the arguments
					const allArgs = JSON.parse(msgParams);
					allArgs.signature = userSig;

					// encrypt + send the message to the MVO, passing callback
					mvoComm.sendToMVO(JSON.stringify(allArgs),
									  props.parseReplyMethod);
					// NB: even if sendToMVO() fails, we still want to resolve
					resolve(true);
				}
			);
		}

		// clear the existing eNFT list
		eNFTs.splice(0, eNFTs.length);
		return true;
	}
	
	// method to send a signed wallet spend request to an MVO
	async function sendSpendReqToMVO(resolve, reject) {
		// access msg.sender and verifying contract address
		const sender = userAcct;
		const enshAddress = enshContract.options.address;

		// examine passed MVO configuration to ensure it's been downloaded
		const mvoConfig = chainConn.MVOConf;
		const chId = chainConn.chainConfig.chainId;
		if (mvoConfig.availableMVOs.length === 0) {
			let inputErr = new Error("No MVOs listed for chainId " + chId
					+ "; is " + chainConn.chainConfig.chain + " connected?");
			alert(inputErr.message);
			reject(inputErr);
			return false;
		}

		// obtain secure communicator to randomly selected MVO for this chain
		const mvoComm = mvoConfig.getMVOCommunicator('spend', true);
		if (!(mvoComm instanceof MVOComm)) {
			let mvoErr = new Error("Could not select an MVO");
			alert(mvoErr.message);
			reject(mvoErr);
			return false;
		}

		// verify that we have at least one selected asset, or there's no point
		if (selectedConfigs.length === 0) {
			let noConf = new Error("No available assets are selected");
			alert(noConf.message);
			reject(noConf);
			return false;
		}

		// verify that we haven't lost our eNFT[] list
		if (props.eNFTList.length === 0) {
			let noEnfts = new Error("Please Refresh the list of eNFTs");
			alert(noEnfts.message);
			reject(noEnfts);
			return false;
		}

		/* Before doing anything else, we must audit the payee config for
		 * sanity.  If the requested outputs exceed the inputs, no point in
		 * sending as the MVO will not let us get away with such tripe.
		 *
		 * If the eNFT amount is not fully utilized, we must gin up a new
		 * implicit payee back to the sender to catch the difference.
		 * (Although if we didn't, the MVO would do this on its own.)
		 */
		// loop through all selected PayeeConfigS, build Maps of totals
		const payeeTotalsPerAsset = new Map();
		const totalEnftValuePerAsset = new Map();
		const selectedEnftValuePerAsset = new Map();
		selectedConfigs.forEach(selectedAsset => {
			const payeeConfig
				= payeeConfigs.find(elt => elt.asset === selectedAsset);
			// total up the SpendPayeeS
			var total = new BigNumber(0.0);
			payeeConfig.payees.forEach(payee => {
				total = total.plus(payee.amount);
			});
			if (!total.eq(payeeConfig.total)) {
				console.error("Computed total " + total.toString()
							+ " does not match internal total "
							+ payeeConfig.total.toString()
							+ " in payeeConfig for asset " + payeeConfig.asset);
				payeeConfig.total = total;
			}
			if (total.gte(0.0)) {
				payeeTotalsPerAsset.set(payeeConfig.asset, total.toString());

				/* Loop through all Enfts in wallet, and arrive at overall and
				 * selected totals.  Use BigNumberS to prevent roundoff errors.
				 */
				var enftTotal = new BigNumber(0.0);
				var selTotal = new BigNumber(0.0);
				props.eNFTList.forEach(enft => {
					if (enft.asset === payeeConfig.asset
						&& enft.valid && enft.avail)
					{
						const enftAmt = web3.utils.fromWei(enft.amount);
						enftTotal = enftTotal.plus(enftAmt);
						if (payeeConfig.isENFTselected(enft.id)) {
							selTotal = selTotal.plus(enftAmt);
						}
					}
					// else: ignore
				});
				totalEnftValuePerAsset.set(payeeConfig.asset,
										   enftTotal.toString());
				selectedEnftValuePerAsset.set(payeeConfig.asset,
											  selTotal.toString());
			}
		});

		// for each asset with non-zero payee total...
		var gotErr = false;
		const totIterator = payeeTotalsPerAsset[Symbol.iterator]();
		for (const totItem of totIterator) {
			// if we don't have enough of the indicated asset, that's it, stop
			const asset = totItem[0];
			const payeeTot = totItem[1];
			const tokSym = tokenSymbols.find(elt => elt.contract === asset);
			// get the Total Spend Amount, which includes any remainder (change)
			const totSpend = spendAmounts.find(elt => elt.contract === asset);
			if (totSpend === undefined) {
				let noTot = new Error("No Total Spend Amount found for "
										+ tokSym.symbol);
				alert(noTot.message);
				reject(noTot);
				gotErr = true;
				break;
			}
			var totSpendAmt = new BigNumber(totSpend.amount);
			if (totSpendAmt.lt(payeeTot)) {
				let overage = new Error("Total for " + tokSym.symbol
									+ " ("+ payeeTot.toString() + ")"
									+ " should not exceed TotalSpend Amount, "
									+ totSpendAmt.toString());
				alert(overage.message);
				reject(overage);
				gotErr = true;
				totSpendAmt = new BigNumber(payeeTot);
				break;
			}
			const totAvail = new BigNumber(totalEnftValuePerAsset.get(asset));
			if (totAvail.lt(totSpendAmt)) {
				let insuff = new Error("Insufficient eNFT value to fund a "
										+ "spend of " + totSpendAmt.toString()
										+ " " + tokSym.symbol + ", max = "
										+ totAvail.toString());
				alert(insuff.message);
				reject(insuff);
				gotErr = true;
				break;
			}

			// check for adequate selected value
			var totSelected
				= new BigNumber(selectedEnftValuePerAsset.get(asset));
		/*
			console.debug("for spend in " + tokSym.symbol + ", totAmt = "
						+ totSpendAmt.toString() + ", avail = "
						+ totAvail.toString() + ", sel = "
						+ totSelected.toString());
		 */
			const selConfig = payeeConfigs.find(elt => elt.asset === asset);
			if (totSelected.lt(totSpendAmt)) {
				// loop through eNFTs of this asset type again and sort by gen
				const assetEnfts = [];
				props.eNFTList.forEach(enft => {
					if (enft.asset === asset && enft.valid && enft.avail) {
						assetEnfts.push(enft);
					}
				});
				// use an ascending sort so that lowest generations come first
				assetEnfts.sort((a, b) => a.generation - b.generation);

				// loop through the sorted Enfts and select until we have enough
				for (const enft of assetEnfts) {
					if (selConfig.isENFTselected(enft.id)) continue;
					const enftAmt
						= new BigNumber(web3.utils.fromWei(enft.amount));
					totSelected = totSelected.plus(enftAmt);
					selConfig.selectENFT(enft.id, enft);
					if (totSelected.gte(totSpendAmt)) {
						break;
					}
				}
			}

			// add additional payee for any overage not in an explicit payee
			var overage = totSelected.minus(payeeTot);
			if (overage.gt(0.0)) {
				// add a payee to user for this difference
				const chgPayee = selConfig.boundAddPayee(sender, overage);
				chgPayee.memo = "auto-generated change amount";
			} else if (overage.lt(0.0)) {
				// shouldn't happen
				let selectFail = new Error("Could not select enough eNFT "
											+ "value to spend "
											+ totSpendAmt.toString()
											+ " of " + tokSym.symbol);
				alert(selectFail.message);
				reject(selectFail);
				gotErr = true;
			}

			/* If we got an error, undo the selections we made for this
			 * payeeConfig.  Then if we repeat, selection will repeat also.
			 * This prevents a case where an implicit payee gets skipped.
			 */
			if (gotErr) {
				props.eNFTList.forEach(enft => {
					selConfig.deselectENFT(enft.id);
				});
			}
		}
		if (gotErr) {
			return false;
		}

		/* The smart contract (and the MVOs) will only allow us to mint 20
		 * eNFTs.  Likewise, we can only utilize a max of 20 input eNFTs.  
		 * For each output payee, assign a salt value if it doesn't have one.
		 * Build the json strings for both inputs and payees.
		 */
		var totOutputs = 0;
		var totInputs = 0;
		var inputJson = '"inputs":[';
		var payeeJson = '"payees":[';
		var inputArray = [];
		var payeeArray = [];
		var payeeIdx = 1;
		var inputIdx = 1;
		const nf = new Intl.NumberFormat("en-US", {minimumIntegerDigits: 3});
		for (const selectedAsset of selectedConfigs) {
			const payeeConfig
				= payeeConfigs.find(elt => elt.asset === selectedAsset);
			// check number of inputs and outputs first
			totOutputs += payeeConfig.payees.length;
			if (totOutputs > ARRAY_LIMIT) {
				let outErr = new Error("Sorry, the maximum number of eNFTs "
										+ "that can be minted at once is "
										+ ARRAY_LIMIT + ".");
				alert(outErr.message);
				reject(outErr);
				gotErr = true;
				break;
			}
			totInputs += payeeConfig.selectedENFTs.size;
			if (totInputs > ARRAY_LIMIT) {
				let inErr = new Error("Sorry, the maximum number of input "
										+ "eNFTs which can be used at once is "
										+ ARRAY_LIMIT + ".");
				alert(inErr.message);
				reject(inErr);
				gotErr = true;
				break;
			}

			// set rand values and append each payee to array and to string
			for (const payee of payeeConfig.payees) {
				// NB: 16 bytes of base64Url will be 32 chars
				if (payee.rand.length !== 32) {
					// either doesn't exist or improper
					payee.setRand();
				}
				// add this payee to the JSON list
				let payeeAmt = web3.utils.toWei(payee.amount.toString());
				let payeeIdent = "payee" + nf.format(payeeIdx);
				let payeeStr = '{"payeeLabel":"' + payeeIdent
							+ '","payeeSpec":{"address":"' + payee.address
							+ '","asset":"' + payeeConfig.asset
							+ '","amount":"' + payeeAmt
							+ '","units":"","rand":"' + payee.rand
							+ '","memo":"' + payee.memo + '"}}';
				if (payeeIdx < payeeConfig.payees.length) {
					// add a comma except on last one
					payeeStr += ',';
				}
				payeeJson += payeeStr;

				// add to array as well (for encrypted/signature case)
				const payeeDetails = {	address: payee.address,
										asset: payeeConfig.asset,
										amount: payeeAmt,
										units: '',
										rand: payee.rand,
										memo: payee.memo };
				const payeeObj
					= {	payeeLabel: payeeIdent, payeeSpec: payeeDetails	};
				payeeArray.push(payeeObj);
				payeeIdx++;
			}

			// append each input to array and to string
			const mapIter = payeeConfig.selectedENFTs.entries();
			for (const enft of mapIter) {
				let inputIdent = "input" + nf.format(inputIdx);
				let inputStr
					= '{"inputLabel":"' + inputIdent + '","inputSpec":';
				const eNFT = enft[1];
				let inputDetJson = eNFT.emitEnftJSON();
				inputStr += inputDetJson;

			 	// add key=AESkey
			 	let aesKey = eNFT.AESkey;
			 	if (aesKey !== '') {
			 		let keyStr = ',"key":"' + aesKey + '"';
			 		inputStr += keyStr;
			 	}
				inputStr += "}";

				if (inputIdx < payeeConfig.selectedENFTs.size) {
					// add a comma except on last one
					inputStr += ',';
				}
				inputJson += inputStr;

				// add to array as well (for encrypted/signature case)
				let inputDetails = JSON.parse(inputDetJson);
				const inputObj = {	inputLabel: inputIdent,
									inputSpec: inputDetails,
									key: aesKey };
				inputArray.push(inputObj);
				inputIdx++;
			}
		}
		if (gotErr) {
			return false;
		}
		payeeJson += ']';
		inputJson += ']';

		// now generate reply key and the payload we must sign
		var replyKey = '';
		var payload = '';
		if (!mvoComm.encrypted) {
			// old version, for use without encryption (passed as POST param)
			// NB: unused in current implementation
			payload = 'spendspec={"chainId":"' + chId + '","opcode":"spend"'
						+ ',"sender":"' + sender + '",' + inputJson + ','
						+ payeeJson + ',"signature":'
						// NB: in unencrypted case signature is never verified
						+ '"130 character signature string goes here"}';

			// send plain data unencrypted and unsigned
			mvoComm.sendToMVO(payload, props.parseReplyMethod);
			resolve(true);
		} else {
			// generate an AES key for the MVO to use to encrypt normal replies
			mvoComm.generateAesKey();
			// NB: generateAesKey() stored raw key in mvoComm.replyKeyB64
			replyKey = mvoComm.decryptKey;

			// define eth_signTypedData_v4 parameters
			const msgParams = JSON.stringify({
				// EIP-712 domain info (depends upon chId scan URL for display)
				domain: {
					chainId: chId,
					name: 'Enshroud',
					verifyingContract: enshAddress,
					version: '1',
				},
				// descriptive info on what's being signed and for whom
				message: {
					contents: 'Send encrypted request to spend eNFTs to your specified payees',
					to: {
						MVOId: mvoComm.mvo,
						URL: mvoComm.mvoURL,
					},
					requestJson: {
						spendspec: {
							chainId: `${chId}`,
							opcode: 'spend',
							sender: sender,
							inputs: inputArray,
							payees: payeeArray,
							replyKey: replyKey,
						},
					},
				},
				primaryType: 'Request',
				types: {
					// the domain the contract is hosted on
					EIP712Domain: [
						{ name: 'chainId', type: 'uint256' },
						{ name: 'name', type: 'string' },
						{ name: 'verifyingContract', type: 'address' },
						{ name: 'version', type: 'string' },
					],
					// refer to primaryType
					Request: [
						{ name: 'contents', type: 'string' },
						{ name: 'to', type: 'MVO' },
						{ name: 'requestJson', type: 'SpendSpec' },
					],
					// not an EIP712Domain definition
					MVO: [
						{ name: 'MVOId', type: 'string' },
						{ name: 'URL', type: 'string' },
					],
					// not an EIP712Domain definition
					SpendSpec: [
						{ name: 'spendspec', type: 'Payload' },
					],
					// not an EIP712Domain definition
					Payload: [
						{ name: 'chainId', type: 'string' },
						{ name: 'opcode', type: 'string' },
						{ name: 'sender', type: 'address' },
						{ name: 'inputs', type: 'Input[]' },
						{ name: 'payees', type: 'Payee[]' },
						{ name: 'replyKey', type: 'string' },
					],
					// not an EIP712Domain definition
					Input: [
						// NB: smart contract supports max of 20 eNFTs at once
						{ name: 'inputLabel', type: 'string'},
						{ name: 'inputSpec', type: 'EnshroudedSpec'},
						{ name: 'key', type: 'string'},
						// NB: at least one input required
					],
					// not an EIP712Domain definition
					EnshroudedSpec: [
						{ name: 'enshrouded', type: 'InputSpec' },
					],
					// not an EIP712Domain definition
					InputSpec: [
						{ name: 'id', type: 'string' },
						{ name: 'schema', type: 'string' },
						{ name: 'owner', type: 'address' },
						{ name: 'asset', type: 'address' },
						{ name: 'amount' , type: 'string' },
						{ name: 'rand', type: 'string' },
						{ name: 'generation', type: 'string' },
						//{ name: 'expiration', type: 'string' },
						//{ name: 'growth', type: 'string' },
						//{ name: 'cost', type: 'string' },
						{ name: 'memo', type: 'string' },
						{ name: 'signer', type: 'string' },
						{ name: 'signature', type: 'string' },
					],
					// not an EIP712Domain definition
					Payee: [
						// NB: smart contract supports max of 20 eNFTs at once
						{ name: 'payeeLabel', type: 'string'},
						{ name: 'payeeSpec', type: 'PayeeSpec'},
						// NB: at least one payee required
					],
					// not an EIP712Domain definition
					PayeeSpec: [
						{ name: 'address', type: 'address' },
						{ name: 'asset', type: 'address' },
						{ name: 'amount' , type: 'string' },
						{ name: 'units', type: 'string' },
						{ name: 'rand', type: 'string' },
						{ name: 'memo', type: 'string' },
					],
				},
			});
			//console.debug("EIP712 sign args: \"" + msgParams + "\"");
			const method = 'eth_signTypedData_v4';
			var params = [sender, msgParams];

			// now obtain signature on params in a EIP-712 compatible way
			var userSig;
			await web3.currentProvider.sendAsync(
				{
					method,
					params,
					from: sender,
				},
				function (err, result) {
					if (err) console.dir(err);
					if (result.error) alert(result.error.message);
					if (result.error) console.error('ERROR', result.error);
					userSig = result.result;
					if (userSig === undefined) {
						// cause redisplay with possible change payees added
						for (const selectedAsset of selectedConfigs) {
							const pConfig = payeeConfigs.find(
											elt => elt.asset === selectedAsset);
							handleUpdatePayeeConfig(pConfig);
							// make spendAmount.amount match config if less
							const assetTotalAmt = spendAmounts.find(
										elt => elt.contract === selectedAsset);
							if (pConfig.total.gt(assetTotalAmt.amount)) {
								setAssetSpendAmount(selectedAsset, 
													pConfig.total.toString());
							}
						}
						let sigErr
							= new Error("Error building EIP712 signature");
						reject(sigErr);
						return false;
					}

					// append signature to the arguments
					const allArgs = JSON.parse(msgParams);
					allArgs.signature = userSig;

					// encrypt + send the message to the MVO, passing callback
					mvoComm.sendToMVO(JSON.stringify(allArgs),
									  props.parseReplyMethod);
					// NB: even if sendToMVO() fails, we still want to resolve
					resolve(true);
				}
			);
		}
		return true;
	}

	// render spend UI
	const spendENFTs = 
		<>
		<div className="SpendENFTs">
		<Container fluid align="center">
			<h2>Spend eNFTs</h2>
			<br/><br/>

			{ /* Refresh button for balances */ }
			<h4>Your eNFT Balances:
				<LoadingButton variant="primary"
					buttonTitle="This fetches the eNFT list from an MVO, and populates the eNFTs Available table below"
					netMethod={(resolve, reject) => sendWalletReqToMVO(resolve, reject)}
					buttonText="Refresh"
					buttonStyle="m-3"
					buttonIcon="images/download.svg"
				/>
				<i>(signature required)</i>
			</h4>
			<br/><br/>
		</Container>

		{ /* instructions */ }
		<Container fluid align="left">
			<h4>Spending Your Existing eNFTs</h4>
			<ListGroup as="ol" numbered className="text-muted">
				<ListGroup.Item as="li">
					In the <i>eNFTs Available for Spending</i> table below,
					activate each asset you wish to spend using the switch,
					and enter the total amount of that token to be spent.  If
					the table is empty, use the <b>Refresh</b> button above to
					populate it.  Also use <b>Refresh</b> after making a spend,
					to add to the table any change eNFTs minted back to your
					account.
				</ListGroup.Item>
				<ListGroup.Item as="li">
					Then specify individual payee addresses and amounts in
					the <i>eNFT Payee Configuration</i> table below.
					For readability all amounts are specified
					in <i>ethers</i> (1e-18 units), rather than in <i>wei</i>.
					Note you must use the '+' icon to add each payee item to
					the configuration.
				</ListGroup.Item>
				<ListGroup.Item as="li">
					Configure (encrypt) your Spend by signing and sending a
					request to an MVO on Layer2.  To do this use
					the <b>Configure Spend</b> button below.
				</ListGroup.Item>
				<ListGroup.Item as="li">
					You will then have a chance to review the preprocessed spend
					transaction once it has been returned from the MVOs, before
					signing again and sending it to the blockchain for mining.
				</ListGroup.Item>
				<ListGroup.Item as="li" variant="info">
					Any unallocated remainder will be spent back to your own
                    account as a "change" eNFT automatically.
					<br/>
					Note that the Remainder shown may be smaller than the
					actual change eNFT you will receive, due to the fact that
					the selected input eNFTs may add up to a larger total than
					the Total Spend Amount you have entered for any given asset.
				</ListGroup.Item>
				<ListGroup.Item as="li" variant="info">
					(Optional) You can override the default selection of input
                    eNFTs (lowest generations first) by opening the list of
					eNFTs and selecting a sufficient amount manually.  You must
					select inputs at least equal to the Total Spend Amount.
					(If you do not, additional inputs will be auto-selected.)
				</ListGroup.Item>
			</ListGroup>
			<br/><br/>
		</Container>

		{ /* table for eNFTs in wallet */ }
		<Container fluid>
			<Table striped bordered hover responsive variant="dark">
				<caption className="caption-top">
					eNFTs Available for Spending:<br/>
					<i>
						Available can be less if one or more eNFTs is 
						unconfirmed (marked with a yellow badge).  If this
						occurs, wait a few minutes and refresh your eNFT list.
					</i>
				</caption>
				<thead>
					<tr align="center" valign="middle" key="eNFThead">
						<th scope="col"
							title="Toggle inclusion of this asset in the Payee Configuration below">
							Select
						</th>
						<th scope="col" title="Symbol for this token">
							Asset
						</th>
						<th scope="col"
							title="In ethers units, converted from wei for display">
							Balance <i>(Available)</i>
						</th>
						<th scope="col"
							title="The total for this asset in the eNFT Payee Configuration below will equal this amount">
							Total Spend Amount (1e-18)
						</th>
						<th scope="col"
							title="The value of the Total Spend Amount in USD at market prices, if available">
							Value in USD
						</th>
					</tr>
				</thead>

				{ /* this supplies the <tbody/> */}
				<ENFTTable
					details={eNFTTableProps}
					payeeConfigs={payeeConfigs}
					activeConfigs={selectedConfigs}
					onConfigSelect={(contract, active) => selectPayeeConfig(contract, active)}
					onTotalChange={(asset, amount) => setAssetSpendAmount(asset, amount)}
					onNewPayeeConfig={(contract) => addPayeeConfig(contract)}
					tokenSymbols={tokenSymbols}
				/>
			</Table>
			<br/><br/>
		</Container>
		<hr/>
		<br/>

		{ /* table for eNFT payee configuration */ }
		<Container fluid>
			<Table striped bordered hover responsive variant="dark">
				<caption className="caption-top">
					eNFT Payee Configuration:
				</caption>
				<thead>
					<tr align="center" valign="middle" key="payeeHead">
						<th scope="col" title="Specify payee address">
							Spend to Address
						</th>
						<th scope="col"
							title="Symbol for token; see hover text for contract address">
							Asset
						</th>
						<th scope="col"
							title="Enter amounts in ethers; they will be converted to wei">
							Amount (1e-18)
						</th>
						<th scope="col"
							title="Enter any note from you for the payee">
							Memo
						</th>
						<th scope="col"
							title="Click + icon to add payee, X icon to remove">
							<h3>+/-</h3>
						</th>
					</tr>
				</thead>

				{ /* this supplies the <tbody/> */ }
				<PayeeTable
					details={eNFTPayeeProps}
					selectedConfigs={selectedConfigs}
					onConfigUpdate={(updatedConfig) => handleUpdatePayeeConfig(updatedConfig)}
					tokenSymbols={tokenSymbols}
					spendAmounts={spendAmounts}
				/>
			</Table>
			<br/><br/>

			{ /* configure spend button */ }
			<h4 align="left">
				<LoadingButton variant="primary" buttonStyle="m-3"
					buttonTitle="This sends the spend config to an MVO for encryption"
					buttonText="Configure Spend"
					buttonIcon="images/send-check.svg"
					netMethod={(resolve, reject) => sendSpendReqToMVO(resolve, reject)}
				/>
				<i>(signature required)</i>
			</h4>
			<p className="text-muted">
				Note: Your account signature is required for authentication to
				the MVO.<br/>
				However no on-chain action occurs during the configuration step.
				<br/>
				You will also review and sign the encrypted transaction again
				before submitting it to the blockchain.
			</p>
			<br/>
		</Container>

		{ /* Show the result of the last successful smart contract submission.
		   * Note this will not be visible once the user has closed the DAlert.
		   */ }
		<br/>
		{smartResult}
		<br/>

		{ /* show MVO results and prompt for smart contract submittal */ }
		<h4>Smart Contract Submission Preview</h4>
		<SmartContractSubmitter
			opsBlock={props.opsBlock}
			active={props.active}
			opcode='spend'
			payeeConfigs={payeeConfigs}
			resetConfigs={(configs) => resetPayeeConfigs(configs)}
			eNFTList={eNFTs}
		/>
		<br/>
		</div>
		</>;

	return (
		<div id="SpendENFTs">
		{
			!artifacts.EnshroudProtocol ? <NoticeNoArtifact /> :
			contracts == null ||
					!contracts["EnshroudProtocol"] ? <NoticeWrongNetwork /> :
				spendENFTs
		}
		</div>
	);
}

export default SpendENFTs;
