diff --git a/.github/workflows/nextjs.yml b/.github/workflows/deploy.yml
similarity index 51%
rename from .github/workflows/nextjs.yml
rename to .github/workflows/deploy.yml
index ed747367..69ef6f2d 100644
--- a/.github/workflows/nextjs.yml
+++ b/.github/workflows/deploy.yml
@@ -1,14 +1,11 @@
-# Sample workflow for building and deploying a Next.js site to GitHub Pages
-#
-# To get started with Next.js see: https://nextjs.org/docs/getting-started
-#
-name: Deploy Next.js site to Pages
+# Sample workflow for building and deploying a Vite React site to GitHub Pages
+# This workflow handles a monorepo structure with frontend in a subfolder
+name: Deploy Vite React site to Pages
on:
# Runs on pushes targeting the default branch
push:
branches: ["main"]
-
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
@@ -25,62 +22,75 @@ concurrency:
cancel-in-progress: false
jobs:
- # Build job
+ # Build job - compiles the React app using Vite
build:
runs-on: ubuntu-latest
steps:
+ # Download repository code
- name: Checkout
uses: actions/checkout@v4
+
+ # Detect package manager with robust lockfile handling
- name: Detect package manager
id: detect-package-manager
run: |
- if [ -f "${{ github.workspace }}/yarn.lock" ]; then
+ FRONTEND="${{ github.workspace }}/frontend"
+ # Check for yarn.lock first in frontend directory
+ if [ -f "$FRONTEND/yarn.lock" ]; then
echo "manager=yarn" >> $GITHUB_OUTPUT
echo "command=install" >> $GITHUB_OUTPUT
echo "runner=yarn" >> $GITHUB_OUTPUT
+ echo "lockfile_path=frontend/yarn.lock" >> $GITHUB_OUTPUT
exit 0
- elif [ -f "${{ github.workspace }}/package.json" ]; then
+ # Check for package-lock.json (prefer npm ci when lockfile exists)
+ elif [ -f "$FRONTEND/package-lock.json" ]; then
echo "manager=npm" >> $GITHUB_OUTPUT
echo "command=ci" >> $GITHUB_OUTPUT
- echo "runner=npx --no-install" >> $GITHUB_OUTPUT
+ echo "runner=npm" >> $GITHUB_OUTPUT
+ echo "lockfile_path=frontend/package-lock.json" >> $GITHUB_OUTPUT
+ exit 0
+ # Fallback to npm install when only package.json exists
+ elif [ -f "$FRONTEND/package.json" ]; then
+ echo "manager=npm" >> $GITHUB_OUTPUT
+ echo "command=install" >> $GITHUB_OUTPUT
+ echo "runner=npm" >> $GITHUB_OUTPUT
+ echo "lockfile_path=" >> $GITHUB_OUTPUT
exit 0
else
- echo "Unable to determine package manager"
+ # Neither found - fail the build
+ echo "Unable to determine package manager (missing frontend/)"
exit 1
fi
+
+ # Setup Node.js environment with built-in caching
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: ${{ steps.detect-package-manager.outputs.manager }}
+ cache-dependency-path: ${{ steps.detect-package-manager.outputs.lockfile_path }}
+
+ # Configure GitHub Pages
- name: Setup Pages
uses: actions/configure-pages@v5
- with:
- # Automatically inject basePath in your Next.js configuration file and disable
- # server side image optimization (https://nextjs.org/docs/api-reference/next/image#unoptimized).
- #
- # You may remove this line if you want to manage the configuration yourself.
- static_site_generator: next
- - name: Restore cache
- uses: actions/cache@v4
- with:
- path: |
- .next/cache
- # Generate a new cache whenever packages or source files change.
- key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
- # If source files changed but packages didn't, rebuild from a prior cache.
- restore-keys: |
- ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-
+
+ # Install project dependencies using detected package manager
- name: Install dependencies
run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }}
- - name: Build with Next.js
- run: ${{ steps.detect-package-manager.outputs.runner }} next build
+ working-directory: ./frontend
+
+ # Build the React app using Vite
+ - name: Build with Vite
+ run: ${{ steps.detect-package-manager.outputs.runner }} run build
+ working-directory: ./frontend
+
+ # Upload the built files for deployment
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
- path: ./out
+ path: ./frontend/dist
- # Deployment job
+ # Deployment job - deploys the built files to GitHub Pages
deploy:
environment:
name: github-pages
@@ -88,6 +98,7 @@ jobs:
runs-on: ubuntu-latest
needs: build
steps:
+ # Deploy the uploaded artifact to GitHub Pages
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
diff --git a/contracts/src/Chainvoice.sol b/contracts/src/Chainvoice.sol
index 662a07ad..38cb04cd 100644
--- a/contracts/src/Chainvoice.sol
+++ b/contracts/src/Chainvoice.sol
@@ -2,73 +2,85 @@
pragma solidity ^0.8.13;
interface IERC20 {
- function transferFrom(
- address sender,
- address recipient,
- uint256 amount
- ) external returns (bool);
+ function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
function balanceOf(address account) external view returns (uint256);
- function allowance(
- address owner,
- address spender
- ) external view returns (uint256);
+ function allowance(address owner, address spender) external view returns (uint256);
}
contract Chainvoice {
+ // Errors
+ error MixedTokenBatch();
+ error InvalidBatchSize();
+ error AlreadySettled();
+ error NotAuthorizedPayer();
+ error IncorrectNativeValue();
+ error InsufficientAllowance();
+
+ // Storage
struct InvoiceDetails {
uint256 id;
address from;
address to;
uint256 amountDue;
- address tokenAddress;
+ address tokenAddress; // address(0) == native
bool isPaid;
bool isCancelled;
- string encryptedInvoiceData; // Base64-encoded ciphertext
- string encryptedHash;
+ string encryptedInvoiceData; // Base64-encoded ciphertext
+ string encryptedHash; // Content hash or integrity ref
}
InvoiceDetails[] public invoices;
-
mapping(address => uint256[]) public sentInvoices;
mapping(address => uint256[]) public receivedInvoices;
address public owner;
address public treasuryAddress;
- uint256 public fee;
- uint256 public accumulatedFees;
-
- event InvoiceCreated(
- uint256 indexed id,
- address indexed from,
- address indexed to,
- address tokenAddress
- );
-
- event InvoicePaid(
- uint256 indexed id,
- address indexed from,
- address indexed to,
- uint256 amount,
- address tokenAddress
- );
-
- event InvoiceCancelled(
- uint256 indexed id,
- address indexed from,
- address indexed to,
- address tokenAddress
- );
+ uint256 public fee; // native fee per invoice
+ uint256 public accumulatedFees; // native fees accrued (for withdraw)
+
+ // Events
+ event InvoiceCreated(uint256 indexed id, address indexed from, address indexed to, address tokenAddress);
+ event InvoicePaid(uint256 indexed id, address indexed from, address indexed to, uint256 amount, address tokenAddress);
+ event InvoiceCancelled(uint256 indexed id, address indexed from, address indexed to, address tokenAddress);
+ event InvoiceBatchCreated(address indexed creator, address indexed token, uint256 count, uint256[] ids);
+ event InvoiceBatchPaid(address indexed payer, address indexed token, uint256 count, uint256 totalAmount, uint256[] ids);
+
+ // Constructor
constructor() {
owner = msg.sender;
fee = 0.0005 ether;
}
+ // Modifiers
modifier onlyOwner() {
require(msg.sender == owner, "Only owner can call");
_;
}
+ // Simple non-reentrancy guard
+ bool private _entered;
+ modifier nonReentrant() {
+ require(!_entered, "Reentrancy");
+ _entered = true;
+ _;
+ _entered = false;
+ }
+
+ // Constants
+ uint256 public constant MAX_BATCH = 50;
+
+ // Internal utils
+ function _isERC20(address token) internal view returns (bool) {
+ if (token == address(0)) return false;
+ if (token.code.length == 0) return false;
+ (bool success, ) = token.staticcall(
+ abi.encodeWithSignature("balanceOf(address)", address(this))
+ );
+ return success;
+ }
+
+ // ========== Single-invoice create ==========
function createInvoice(
address to,
uint256 amountDue,
@@ -86,6 +98,7 @@ contract Chainvoice {
);
require(success, "Not an ERC20 token");
}
+
uint256 invoiceId = invoices.length;
invoices.push(
@@ -108,20 +121,86 @@ contract Chainvoice {
emit InvoiceCreated(invoiceId, msg.sender, to, tokenAddress);
}
+ // ========== Batch create ==========
+ function createInvoicesBatch(
+ address[] calldata tos,
+ uint256[] calldata amountsDue,
+ address tokenAddress,
+ string[] calldata encryptedPayloads,
+ string[] calldata encryptedHashes
+ ) external {
+ uint256 n = tos.length;
+ if (n == 0 || n > MAX_BATCH) revert InvalidBatchSize();
+ require(
+ n == amountsDue.length &&
+ n == encryptedPayloads.length &&
+ n == encryptedHashes.length,
+ "Array length mismatch"
+ );
+
+ if (tokenAddress != address(0)) {
+ require(tokenAddress.code.length > 0, "Not a contract address");
+ (bool ok, ) = tokenAddress.staticcall(
+ abi.encodeWithSignature("balanceOf(address)", address(this))
+ );
+ require(ok, "Not an ERC20 token");
+ }
+
+ uint256[] memory ids = new uint256[](n);
+
+ for (uint256 i = 0; i < n; i++) {
+ address to = tos[i];
+ require(to != address(0), "Recipient zero");
+ require(to != msg.sender, "Self-invoicing");
+ uint256 amt = amountsDue[i];
+ require(amt > 0, "Amount zero");
+
+ uint256 invoiceId = invoices.length;
+
+ invoices.push(
+ InvoiceDetails({
+ id: invoiceId,
+ from: msg.sender,
+ to: to,
+ amountDue: amt,
+ tokenAddress: tokenAddress,
+ isPaid: false,
+ isCancelled: false,
+ encryptedInvoiceData: encryptedPayloads[i],
+ encryptedHash: encryptedHashes[i]
+ })
+ );
+
+ sentInvoices[msg.sender].push(invoiceId);
+ receivedInvoices[to].push(invoiceId);
+
+ emit InvoiceCreated(invoiceId, msg.sender, to, tokenAddress);
+ ids[i] = invoiceId;
+ }
+
+ emit InvoiceBatchCreated(msg.sender, tokenAddress, n, ids);
+ }
+
+ // ========== Cancel single invoice ==========
function cancelInvoice(uint256 invoiceId) external {
require(invoiceId < invoices.length, "Invalid invoice ID");
InvoiceDetails storage invoice = invoices[invoiceId];
require(msg.sender == invoice.from, "Only invoice creator can cancel");
- require(
- !invoice.isPaid && !invoice.isCancelled,
- "Invoice not cancellable"
- );
+ require(!invoice.isPaid && !invoice.isCancelled, "Invoice not cancellable");
+
invoice.isCancelled = true;
- emit InvoiceCancelled(invoiceId, invoice.from, invoice.to, invoice.tokenAddress);
+
+ emit InvoiceCancelled(
+ invoiceId,
+ invoice.from,
+ invoice.to,
+ invoice.tokenAddress
+ );
}
- function payInvoice(uint256 invoiceId) external payable {
+ // ========== Pay single invoice ==========
+ function payInvoice(uint256 invoiceId) external payable nonReentrant {
require(invoiceId < invoices.length, "Invalid invoice ID");
InvoiceDetails storage invoice = invoices[invoiceId];
@@ -129,31 +208,24 @@ contract Chainvoice {
require(!invoice.isPaid, "Already paid");
require(!invoice.isCancelled, "Invoice is cancelled");
+ // Effects first for CEI (mark paid, bump fees), then interactions
+ invoice.isPaid = true;
+
if (invoice.tokenAddress == address(0)) {
- // Native token (ETH) payment
- require(
- msg.value == invoice.amountDue + fee,
- "Incorrect payment amount"
- );
+ require(msg.value == invoice.amountDue + fee, "Incorrect payment amount");
accumulatedFees += fee;
- uint256 amountToSender = msg.value - fee;
- (bool sent, ) = payable(invoice.from).call{value: amountToSender}(
- ""
- );
+ (bool sent, ) = payable(invoice.from).call{value: invoice.amountDue}("");
require(sent, "Transfer failed");
} else {
- // ERC20 token payment
require(msg.value == fee, "Must pay fee in native token");
require(
- IERC20(invoice.tokenAddress).allowance(
- msg.sender,
- address(this)
- ) >= invoice.amountDue,
+ IERC20(invoice.tokenAddress).allowance(msg.sender, address(this)) >= invoice.amountDue,
"Insufficient allowance"
);
accumulatedFees += fee;
+
bool transferSuccess = IERC20(invoice.tokenAddress).transferFrom(
msg.sender,
invoice.from,
@@ -162,7 +234,6 @@ contract Chainvoice {
require(transferSuccess, "Token transfer failed");
}
- invoice.isPaid = true;
emit InvoicePaid(
invoiceId,
invoice.from,
@@ -172,6 +243,81 @@ contract Chainvoice {
);
}
+ // ========== Batch pay (all-or-nothing) ==========
+ function payInvoicesBatch(uint256[] calldata invoiceIds) external payable nonReentrant {
+ uint256 n = invoiceIds.length;
+ if (n == 0 || n > MAX_BATCH) revert InvalidBatchSize();
+
+ // Establish token for batch & initial checks
+ uint256 firstId = invoiceIds[0]; // FIX: index into the array
+ require(firstId < invoices.length, "Invalid id");
+
+ InvoiceDetails storage inv0 = invoices[firstId];
+ if (msg.sender != inv0.to) revert NotAuthorizedPayer();
+ if (inv0.isPaid || inv0.isCancelled) revert AlreadySettled();
+
+ address token = inv0.tokenAddress;
+
+ uint256 totalAmounts = 0;
+ uint256 totalNativeFee = fee * n;
+
+ // Validate and sum
+ for (uint256 i = 0; i < n; i++) {
+ uint256 id = invoiceIds[i];
+ require(id < invoices.length, "Invalid id");
+
+ InvoiceDetails storage inv = invoices[id];
+
+ if (msg.sender != inv.to) revert NotAuthorizedPayer();
+ if (inv.isPaid || inv.isCancelled) revert AlreadySettled();
+ if (inv.tokenAddress != token) revert MixedTokenBatch();
+
+ totalAmounts += inv.amountDue;
+ }
+
+ // Effects: mark all paid & bump fee accumulator BEFORE interactions
+ for (uint256 i = 0; i < n; i++) {
+ invoices[invoiceIds[i]].isPaid = true;
+ }
+ accumulatedFees += totalNativeFee;
+
+ // Interactions
+ if (token == address(0)) {
+ // Native: must include amounts + total fee
+ if (msg.value != (totalAmounts + totalNativeFee)) revert IncorrectNativeValue();
+
+ // Pay each issuer
+ for (uint256 i = 0; i < n; i++) {
+ InvoiceDetails storage inv = invoices[invoiceIds[i]];
+ (bool sent, ) = payable(inv.from).call{value: inv.amountDue}("");
+ require(sent, "Native transfer failed");
+ emit InvoicePaid(inv.id, inv.from, inv.to, inv.amountDue, address(0));
+ }
+ } else {
+ // ERC-20: fee in native, token from allowance
+ if (msg.value != totalNativeFee) revert IncorrectNativeValue();
+
+ IERC20 erc20 = IERC20(token);
+ if (erc20.allowance(msg.sender, address(this)) < totalAmounts) {
+ revert InsufficientAllowance();
+ }
+
+ for (uint256 i = 0; i < n; i++) {
+ InvoiceDetails storage inv = invoices[invoiceIds[i]];
+ bool ok = erc20.transferFrom(msg.sender, inv.from, inv.amountDue);
+ require(ok, "Token transfer failed");
+ emit InvoicePaid(inv.id, inv.from, inv.to, inv.amountDue, token);
+ }
+ }
+
+ // Emit batch summary; dynamic array emitted from memory
+ uint256[] memory idsCopy = new uint256[](n);
+ for (uint256 i = 0; i < n; i++) idsCopy[i] = invoiceIds[i];
+
+ emit InvoiceBatchPaid(msg.sender, token, n, totalAmounts, idsCopy);
+ }
+
+ // ========== Views ==========
function getPaymentStatus(
uint256 invoiceId,
address payer
@@ -182,46 +328,38 @@ contract Chainvoice {
{
require(invoiceId < invoices.length, "Invalid invoice ID");
InvoiceDetails memory invoice = invoices[invoiceId];
+
if (invoice.isCancelled) {
- return (false, (payer).balance, 0);
+ return (false, payer.balance, 0);
}
if (invoice.tokenAddress == address(0)) {
+ // Native
return (
payer.balance >= invoice.amountDue + fee,
payer.balance,
- type(uint256).max // Native token has no allowance
+ type(uint256).max // Native has no allowance
);
} else {
+ uint256 bal = IERC20(invoice.tokenAddress).balanceOf(payer);
+ uint256 allw = IERC20(invoice.tokenAddress).allowance(payer, address(this));
return (
- IERC20(invoice.tokenAddress).balanceOf(payer) >=
- invoice.amountDue &&
- IERC20(invoice.tokenAddress).allowance(
- payer,
- address(this)
- ) >=
- invoice.amountDue,
- IERC20(invoice.tokenAddress).balanceOf(payer),
- IERC20(invoice.tokenAddress).allowance(payer, address(this))
+ bal >= invoice.amountDue && allw >= invoice.amountDue,
+ bal,
+ allw
);
}
}
- function getSentInvoices(
- address user
- ) external view returns (InvoiceDetails[] memory) {
+ function getSentInvoices(address user) external view returns (InvoiceDetails[] memory) {
return _getInvoices(sentInvoices[user]);
}
- function getReceivedInvoices(
- address user
- ) external view returns (InvoiceDetails[] memory) {
+ function getReceivedInvoices(address user) external view returns (InvoiceDetails[] memory) {
return _getInvoices(receivedInvoices[user]);
}
- function _getInvoices(
- uint256[] storage ids
- ) internal view returns (InvoiceDetails[] memory) {
+ function _getInvoices(uint256[] storage ids) internal view returns (InvoiceDetails[] memory) {
InvoiceDetails[] memory result = new InvoiceDetails[](ids.length);
for (uint256 i = 0; i < ids.length; i++) {
result[i] = invoices[ids[i]];
@@ -229,13 +367,12 @@ contract Chainvoice {
return result;
}
- function getInvoice(
- uint256 invoiceId
- ) external view returns (InvoiceDetails memory) {
+ function getInvoice(uint256 invoiceId) external view returns (InvoiceDetails memory) {
require(invoiceId < invoices.length, "Invalid ID");
return invoices[invoiceId];
}
+ // ========== Admin ==========
function setFeeAmount(uint256 _fee) external onlyOwner {
fee = _fee;
}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 67637337..3c0898db 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -33,6 +33,8 @@ export const config = getDefaultConfig({
const queryClient = new QueryClient();
import { Toaster } from "react-hot-toast";
import GenerateLink from "./page/GenerateLink";
+import CreateInvoicesBatch from "./page/CreateInvoicesBatch";
+import BatchPayment from "./page/BatchPayment"; // New import needed
function App() {
return (
@@ -83,7 +85,11 @@ function App() {
+ Select and pay multiple invoices in one transaction +
+Loading invoices...
+{error}
++ You don't have any received invoices yet. +
+| + 0 + } + onChange={(e) => { + if (e.target.checked) { + handleSelectAll(); + } else { + handleClearAll(); + } + }} + /> + | ++ Client + | ++ Sender + | ++ Amount + | ++ Status + | ++ Date + | ++ Actions + | +
|---|---|---|---|---|---|---|
| + handleSelectInvoice(invoice.id)} + disabled={invoice.isPaid || invoice.isCancelled} + /> + | +
+
+
+
+
+ {invoice.user?.fname?.charAt(0) || "C"}
+
+
+
+
+
+ {invoice.user?.fname} {invoice.user?.lname}
+
+
+ {invoice.user?.email}
+
+ {/* Batch indicator */}
+ {invoice.batchInfo && (
+
+
+ )}
+
+
+ |
+ + + {formatAddress(invoice.user?.address)} + + | +
+
+ {invoice.paymentToken?.logo ? (
+
+ |
+
+ {invoice.isCancelled ? (
+
+ |
+ + {formatDate(invoice.issueDate)} + | +
+
+
+
+ {/* Pay Entire Batch Button */}
+ {invoice.batchInfo &&
+ !invoice.isPaid &&
+ !invoice.isCancelled && (
+
+ )}
+
+ {!invoice.isPaid && !invoice.isCancelled && (
+
+ )}
+ {invoice.isCancelled && (
+
+ )}
+
+ |
+
+ + Cha + + in + + voice +
++ Powered by Chainvoice +
++ # + {drawerState.selectedInvoice.id + .toString() + .padStart(6, "0")} +
++ {drawerState.selectedInvoice.user.fname}{" "} + {drawerState.selectedInvoice.user.lname} +
++ {drawerState.selectedInvoice.user.address} +
++ {drawerState.selectedInvoice.user.city},{" "} + {drawerState.selectedInvoice.user.country},{" "} + {drawerState.selectedInvoice.user.postalcode} +
++ {drawerState.selectedInvoice.user.email} +
++ {drawerState.selectedInvoice.client.fname}{" "} + {drawerState.selectedInvoice.client.lname} +
++ {drawerState.selectedInvoice.client.address} +
++ {drawerState.selectedInvoice.client.city},{" "} + {drawerState.selectedInvoice.client.country},{" "} + {drawerState.selectedInvoice.client.postalcode} +
++ {drawerState.selectedInvoice.client.email} +
++ {drawerState.selectedInvoice.paymentToken?.name || + "Ether "} + {"("} + {drawerState.selectedInvoice.paymentToken?.symbol || + "ETH"} + {")"} +
++ {drawerState.selectedInvoice.paymentToken?.address + ? `${drawerState.selectedInvoice.paymentToken.address.substring( + 0, + 10 + )}......${drawerState.selectedInvoice.paymentToken.address.substring( + 33 + )}` + : "Native Currency"} +
++ Decimals:{" "} + {drawerState.selectedInvoice.paymentToken.decimals || + 18} +
+Chain: Sepolia Testnet
+| Description | +Qty | +Price | +Discount | +Tax | +Amount | +
|---|---|---|---|---|---|
| {item.description} | +{item.qty} | ++ {item.unitPrice}{" "} + {drawerState.selectedInvoice.paymentToken?.symbol} + | ++ {item.discount || "0"} + | ++ {item.tax || "0%"} + | ++ {item.amount}{" "} + {drawerState.selectedInvoice.paymentToken?.symbol} + | +
+ Create multiple invoices in a single transaction and save on gas + fees +
+{error}
+{error}
Powered by Chainvoice
@@ -1075,19 +1823,21 @@ function ReceivedInvoice() {